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; 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 /** * 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; 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) pos += 4; // 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; } } /** * 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 * * @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) { if (enableLogging) { console.log(`[SNI Extraction] Found standard SNI: ${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'); } // Try to extract from PSK extension (TLS 1.3 resumption) const pskSni = this.extractSNIFromPSKExtension(buffer, enableLogging); if (pskSni) { if (enableLogging) { console.log(`[SNI Extraction] Extracted SNI from PSK extension: ${pskSni}`); } return pskSni; } // Could add more browser-specific heuristics here if needed if (enableLogging) { console.log('[SNI Extraction] Failed to extract SNI from resumption mechanisms'); } } return undefined; } }