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.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);
|
|
});
|
|
});
|
|
}
|
|
} |