smartproxy/ts/tls/sni/sni-extraction.ts

353 lines
12 KiB
TypeScript
Raw Normal View History

2025-05-09 17:00:27 +00:00
import { Buffer } from 'buffer';
import { TlsExtensionType, TlsUtils } from '../utils/tls-utils.js';
import {
ClientHelloParser,
type LoggerFunction
} from './client-hello-parser.js';
/**
* Connection tracking information
*/
export interface ConnectionInfo {
sourceIp: string;
sourcePort: number;
destIp: string;
destPort: number;
timestamp?: number;
}
/**
* Utilities for extracting SNI information from TLS handshakes
*/
export class SniExtraction {
/**
* Extracts the SNI (Server Name Indication) from a TLS ClientHello message.
*
* @param buffer The buffer containing the TLS ClientHello message
* @param logger Optional logging function
* @returns The extracted server name or undefined if not found
*/
public static extractSNI(buffer: Buffer, logger?: LoggerFunction): string | undefined {
const log = logger || (() => {});
try {
// Parse the ClientHello
const parseResult = ClientHelloParser.parseClientHello(buffer, logger);
if (!parseResult.isValid) {
log(`Failed to parse ClientHello: ${parseResult.error}`);
return undefined;
}
// Check if ServerName extension was found
if (parseResult.serverNameList && parseResult.serverNameList.length > 0) {
// Use the first hostname (most common case)
const serverName = parseResult.serverNameList[0];
log(`Found SNI: ${serverName}`);
return serverName;
}
log('No SNI extension found in ClientHello');
return undefined;
} catch (error) {
log(`Error extracting SNI: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}
/**
* Attempts to extract SNI from the PSK extension in a TLS 1.3 ClientHello.
*
* In TLS 1.3, when a client attempts to resume a session, it may include
* the server name in the PSK identity hint rather than in the SNI extension.
*
* @param buffer The buffer containing the TLS ClientHello message
* @param logger Optional logging function
* @returns The extracted server name or undefined if not found
*/
public static extractSNIFromPSKExtension(
buffer: Buffer,
logger?: LoggerFunction
): string | undefined {
const log = logger || (() => {});
try {
// Ensure this is a ClientHello
if (!TlsUtils.isClientHello(buffer)) {
log('Not a ClientHello message');
return undefined;
}
// Parse the ClientHello to find PSK extension
const parseResult = ClientHelloParser.parseClientHello(buffer, logger);
if (!parseResult.isValid || !parseResult.extensions) {
return undefined;
}
// Find the PSK extension
const pskExtension = parseResult.extensions.find(ext =>
ext.type === TlsExtensionType.PRE_SHARED_KEY);
if (!pskExtension) {
log('No PSK extension found');
return undefined;
}
// Parse the PSK extension data
const data = pskExtension.data;
// PSK extension structure:
// 2 bytes: identities list length
if (data.length < 2) return undefined;
const identitiesLength = (data[0] << 8) + data[1];
let pos = 2;
// End of identities list
const identitiesEnd = pos + identitiesLength;
if (identitiesEnd > data.length) return undefined;
// Process each PSK identity
while (pos + 2 <= identitiesEnd) {
// Identity length (2 bytes)
if (pos + 2 > identitiesEnd) break;
const identityLength = (data[pos] << 8) + data[pos + 1];
pos += 2;
if (pos + identityLength > identitiesEnd) break;
// Try to extract hostname from identity
// Chrome often embeds the hostname in the PSK identity
// This is a heuristic as there's no standard format
if (identityLength > 0) {
const identity = data.slice(pos, pos + identityLength);
// Skip identity bytes
pos += identityLength;
// Skip obfuscated ticket age (4 bytes)
if (pos + 4 <= identitiesEnd) {
pos += 4;
} else {
break;
}
// Try to parse the identity as UTF-8
try {
const identityStr = identity.toString('utf8');
log(`PSK identity: ${identityStr}`);
// Check if the identity contains hostname hints
// Chrome often embeds the hostname in a known format
// Try to extract using common patterns
// Pattern 1: Look for domain name pattern
const domainPattern =
/([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?/i;
const domainMatch = identityStr.match(domainPattern);
if (domainMatch && domainMatch[0]) {
log(`Found domain in PSK identity: ${domainMatch[0]}`);
return domainMatch[0];
}
// Pattern 2: Chrome sometimes uses a specific format with delimiters
// This is a heuristic approach since the format isn't standardized
const parts = identityStr.split('|');
if (parts.length > 1) {
for (const part of parts) {
if (part.includes('.') && !part.includes('/')) {
const possibleDomain = part.trim();
if (/^[a-z0-9.-]+$/i.test(possibleDomain)) {
log(`Found possible domain in PSK delimiter format: ${possibleDomain}`);
return possibleDomain;
}
}
}
}
} catch (e) {
log('Failed to parse PSK identity as UTF-8');
}
}
}
log('No hostname found in PSK extension');
return undefined;
} catch (error) {
log(`Error parsing PSK: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}
/**
* Main entry point for SNI extraction with support for fragmented messages
* and session resumption edge cases.
*
* @param buffer The buffer containing TLS data
* @param connectionInfo Connection tracking information
* @param logger Optional logging function
* @param cachedSni Optional previously cached SNI value
* @returns The extracted server name or undefined
*/
public static extractSNIWithResumptionSupport(
buffer: Buffer,
connectionInfo?: ConnectionInfo,
logger?: LoggerFunction,
cachedSni?: string
): string | undefined {
const log = logger || (() => {});
// Log buffer details for debugging
if (logger) {
log(`Buffer size: ${buffer.length} bytes`);
log(`Buffer starts with: ${buffer.slice(0, Math.min(10, buffer.length)).toString('hex')}`);
if (buffer.length >= 5) {
const recordType = buffer[0];
const majorVersion = buffer[1];
const minorVersion = buffer[2];
const recordLength = (buffer[3] << 8) + buffer[4];
log(
`TLS Record: type=${recordType}, version=${majorVersion}.${minorVersion}, length=${recordLength}`
);
}
}
// Check if we need to handle fragmented packets
let processBuffer = buffer;
if (connectionInfo) {
const connectionId = TlsUtils.createConnectionId(connectionInfo);
const reassembledBuffer = ClientHelloParser.handleFragmentedClientHello(
buffer,
connectionId,
logger
);
if (!reassembledBuffer) {
log(`Waiting for more fragments on connection ${connectionId}`);
return undefined; // Need more fragments to complete ClientHello
}
processBuffer = reassembledBuffer;
log(`Using reassembled buffer of length ${processBuffer.length}`);
}
// First try the standard SNI extraction
const standardSni = this.extractSNI(processBuffer, logger);
if (standardSni) {
log(`Found standard SNI: ${standardSni}`);
return standardSni;
}
// Check for session resumption when standard SNI extraction fails
if (TlsUtils.isClientHello(processBuffer)) {
const resumptionInfo = ClientHelloParser.hasSessionResumption(processBuffer, logger);
if (resumptionInfo.isResumption) {
log(`Detected session resumption in ClientHello without standard SNI`);
// Try to extract SNI from PSK extension
const pskSni = this.extractSNIFromPSKExtension(processBuffer, logger);
if (pskSni) {
log(`Extracted SNI from PSK extension: ${pskSni}`);
return pskSni;
}
}
}
// If cached SNI was provided, use it for application data packets
if (cachedSni && TlsUtils.isTlsApplicationData(buffer)) {
log(`Using provided cached SNI for application data: ${cachedSni}`);
return cachedSni;
}
return undefined;
}
/**
* Unified method for processing a TLS packet and extracting SNI.
* Main entry point for SNI extraction that handles all edge cases.
*
* @param buffer The buffer containing TLS data
* @param connectionInfo Connection tracking information
* @param logger Optional logging function
* @param cachedSni Optional previously cached SNI value
* @returns The extracted server name or undefined
*/
public static processTlsPacket(
buffer: Buffer,
connectionInfo: ConnectionInfo,
logger?: LoggerFunction,
cachedSni?: string
): string | undefined {
const log = logger || (() => {});
// Add timestamp if not provided
if (!connectionInfo.timestamp) {
connectionInfo.timestamp = Date.now();
}
// Check if this is a TLS handshake or application data
if (!TlsUtils.isTlsHandshake(buffer) && !TlsUtils.isTlsApplicationData(buffer)) {
log('Not a TLS handshake or application data packet');
return undefined;
}
// Create connection ID for tracking
const connectionId = TlsUtils.createConnectionId(connectionInfo);
log(`Processing TLS packet for connection ${connectionId}, buffer length: ${buffer.length}`);
// Handle application data with cached SNI (for connection racing)
if (TlsUtils.isTlsApplicationData(buffer)) {
// If explicit cachedSni was provided, use it
if (cachedSni) {
log(`Using provided cached SNI for application data: ${cachedSni}`);
return cachedSni;
}
log('Application data packet without cached SNI, cannot determine hostname');
return undefined;
}
// Enhanced session resumption detection
if (TlsUtils.isClientHello(buffer)) {
const resumptionInfo = ClientHelloParser.hasSessionResumption(buffer, logger);
if (resumptionInfo.isResumption) {
log(`Session resumption detected in TLS packet`);
// Always try standard SNI extraction first
const standardSni = this.extractSNI(buffer, logger);
if (standardSni) {
log(`Found standard SNI in session resumption: ${standardSni}`);
return standardSni;
}
// Enhanced session resumption SNI extraction
// Try extracting from PSK identity
const pskSni = this.extractSNIFromPSKExtension(buffer, logger);
if (pskSni) {
log(`Extracted SNI from PSK extension: ${pskSni}`);
return pskSni;
}
log(`Session resumption without extractable SNI`);
}
}
// For handshake messages, try the full extraction process
const sni = this.extractSNIWithResumptionSupport(buffer, connectionInfo, logger);
if (sni) {
log(`Successfully extracted SNI: ${sni}`);
return sni;
}
// If we couldn't extract an SNI, check if this is a valid ClientHello
if (TlsUtils.isClientHello(buffer)) {
log('Valid ClientHello detected, but no SNI extracted - might need more data');
}
return undefined;
}
}