2025-05-22 10:18:02 +00:00

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.js';
import type {
ISmtpConnection,
ISmtpClientOptions,
ConnectionState
} from './interfaces.js';
import { CONNECTION_STATES } from './constants.js';
import { logTLS, logDebug } from './utils/logging.js';
import { isSuccessCode } from './utils/helpers.js';
import type { CommandHandler } from './command-handler.js';
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);
});
});
}
}