import { Buffer } from 'buffer'; /** * SNI (Server Name Indication) handler for TLS connections. * Provides robust extraction of SNI values from TLS ClientHello messages. */ export class SniHandler { // TLS record types and constants private static readonly TLS_HANDSHAKE_RECORD_TYPE = 22; 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; /** * 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 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; } /** * 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; // 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 { // Skip this extension pos += extensionLength; } } // Log if we found a session ticket but no SNI if (hasSessionTicket) { log('Session ticket present but no SNI found - possible resumption scenario'); } 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 an initial ClientHello packet and handles * session resumption edge cases more robustly than the standard extraction. * * This method is specifically designed for Chrome and other browsers that * may send different ClientHello formats during session resumption. * * @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 extractSNIWithResumptionSupport( buffer: Buffer, enableLogging: boolean = false ): string | undefined { // First try the standard SNI extraction const standardSni = this.extractSNI(buffer, enableLogging); if (standardSni) { return standardSni; } // If standard extraction failed and we have a valid ClientHello, // this might be a session resumption with non-standard format if (this.isClientHello(buffer)) { if (enableLogging) { console.log('[SNI Extraction] Detected ClientHello without standard SNI, possible session resumption'); } // Additional handling could be implemented here for specific browser behaviors // For now, this is a placeholder for future improvements } return undefined; } }