254 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			254 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | /** | ||
|  |  * SMTP Client TLS Handler | ||
|  |  * TLS and STARTTLS client functionality | ||
|  |  */ | ||
|  | 
 | ||
|  | import * as tls from 'node:tls'; | ||
|  | import * as net from 'node:net'; | ||
|  | import { DEFAULTS } from './constants.ts'; | ||
|  | import type {  | ||
|  |   ISmtpConnection,  | ||
|  |   ISmtpClientOptions, | ||
|  |   ConnectionState  | ||
|  | } from './interfaces.ts'; | ||
|  | import { CONNECTION_STATES } from './constants.ts'; | ||
|  | import { logTLS, logDebug } from './utils/logging.ts'; | ||
|  | import { isSuccessCode } from './utils/helpers.ts'; | ||
|  | import type { CommandHandler } from './command-handler.ts'; | ||
|  | 
 | ||
|  | export class TlsHandler { | ||
|  |   private options: ISmtpClientOptions; | ||
|  |   private commandHandler: CommandHandler; | ||
|  |    | ||
|  |   constructor(options: ISmtpClientOptions, commandHandler: CommandHandler) { | ||
|  |     this.options = options; | ||
|  |     this.commandHandler = commandHandler; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Upgrade connection to TLS using STARTTLS | ||
|  |    */ | ||
|  |   public async upgradeToTLS(connection: ISmtpConnection): Promise<void> { | ||
|  |     if (connection.secure) { | ||
|  |       logDebug('Connection already secure', this.options); | ||
|  |       return; | ||
|  |     } | ||
|  |      | ||
|  |     // Check if STARTTLS is supported
 | ||
|  |     if (!connection.capabilities?.starttls) { | ||
|  |       throw new Error('Server does not support STARTTLS'); | ||
|  |     } | ||
|  |      | ||
|  |     logTLS('starttls_start', this.options); | ||
|  |      | ||
|  |     try { | ||
|  |       // Send STARTTLS command
 | ||
|  |       const response = await this.commandHandler.sendStartTls(connection); | ||
|  |        | ||
|  |       if (!isSuccessCode(response.code)) { | ||
|  |         throw new Error(`STARTTLS command failed: ${response.message}`); | ||
|  |       } | ||
|  |        | ||
|  |       // Upgrade the socket to TLS
 | ||
|  |       await this.performTLSUpgrade(connection); | ||
|  |        | ||
|  |       // Clear capabilities as they may have changed after TLS
 | ||
|  |       connection.capabilities = undefined; | ||
|  |       connection.secure = true; | ||
|  |        | ||
|  |       logTLS('starttls_success', this.options); | ||
|  |        | ||
|  |     } catch (error) { | ||
|  |       logTLS('starttls_failure', this.options, { error }); | ||
|  |       throw error; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Create a direct TLS connection | ||
|  |    */ | ||
|  |   public async createTLSConnection(host: string, port: number): Promise<tls.TLSSocket> { | ||
|  |     return new Promise((resolve, reject) => { | ||
|  |       const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT; | ||
|  |        | ||
|  |       const tlsOptions: tls.ConnectionOptions = { | ||
|  |         host, | ||
|  |         port, | ||
|  |         ...this.options.tls, | ||
|  |         // Default TLS options for email
 | ||
|  |         secureProtocol: 'TLS_method', | ||
|  |         ciphers: 'HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA', | ||
|  |         rejectUnauthorized: this.options.tls?.rejectUnauthorized !== false | ||
|  |       }; | ||
|  |        | ||
|  |       logTLS('tls_connected', this.options, { host, port }); | ||
|  |        | ||
|  |       const socket = tls.connect(tlsOptions); | ||
|  |        | ||
|  |       const timeoutHandler = setTimeout(() => { | ||
|  |         socket.destroy(); | ||
|  |         reject(new Error(`TLS connection timeout after ${timeout}ms`)); | ||
|  |       }, timeout); | ||
|  |        | ||
|  |       socket.once('secureConnect', () => { | ||
|  |         clearTimeout(timeoutHandler); | ||
|  |          | ||
|  |         if (!socket.authorized && this.options.tls?.rejectUnauthorized !== false) { | ||
|  |           socket.destroy(); | ||
|  |           reject(new Error(`TLS certificate verification failed: ${socket.authorizationError}`)); | ||
|  |           return; | ||
|  |         } | ||
|  |          | ||
|  |         logDebug('TLS connection established', this.options, { | ||
|  |           authorized: socket.authorized, | ||
|  |           protocol: socket.getProtocol(), | ||
|  |           cipher: socket.getCipher() | ||
|  |         }); | ||
|  |          | ||
|  |         resolve(socket); | ||
|  |       }); | ||
|  |        | ||
|  |       socket.once('error', (error) => { | ||
|  |         clearTimeout(timeoutHandler); | ||
|  |         reject(error); | ||
|  |       }); | ||
|  |     }); | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Validate TLS certificate | ||
|  |    */ | ||
|  |   public validateCertificate(socket: tls.TLSSocket): boolean { | ||
|  |     if (!socket.authorized) { | ||
|  |       logDebug('TLS certificate not authorized', this.options, { | ||
|  |         error: socket.authorizationError | ||
|  |       }); | ||
|  |        | ||
|  |       // Allow self-signed certificates if explicitly configured
 | ||
|  |       if (this.options.tls?.rejectUnauthorized === false) { | ||
|  |         logDebug('Accepting unauthorized certificate (rejectUnauthorized: false)', this.options); | ||
|  |         return true; | ||
|  |       } | ||
|  |        | ||
|  |       return false; | ||
|  |     } | ||
|  |      | ||
|  |     const cert = socket.getPeerCertificate(); | ||
|  |     if (!cert) { | ||
|  |       logDebug('No peer certificate available', this.options); | ||
|  |       return false; | ||
|  |     } | ||
|  |      | ||
|  |     // Additional certificate validation
 | ||
|  |     const now = new Date(); | ||
|  |     if (cert.valid_from && new Date(cert.valid_from) > now) { | ||
|  |       logDebug('Certificate not yet valid', this.options, { validFrom: cert.valid_from }); | ||
|  |       return false; | ||
|  |     } | ||
|  |      | ||
|  |     if (cert.valid_to && new Date(cert.valid_to) < now) { | ||
|  |       logDebug('Certificate expired', this.options, { validTo: cert.valid_to }); | ||
|  |       return false; | ||
|  |     } | ||
|  |      | ||
|  |     logDebug('TLS certificate validated', this.options, { | ||
|  |       subject: cert.subject, | ||
|  |       issuer: cert.issuer, | ||
|  |       validFrom: cert.valid_from, | ||
|  |       validTo: cert.valid_to | ||
|  |     }); | ||
|  |      | ||
|  |     return true; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Get TLS connection information | ||
|  |    */ | ||
|  |   public getTLSInfo(socket: tls.TLSSocket): any { | ||
|  |     if (!(socket instanceof tls.TLSSocket)) { | ||
|  |       return null; | ||
|  |     } | ||
|  |      | ||
|  |     return { | ||
|  |       authorized: socket.authorized, | ||
|  |       authorizationError: socket.authorizationError, | ||
|  |       protocol: socket.getProtocol(), | ||
|  |       cipher: socket.getCipher(), | ||
|  |       peerCertificate: socket.getPeerCertificate(), | ||
|  |       alpnProtocol: socket.alpnProtocol | ||
|  |     }; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Check if TLS upgrade is required or recommended | ||
|  |    */ | ||
|  |   public shouldUseTLS(connection: ISmtpConnection): boolean { | ||
|  |     // Already secure
 | ||
|  |     if (connection.secure) { | ||
|  |       return false; | ||
|  |     } | ||
|  |      | ||
|  |     // Direct TLS connection configured
 | ||
|  |     if (this.options.secure) { | ||
|  |       return false; // Already handled in connection establishment
 | ||
|  |     } | ||
|  |      | ||
|  |     // STARTTLS available and not explicitly disabled
 | ||
|  |     if (connection.capabilities?.starttls) { | ||
|  |       return this.options.tls !== null && this.options.tls !== undefined; // Use TLS if configured
 | ||
|  |     } | ||
|  |      | ||
|  |     return false; | ||
|  |   } | ||
|  |    | ||
|  |   private async performTLSUpgrade(connection: ISmtpConnection): Promise<void> { | ||
|  |     return new Promise((resolve, reject) => { | ||
|  |       const plainSocket = connection.socket as net.Socket; | ||
|  |       const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT; | ||
|  |        | ||
|  |       const tlsOptions: tls.ConnectionOptions = { | ||
|  |         socket: plainSocket, | ||
|  |         host: this.options.host, | ||
|  |         ...this.options.tls, | ||
|  |         // Default TLS options for STARTTLS
 | ||
|  |         secureProtocol: 'TLS_method', | ||
|  |         ciphers: 'HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA', | ||
|  |         rejectUnauthorized: this.options.tls?.rejectUnauthorized !== false | ||
|  |       }; | ||
|  |        | ||
|  |       const timeoutHandler = setTimeout(() => { | ||
|  |         reject(new Error(`TLS upgrade timeout after ${timeout}ms`)); | ||
|  |       }, timeout); | ||
|  |        | ||
|  |       // Create TLS socket from existing connection
 | ||
|  |       const tlsSocket = tls.connect(tlsOptions); | ||
|  |        | ||
|  |       tlsSocket.once('secureConnect', () => { | ||
|  |         clearTimeout(timeoutHandler); | ||
|  |          | ||
|  |         // Validate certificate if required
 | ||
|  |         if (!this.validateCertificate(tlsSocket)) { | ||
|  |           tlsSocket.destroy(); | ||
|  |           reject(new Error('TLS certificate validation failed')); | ||
|  |           return; | ||
|  |         } | ||
|  |          | ||
|  |         // Replace the socket in the connection
 | ||
|  |         connection.socket = tlsSocket; | ||
|  |         connection.secure = true; | ||
|  |          | ||
|  |         logDebug('STARTTLS upgrade completed', this.options, { | ||
|  |           protocol: tlsSocket.getProtocol(), | ||
|  |           cipher: tlsSocket.getCipher() | ||
|  |         }); | ||
|  |          | ||
|  |         resolve(); | ||
|  |       }); | ||
|  |        | ||
|  |       tlsSocket.once('error', (error) => { | ||
|  |         clearTimeout(timeoutHandler); | ||
|  |         reject(error); | ||
|  |       }); | ||
|  |     }); | ||
|  |   } | ||
|  | } |