import { Buffer } from 'buffer'; /** * SNI (Server Name Indication) handler for TLS connections. * Provides robust extraction of SNI values from TLS ClientHello messages * with support for fragmented packets, TLS 1.3 resumption, Chrome-specific * connection behaviors, and tab hibernation/reactivation scenarios. */ export class SniHandler { // TLS record types and constants private static readonly TLS_HANDSHAKE_RECORD_TYPE = 22; private static readonly TLS_APPLICATION_DATA_TYPE = 23; // TLS Application Data record type private static readonly TLS_CLIENT_HELLO_HANDSHAKE_TYPE = 1; private static readonly TLS_SNI_EXTENSION_TYPE = 0x0000; private static readonly TLS_SESSION_TICKET_EXTENSION_TYPE = 0x0023; private static readonly TLS_SNI_HOST_NAME_TYPE = 0; private static readonly TLS_PSK_EXTENSION_TYPE = 0x0029; // Pre-Shared Key extension type for TLS 1.3 private static readonly TLS_PSK_KE_MODES_EXTENSION_TYPE = 0x002D; // PSK Key Exchange Modes private static readonly TLS_EARLY_DATA_EXTENSION_TYPE = 0x002A; // Early Data (0-RTT) extension // Buffer for handling fragmented ClientHello messages private static fragmentedBuffers: Map = new Map(); private static fragmentTimeout: number = 1000; // ms to wait for fragments before cleanup // Session tracking for tab reactivation scenarios private static sessionCache: Map = new Map(); // Longer timeout for session cache (24 hours by default) private static sessionCacheTimeout: number = 24 * 60 * 60 * 1000; // 24 hours in milliseconds // Cleanup interval for session cache (run every hour) private static sessionCleanupInterval: NodeJS.Timeout | null = null; /** * Initialize the session cache cleanup mechanism. * This should be called during application startup. */ public static initSessionCacheCleanup(): void { if (this.sessionCleanupInterval === null) { this.sessionCleanupInterval = setInterval(() => { this.cleanupSessionCache(); }, 60 * 60 * 1000); // Run every hour } } /** * Clean up expired entries from the session cache */ private static cleanupSessionCache(): void { const now = Date.now(); const expiredKeys: string[] = []; this.sessionCache.forEach((session, key) => { if (now - session.timestamp > this.sessionCacheTimeout) { expiredKeys.push(key); } }); expiredKeys.forEach(key => { this.sessionCache.delete(key); }); } /** * Create a client identity key for session tracking * Uses source IP and optional client random for uniqueness * * @param sourceIp - Client IP address * @param clientRandom - Optional TLS client random value * @returns A string key for the session cache */ private static createClientKey(sourceIp: string, clientRandom?: Buffer): string { if (clientRandom) { // If we have the client random, use it for more precise tracking return `${sourceIp}:${clientRandom.toString('hex')}`; } // Fall back to just IP-based tracking return sourceIp; } /** * Store SNI information in the session cache * * @param sourceIp - Client IP address * @param sni - The extracted SNI value * @param clientRandom - Optional TLS client random value */ private static cacheSession(sourceIp: string, sni: string, clientRandom?: Buffer): void { const key = this.createClientKey(sourceIp, clientRandom); this.sessionCache.set(key, { sni, timestamp: Date.now(), clientRandom }); } /** * Retrieve SNI information from the session cache * * @param sourceIp - Client IP address * @param clientRandom - Optional TLS client random value * @returns The cached SNI or undefined if not found */ private static getCachedSession(sourceIp: string, clientRandom?: Buffer): string | undefined { // Try with client random first for precision if (clientRandom) { const preciseKey = this.createClientKey(sourceIp, clientRandom); const preciseSession = this.sessionCache.get(preciseKey); if (preciseSession) { return preciseSession.sni; } } // Fall back to IP-only lookup const ipKey = this.createClientKey(sourceIp); const session = this.sessionCache.get(ipKey); if (session) { // Update the timestamp to keep the session alive session.timestamp = Date.now(); return session.sni; } return undefined; } /** * Extract the client random value from a ClientHello message * * @param buffer - The buffer containing the ClientHello * @returns The 32-byte client random or undefined if extraction fails */ private static extractClientRandom(buffer: Buffer): Buffer | undefined { try { if (!this.isClientHello(buffer) || buffer.length < 46) { return undefined; } // In a ClientHello message, the client random starts at position 11 // after record header (5 bytes), handshake type (1 byte), // handshake length (3 bytes), and client version (2 bytes) return buffer.slice(11, 11 + 32); } catch (error) { return undefined; } } /** * Checks if a buffer contains a TLS handshake message (record type 22) * @param buffer - The buffer to check * @returns true if the buffer starts with a TLS handshake record type */ public static isTlsHandshake(buffer: Buffer): boolean { return buffer.length > 0 && buffer[0] === this.TLS_HANDSHAKE_RECORD_TYPE; } /** * Checks if a buffer contains TLS application data (record type 23) * @param buffer - The buffer to check * @returns true if the buffer starts with a TLS application data record type */ public static isTlsApplicationData(buffer: Buffer): boolean { return buffer.length > 0 && buffer[0] === this.TLS_APPLICATION_DATA_TYPE; } /** * Creates a connection ID based on source/destination information * Used to track fragmented ClientHello messages across multiple packets * * @param connectionInfo - Object containing connection identifiers (IP/port) * @returns A string ID for the connection */ public static createConnectionId(connectionInfo: { sourceIp?: string; sourcePort?: number; destIp?: string; destPort?: number; }): string { const { sourceIp, sourcePort, destIp, destPort } = connectionInfo; return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`; } /** * Handles potential fragmented ClientHello messages by buffering and reassembling * TLS record fragments that might span multiple TCP packets. * * @param buffer - The current buffer fragment * @param connectionId - Unique identifier for the connection * @param enableLogging - Whether to enable logging * @returns A complete buffer if reassembly is successful, or undefined if more fragments are needed */ public static handleFragmentedClientHello( buffer: Buffer, connectionId: string, enableLogging: boolean = false ): Buffer | undefined { const log = (message: string) => { if (enableLogging) { console.log(`[SNI Fragment] ${message}`); } }; // Check if we've seen this connection before if (!this.fragmentedBuffers.has(connectionId)) { // New connection, start with this buffer this.fragmentedBuffers.set(connectionId, buffer); // Set timeout to clean up if we don't get a complete ClientHello setTimeout(() => { if (this.fragmentedBuffers.has(connectionId)) { this.fragmentedBuffers.delete(connectionId); log(`Connection ${connectionId} timed out waiting for complete ClientHello`); } }, this.fragmentTimeout); // Evaluate if this buffer already contains a complete ClientHello try { if (buffer.length >= 5) { const recordLength = (buffer[3] << 8) + buffer[4]; if (buffer.length >= recordLength + 5) { log(`Initial buffer contains complete ClientHello, length: ${buffer.length}`); return buffer; } } } catch (e) { log(`Error checking initial buffer completeness: ${e}`); } log(`Started buffering connection ${connectionId}, initial size: ${buffer.length}`); return undefined; // Need more fragments } else { // Existing connection, append this buffer const existingBuffer = this.fragmentedBuffers.get(connectionId)!; const newBuffer = Buffer.concat([existingBuffer, buffer]); this.fragmentedBuffers.set(connectionId, newBuffer); log(`Appended to buffer for ${connectionId}, new size: ${newBuffer.length}`); // Check if we now have a complete ClientHello try { if (newBuffer.length >= 5) { const recordLength = (newBuffer[3] << 8) + newBuffer[4]; if (newBuffer.length >= recordLength + 5) { log(`Assembled complete ClientHello, length: ${newBuffer.length}`); // Complete message received, remove from tracking this.fragmentedBuffers.delete(connectionId); return newBuffer; } } } catch (e) { log(`Error checking reassembled buffer completeness: ${e}`); } return undefined; // Still need more fragments } } /** * Checks if a buffer contains a TLS ClientHello message * @param buffer - The buffer to check * @returns true if the buffer appears to be a ClientHello message */ public static isClientHello(buffer: Buffer): boolean { // Minimum ClientHello size (TLS record header + handshake header) if (buffer.length < 9) { return false; } // Check record type (must be TLS_HANDSHAKE_RECORD_TYPE) if (buffer[0] !== this.TLS_HANDSHAKE_RECORD_TYPE) { return false; } // Skip version and length in TLS record header (5 bytes total) // Check handshake type at byte 5 (must be CLIENT_HELLO) return buffer[5] === this.TLS_CLIENT_HELLO_HANDSHAKE_TYPE; } /** * Detects characteristics of a tab reactivation TLS handshake * These often have specific patterns in Chrome and other browsers * * @param buffer - The buffer containing a ClientHello message * @param enableLogging - Whether to enable logging * @returns true if this appears to be a tab reactivation handshake */ public static isTabReactivationHandshake( buffer: Buffer, enableLogging: boolean = false ): boolean { const log = (message: string) => { if (enableLogging) { console.log(`[Tab Reactivation] ${message}`); } }; if (!this.isClientHello(buffer)) { return false; } try { // Check for session ID presence (tab reactivation often has a session ID) let pos = 5 + 1 + 3 + 2; // Position after handshake type, length and client version pos += 32; // Skip client random if (pos + 1 > buffer.length) return false; const sessionIdLength = buffer[pos]; // Non-empty session ID is a good indicator if (sessionIdLength > 0) { log(`Detected non-empty session ID (length: ${sessionIdLength})`); // Skip to extensions pos += 1 + sessionIdLength; // Skip cipher suites if (pos + 2 > buffer.length) return false; const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2 + cipherSuitesLength; // Skip compression methods if (pos + 1 > buffer.length) return false; const compressionMethodsLength = buffer[pos]; pos += 1 + compressionMethodsLength; // Check for extensions if (pos + 2 > buffer.length) return false; // Look for specific extensions that indicate tab reactivation const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; // Extensions end position const extensionsEnd = pos + extensionsLength; if (extensionsEnd > buffer.length) return false; // Tab reactivation often has session tickets but no SNI let hasSessionTicket = false; let hasSNI = false; let hasPSK = false; // Iterate through extensions while (pos + 4 <= extensionsEnd) { const extensionType = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; const extensionLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; if (extensionType === this.TLS_SESSION_TICKET_EXTENSION_TYPE) { hasSessionTicket = true; } else if (extensionType === this.TLS_SNI_EXTENSION_TYPE) { hasSNI = true; } else if (extensionType === this.TLS_PSK_EXTENSION_TYPE) { hasPSK = true; } // Skip extension data pos += extensionLength; } // Pattern for tab reactivation: session identifier + (ticket or PSK) but no SNI if ((hasSessionTicket || hasPSK) && !hasSNI) { log('Detected tab reactivation pattern: session resumption without SNI'); return true; } } } catch (error) { log(`Error checking for tab reactivation: ${error}`); } return false; } /** * Extracts the SNI (Server Name Indication) from a TLS ClientHello message. * Implements robust parsing with support for session resumption edge cases. * * @param buffer - The buffer containing the TLS ClientHello message * @param enableLogging - Whether to enable detailed debug logging * @returns The extracted server name or undefined if not found */ public static extractSNI(buffer: Buffer, enableLogging: boolean = false): string | undefined { // Logging helper const log = (message: string) => { if (enableLogging) { console.log(`[SNI Extraction] ${message}`); } }; try { // Buffer must be at least 5 bytes (TLS record header) if (buffer.length < 5) { log('Buffer too small for TLS record header'); return undefined; } // Check record type (must be TLS_HANDSHAKE_RECORD_TYPE = 22) if (buffer[0] !== this.TLS_HANDSHAKE_RECORD_TYPE) { log(`Not a TLS handshake record: ${buffer[0]}`); return undefined; } // Check TLS version const majorVersion = buffer[1]; const minorVersion = buffer[2]; log(`TLS version: ${majorVersion}.${minorVersion}`); // Parse record length (bytes 3-4, big-endian) const recordLength = (buffer[3] << 8) + buffer[4]; log(`Record length: ${recordLength}`); // Validate record length against buffer size if (buffer.length < recordLength + 5) { log('Buffer smaller than expected record length'); return undefined; } // Start of handshake message in the buffer let pos = 5; // Check handshake type (must be CLIENT_HELLO = 1) if (buffer[pos] !== this.TLS_CLIENT_HELLO_HANDSHAKE_TYPE) { log(`Not a ClientHello message: ${buffer[pos]}`); return undefined; } // Skip handshake type (1 byte) pos += 1; // Parse handshake length (3 bytes, big-endian) const handshakeLength = (buffer[pos] << 16) + (buffer[pos + 1] << 8) + buffer[pos + 2]; log(`Handshake length: ${handshakeLength}`); // Skip handshake length (3 bytes) pos += 3; // Check client version (2 bytes) const clientMajorVersion = buffer[pos]; const clientMinorVersion = buffer[pos + 1]; log(`Client version: ${clientMajorVersion}.${clientMinorVersion}`); // Skip client version (2 bytes) pos += 2; // Skip client random (32 bytes) pos += 32; // Parse session ID if (pos + 1 > buffer.length) { log('Buffer too small for session ID length'); return undefined; } const sessionIdLength = buffer[pos]; log(`Session ID length: ${sessionIdLength}`); // Skip session ID length (1 byte) and session ID pos += 1 + sessionIdLength; // Check if we have enough bytes left if (pos + 2 > buffer.length) { log('Buffer too small for cipher suites length'); return undefined; } // Parse cipher suites length (2 bytes, big-endian) const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1]; log(`Cipher suites length: ${cipherSuitesLength}`); // Skip cipher suites length (2 bytes) and cipher suites pos += 2 + cipherSuitesLength; // Check if we have enough bytes left if (pos + 1 > buffer.length) { log('Buffer too small for compression methods length'); return undefined; } // Parse compression methods length (1 byte) const compressionMethodsLength = buffer[pos]; log(`Compression methods length: ${compressionMethodsLength}`); // Skip compression methods length (1 byte) and compression methods pos += 1 + compressionMethodsLength; // Check if we have enough bytes for extensions length if (pos + 2 > buffer.length) { log('No extensions present or buffer too small'); return undefined; } // Parse extensions length (2 bytes, big-endian) const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1]; log(`Extensions length: ${extensionsLength}`); // Skip extensions length (2 bytes) pos += 2; // Extensions end position const extensionsEnd = pos + extensionsLength; // Check if extensions length is valid if (extensionsEnd > buffer.length) { log('Extensions length exceeds buffer size'); return undefined; } // Track if we found session tickets (for improved resumption handling) let hasSessionTicket = false; let hasPskExtension = false; // Iterate through extensions while (pos + 4 <= extensionsEnd) { // Parse extension type (2 bytes, big-endian) const extensionType = (buffer[pos] << 8) + buffer[pos + 1]; log(`Extension type: 0x${extensionType.toString(16).padStart(4, '0')}`); // Skip extension type (2 bytes) pos += 2; // Parse extension length (2 bytes, big-endian) const extensionLength = (buffer[pos] << 8) + buffer[pos + 1]; log(`Extension length: ${extensionLength}`); // Skip extension length (2 bytes) pos += 2; // Check if this is the SNI extension if (extensionType === this.TLS_SNI_EXTENSION_TYPE) { log('Found SNI extension'); // Ensure we have enough bytes for the server name list if (pos + 2 > extensionsEnd) { log('Extension too small for server name list length'); pos += extensionLength; // Skip this extension continue; } // Parse server name list length (2 bytes, big-endian) const serverNameListLength = (buffer[pos] << 8) + buffer[pos + 1]; log(`Server name list length: ${serverNameListLength}`); // Skip server name list length (2 bytes) pos += 2; // Ensure server name list length is valid if (pos + serverNameListLength > extensionsEnd) { log('Server name list length exceeds extension size'); break; // Exit the loop, extension parsing is broken } // End position of server name list const serverNameListEnd = pos + serverNameListLength; // Iterate through server names while (pos + 3 <= serverNameListEnd) { // Check name type (must be HOST_NAME_TYPE = 0 for hostname) const nameType = buffer[pos]; log(`Name type: ${nameType}`); if (nameType !== this.TLS_SNI_HOST_NAME_TYPE) { log(`Unsupported name type: ${nameType}`); pos += 1; // Skip name type (1 byte) // Skip name length (2 bytes) and name data if (pos + 2 <= serverNameListEnd) { const nameLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2 + nameLength; } else { log('Invalid server name entry'); break; } continue; } // Skip name type (1 byte) pos += 1; // Ensure we have enough bytes for name length if (pos + 2 > serverNameListEnd) { log('Server name entry too small for name length'); break; } // Parse name length (2 bytes, big-endian) const nameLength = (buffer[pos] << 8) + buffer[pos + 1]; log(`Name length: ${nameLength}`); // Skip name length (2 bytes) pos += 2; // Ensure we have enough bytes for the name if (pos + nameLength > serverNameListEnd) { log('Name length exceeds server name list size'); break; } // Extract server name (hostname) const serverName = buffer.slice(pos, pos + nameLength).toString('utf8'); log(`Extracted server name: ${serverName}`); return serverName; } } else if (extensionType === this.TLS_SESSION_TICKET_EXTENSION_TYPE) { // If we encounter a session ticket extension, mark it for later log('Found session ticket extension'); hasSessionTicket = true; pos += extensionLength; // Skip this extension } else if (extensionType === this.TLS_PSK_EXTENSION_TYPE) { // TLS 1.3 PSK extension - mark for resumption support log('Found PSK extension (TLS 1.3 resumption indicator)'); hasPskExtension = true; // We'll skip the extension here and process it separately if needed pos += extensionLength; } else { // Skip this extension pos += extensionLength; } } // Log if we found session resumption indicators but no SNI if (hasSessionTicket || hasPskExtension) { log('Session resumption indicators present but no SNI found'); } log('No SNI extension found in ClientHello'); return undefined; } catch (error) { log(`Error parsing 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 enableLogging - Whether to enable detailed debug logging * @returns The extracted server name or undefined if not found */ public static extractSNIFromPSKExtension( buffer: Buffer, enableLogging: boolean = false ): string | undefined { const log = (message: string) => { if (enableLogging) { console.log(`[PSK-SNI Extraction] ${message}`); } }; try { // Ensure this is a ClientHello if (!this.isClientHello(buffer)) { log('Not a ClientHello message'); return undefined; } // Find the start position of extensions let pos = 5; // Start after record header // Skip handshake type (1 byte) pos += 1; // Skip handshake length (3 bytes) pos += 3; // Skip client version (2 bytes) pos += 2; // Skip client random (32 bytes) pos += 32; // Skip session ID if (pos + 1 > buffer.length) return undefined; const sessionIdLength = buffer[pos]; pos += 1 + sessionIdLength; // Skip cipher suites if (pos + 2 > buffer.length) return undefined; const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2 + cipherSuitesLength; // Skip compression methods if (pos + 1 > buffer.length) return undefined; const compressionMethodsLength = buffer[pos]; pos += 1 + compressionMethodsLength; // Check if we have extensions if (pos + 2 > buffer.length) { log('No extensions present'); return undefined; } // Get extensions length const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; // Extensions end position const extensionsEnd = pos + extensionsLength; if (extensionsEnd > buffer.length) return undefined; // Look for PSK extension while (pos + 4 <= extensionsEnd) { const extensionType = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; const extensionLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; if (extensionType === this.TLS_PSK_EXTENSION_TYPE) { log('Found PSK extension'); // PSK extension structure: // 2 bytes: identities list length if (pos + 2 > extensionsEnd) break; const identitiesLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; // End of identities list const identitiesEnd = pos + identitiesLength; if (identitiesEnd > extensionsEnd) break; // Process each PSK identity while (pos + 2 <= identitiesEnd) { // Identity length (2 bytes) if (pos + 2 > identitiesEnd) break; const identityLength = (buffer[pos] << 8) + buffer[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 = buffer.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'); } } } } else { // Skip this extension pos += extensionLength; } } log('No hostname found in PSK extension'); return undefined; } catch (error) { log(`Error parsing PSK: ${error instanceof Error ? error.message : String(error)}`); return undefined; } } /** * Checks if the buffer contains TLS 1.3 early data (0-RTT) * @param buffer - The buffer to check * @param enableLogging - Whether to enable logging * @returns true if early data is detected */ public static hasEarlyData( buffer: Buffer, enableLogging: boolean = false ): boolean { const log = (message: string) => { if (enableLogging) { console.log(`[Early Data] ${message}`); } }; try { // Check if this is a valid ClientHello first if (!this.isClientHello(buffer)) { return false; } // Find the extensions section let pos = 5; // Start after record header // Skip handshake type (1 byte) pos += 1; // Skip handshake length (3 bytes) pos += 3; // Skip client version (2 bytes) pos += 2; // Skip client random (32 bytes) pos += 32; // Skip session ID if (pos + 1 > buffer.length) return false; const sessionIdLength = buffer[pos]; pos += 1 + sessionIdLength; // Skip cipher suites if (pos + 2 > buffer.length) return false; const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2 + cipherSuitesLength; // Skip compression methods if (pos + 1 > buffer.length) return false; const compressionMethodsLength = buffer[pos]; pos += 1 + compressionMethodsLength; // Check if we have extensions if (pos + 2 > buffer.length) return false; // Get extensions length const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; // Extensions end position const extensionsEnd = pos + extensionsLength; if (extensionsEnd > buffer.length) return false; // Look for early data extension while (pos + 4 <= extensionsEnd) { const extensionType = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; const extensionLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; if (extensionType === this.TLS_EARLY_DATA_EXTENSION_TYPE) { log('Early Data (0-RTT) extension detected'); return true; } // Skip to next extension pos += extensionLength; } return false; } catch (error) { log(`Error checking for early data: ${error}`); return false; } } /** * Attempts to extract SNI from an initial ClientHello packet and handles * session resumption edge cases more robustly than the standard extraction. * * This method handles: * 1. Standard SNI extraction * 2. TLS 1.3 PSK-based resumption (Chrome, Firefox, etc.) * 3. Session ticket-based resumption * 4. Fragmented ClientHello messages * 5. TLS 1.3 Early Data (0-RTT) * 6. Chrome's connection racing behaviors * 7. Tab reactivation patterns with session cache * * @param buffer - The buffer containing the TLS ClientHello message * @param connectionInfo - Optional connection information for fragment handling * @param enableLogging - Whether to enable detailed debug logging * @returns The extracted server name or undefined if not found */ public static extractSNIWithResumptionSupport( buffer: Buffer, connectionInfo?: { sourceIp?: string; sourcePort?: number; destIp?: string; destPort?: number; }, enableLogging: boolean = false ): string | undefined { const log = (message: string) => { if (enableLogging) { console.log(`[SNI Extraction] ${message}`); } }; // Check if we need to handle fragmented packets let processBuffer = buffer; if (connectionInfo) { const connectionId = this.createConnectionId(connectionInfo); const reassembledBuffer = this.handleFragmentedClientHello( buffer, connectionId, enableLogging ); 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, enableLogging); if (standardSni) { log(`Found standard SNI: ${standardSni}`); // If we extracted a standard SNI, cache it for future use if (connectionInfo?.sourceIp) { const clientRandom = this.extractClientRandom(processBuffer); this.cacheSession(connectionInfo.sourceIp, standardSni, clientRandom); log(`Cached SNI for future reference: ${standardSni}`); } return standardSni; } // Check for tab reactivation pattern const isTabReactivation = this.isTabReactivationHandshake(processBuffer, enableLogging); if (isTabReactivation && connectionInfo?.sourceIp) { // Try to get the SNI from our session cache for tab reactivation const cachedSni = this.getCachedSession(connectionInfo.sourceIp); if (cachedSni) { log(`Retrieved cached SNI for tab reactivation: ${cachedSni}`); return cachedSni; } log('Tab reactivation detected but no cached SNI found'); } // Check for TLS 1.3 early data (0-RTT) const hasEarly = this.hasEarlyData(processBuffer, enableLogging); if (hasEarly) { log('TLS 1.3 Early Data detected, trying session cache'); // For 0-RTT, check the session cache if (connectionInfo?.sourceIp) { const cachedSni = this.getCachedSession(connectionInfo.sourceIp); if (cachedSni) { log(`Retrieved cached SNI for 0-RTT: ${cachedSni}`); return cachedSni; } } } // If standard extraction failed and we have a valid ClientHello, // this might be a session resumption with non-standard format if (this.isClientHello(processBuffer)) { log('Detected ClientHello without standard SNI, possible session resumption'); // Try to extract from PSK extension (TLS 1.3 resumption) const pskSni = this.extractSNIFromPSKExtension(processBuffer, enableLogging); if (pskSni) { log(`Extracted SNI from PSK extension: ${pskSni}`); // Cache this SNI for future reference if (connectionInfo?.sourceIp) { const clientRandom = this.extractClientRandom(processBuffer); this.cacheSession(connectionInfo.sourceIp, pskSni, clientRandom); log(`Cached PSK-derived SNI: ${pskSni}`); } return pskSni; } // If we have a session ticket but no SNI or PSK identity, // check our session cache as a last resort if (connectionInfo?.sourceIp) { const cachedSni = this.getCachedSession(connectionInfo.sourceIp); if (cachedSni) { log(`Using cached SNI as last resort: ${cachedSni}`); return cachedSni; } } log('Failed to extract SNI from resumption mechanisms'); } return undefined; } /** * Main entry point for SNI extraction that handles all edge cases. * This should be called for each TLS packet received from a client. * * The method uses connection tracking to handle fragmented ClientHello * messages and various TLS 1.3 behaviors, including Chrome's connection * racing patterns and tab reactivation behaviors. * * @param buffer - The buffer containing TLS data * @param connectionInfo - Connection metadata (IPs and ports) * @param enableLogging - Whether to enable detailed debug logging * @param cachedSni - Optional cached SNI from previous connections (for racing detection) * @returns The extracted server name or undefined if not found or more data needed */ public static processTlsPacket( buffer: Buffer, connectionInfo: { sourceIp: string; sourcePort: number; destIp: string; destPort: number; timestamp?: number; }, enableLogging: boolean = false, cachedSni?: string ): string | undefined { const log = (message: string) => { if (enableLogging) { console.log(`[TLS Packet] ${message}`); } }; // Add timestamp if not provided if (!connectionInfo.timestamp) { connectionInfo.timestamp = Date.now(); } // Check if this is a TLS handshake or application data if (!this.isTlsHandshake(buffer) && !this.isTlsApplicationData(buffer)) { log('Not a TLS handshake or application data packet'); return undefined; } // Create connection ID for tracking const connectionId = this.createConnectionId(connectionInfo); log(`Processing TLS packet for connection ${connectionId}, buffer length: ${buffer.length}`); // Handle application data with cached SNI (for connection racing) if (this.isTlsApplicationData(buffer)) { // First check if explicit cachedSni was provided if (cachedSni) { log(`Using provided cached SNI for application data: ${cachedSni}`); return cachedSni; } // Otherwise check our session cache const sessionCachedSni = this.getCachedSession(connectionInfo.sourceIp); if (sessionCachedSni) { log(`Using session-cached SNI for application data: ${sessionCachedSni}`); return sessionCachedSni; } log('Application data packet without cached SNI, cannot determine hostname'); return undefined; } // For handshake messages, try the full extraction process const sni = this.extractSNIWithResumptionSupport( buffer, connectionInfo, enableLogging ); if (sni) { log(`Successfully extracted SNI: ${sni}`); return sni; } // If we couldn't extract an SNI, check if this is a valid ClientHello // If it is, but we couldn't get an SNI, it might be a fragment or // a connection race situation if (this.isClientHello(buffer)) { // Check if we have a cached session for this IP const sessionCachedSni = this.getCachedSession(connectionInfo.sourceIp); if (sessionCachedSni) { log(`Using session cache for ClientHello without SNI: ${sessionCachedSni}`); return sessionCachedSni; } log('Valid ClientHello detected, but no SNI extracted - might need more data'); } return undefined; } }