353 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			353 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | 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; | ||
|  |   } | ||
|  | } |