'use strict' const cluster = require('node:cluster') const crypto = require('node:crypto') const net = require('node:net') const path = require('node:path') const { spawn } = require('node:child_process') const stream = require('node:stream') const tls = require('node:tls') const util = require('node:util') // npm packages exports.config = require('haraka-config') // exported for tests const Notes = require('haraka-notes') const log = require('./logger') const certsByHost = new Notes() const ctxByHost = {} let ocsp let ocspCache // STARTTLS listeners with different requireAuthorized ports share these contexts; // a session ticket issued on one port must not skip another port's client-cert check. function createSecureContext(opts) { return tls.createSecureContext({ ...opts, secureOptions: (opts.secureOptions ?? 0) | crypto.constants.SSL_OP_NO_TICKET, }) } // provides a common socket for attaching // and detaching from either main socket, or crypto socket class pluggableStream extends stream.Stream { constructor(socket) { super() this.readable = this.writable = true this._timeout = 0 this._keepalive = false this._writeState = true this._pending = [] this._pendingCallbacks = [] if (socket) this.attach(socket) } pause() { if (this.targetsocket.pause) { this.targetsocket.pause() this.readable = false } } resume() { if (this.targetsocket.resume) { this.readable = true this.targetsocket.resume() } } attach(socket) { this.targetsocket = socket this.targetsocket.on('data', (data) => { this.emit('data', data) }) this.targetsocket.on('connect', (a, b) => { this.emit('connect', a, b) }) this.targetsocket.on('secureConnect', (a, b) => { this.emit('secureConnect', a, b) this.emit('secure', a, b) }) this.targetsocket.on('secure', (a, b) => { this.emit('secure', a, b) }) this.targetsocket.on('end', () => { this.writable = this.targetsocket.writable this.emit('end') }) this.targetsocket.on('close', (had_error) => { this.writable = this.targetsocket.writable this.emit('close', had_error) }) this.targetsocket.on('drain', () => { this.emit('drain') }) this.targetsocket.once('error', (exception) => { this.writable = this.targetsocket.writable exception.source = 'tls' if (this.listenerCount('error') > 0) this.emit('error', exception) }) this.targetsocket.on('timeout', () => { this.emit('timeout') }) if (this.targetsocket.remotePort) { this.remotePort = this.targetsocket.remotePort } if (this.targetsocket.remoteAddress) { this.remoteAddress = this.targetsocket.remoteAddress } if (this.targetsocket.localPort) { this.localPort = this.targetsocket.localPort } if (this.targetsocket.localAddress) { this.localAddress = this.targetsocket.localAddress } } clean() { if (this.targetsocket?.removeAllListeners) { for (const name of ['data', 'secure', 'secureConnect', 'end', 'close', 'error', 'drain']) { this.targetsocket.removeAllListeners(name) } } this.targetsocket = {} this.targetsocket.write = () => {} } write(data, encoding, callback) { if (this.targetsocket.write) { return this.targetsocket.write(data, encoding, callback) } return false } end(data, encoding) { if (this.targetsocket.end) { return this.targetsocket.end(data, encoding) } } destroySoon() { if (this.targetsocket.destroySoon) { return this.targetsocket.destroySoon() } } destroy() { if (this.targetsocket.destroy) { return this.targetsocket.destroy() } } setKeepAlive(bool) { this._keepalive = bool return this.targetsocket.setKeepAlive(bool) } setNoDelay(/* true||false */) {} unref() { return this.targetsocket.unref() } setTimeout(timeout) { this._timeout = timeout return this.targetsocket.setTimeout(timeout) } isEncrypted() { return this.targetsocket.encrypted } isSecure() { return this.targetsocket.encrypted && this.targetsocket.authorized } } exports.parse_x509 = async (string) => { const res = {} if (!string) return res const keyRe = /([-]+BEGIN (?:\w+ )?PRIVATE KEY[-]+[^-]*[-]+END (?:\w+ )?PRIVATE KEY[-]+)/gm res.keys = string.match(keyRe) const certRe = /([-]+BEGIN CERTIFICATE[-]+[^-]*[-]+END CERTIFICATE[-]+)/gm res.chain = string.match(certRe) if (res.chain?.length) { // it's cleaner to call openssl with each of -enddate, -subject, etc, but it costs // 40-50ms per spawn with node v21 on a M1 MBP const raw = await openssl(res.chain[0], 'x509', '-noout', '-enddate', '-subject', '-ext', 'subjectAltName') if (!raw) return res res.expire = new Date(raw.match(/notAfter=(.* [A-Z]{3})/)[1]) const match = /CN\s*=\s*([^/\s,]+)/.exec(raw) if (match && match[1]) res.names = [match[1]] for (let name of Array.from(raw.matchAll(/DNS:([^\s,]+)/gm), (m) => m[0])) { name = name.replace('DNS:', '') if (!res.names.includes(name)) res.names.push(name) } } return res } exports.load_tls_ini = (opts) => { log.info('loading tls.ini') const cfg = exports.config.get( 'tls.ini', { booleans: [ '-redis.disable_for_failed_hosts', // wildcards match in any section and are not initialized '*.requestCert', '*.rejectUnauthorized', '*.honorCipherOrder', '*.enableOCSPStapling', '*.requestOCSP', // explicitely declared booleans are initialized '+main.requestCert', '-main.rejectUnauthorized', '+main.honorCipherOrder', '-main.requestOCSP', '-main.mutual_tls', ], }, () => { this.load_tls_ini() }, ) if (cfg.no_tls_hosts === undefined) cfg.no_tls_hosts = Object.create(null) if (cfg.mutual_auth_hosts === undefined) cfg.mutual_auth_hosts = Object.create(null) if (cfg.mutual_auth_hosts_exclude === undefined) cfg.mutual_auth_hosts_exclude = Object.create(null) if (cfg.main.enableOCSPStapling !== undefined) { log.error('deprecated setting enableOCSPStapling in tls.ini') cfg.main.requestOCSP = cfg.main.enableOCSPStapling } if (ocsp === undefined && cfg.main.requestOCSP) { try { ocsp = require('@haraka/ocsp') log.debug('ocsp loaded') ocspCache = new ocsp.Cache() } catch (ignore) { log.notice('OCSP Stapling not available.') } } if (cfg.main.requireAuthorized === undefined) { cfg.main.requireAuthorized = [] } else if (!Array.isArray(cfg.main.requireAuthorized)) { cfg.main.requireAuthorized = [cfg.main.requireAuthorized] } if (!Array.isArray(cfg.main.no_starttls_ports)) cfg.main.no_starttls_ports = [] this.cfg = cfg if (!opts || opts.role === 'server') { this.applySocketOpts('*') this.load_default_opts() } return cfg } // Build a client tls_options, merges a consumers own [tls] section // over tls.ini [main]. exports.load_plugin_tls_options = (plugin_tls_cfg = {}) => { const tls_cfg = exports.load_tls_ini({ role: 'client' }) const cfg = JSON.parse(JSON.stringify(plugin_tls_cfg)) // Inheritance from tls.ini [main] deliberately omits no_tls_hosts: the // [main].no_tls_hosts list is documented as inbound-only; outbound and // queue plugins should opt in explicitly via their own section. const inheritable_opts = [ 'key', 'cert', 'ciphers', 'minVersion', 'dhparam', 'requestCert', 'honorCipherOrder', 'rejectUnauthorized', 'force_tls_hosts', ] for (const opt of inheritable_opts) { if (cfg[opt] !== undefined) continue // set in plugin [tls] if (tls_cfg.main[opt] === undefined) continue // unset in tls.ini [main] cfg[opt] = tls_cfg.main[opt] } // Resolve key/cert/dhparam file references to buffers. Drop empty results // so we never pass null to tls.connect. for (const k of ['key', 'cert', 'dhparam']) { if (!cfg[k]) { delete cfg[k] continue } const ref = Array.isArray(cfg[k]) ? cfg[k][0] : cfg[k] const bin = exports.config.get(ref, 'binary') if (bin) cfg[k] = bin else delete cfg[k] } for (const k of ['no_tls_hosts', 'force_tls_hosts']) { if (!cfg[k]) { cfg[k] = [] continue } if (!Array.isArray(cfg[k])) cfg[k] = [cfg[k]] } return cfg } // https://nodejs.org/api/tls.html#tls_new_tls_tlssocket_socket_options const TLSSocketOptions = [ // 'server' // manually added 'isServer', 'requestCert', 'rejectUnauthorized', 'NPNProtocols', 'ALPNProtocols', 'session', 'requestOCSP', 'secureContext', 'SNICallback', ] // https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options const createSecureContextOptions = [ 'key', 'cert', 'dhparam', 'pfx', 'passphrase', 'ca', 'crl', 'ciphers', 'minVersion', 'honorCipherOrder', 'ecdhCurve', 'secureProtocol', 'secureOptions', 'sessionIdContext', ] exports.applySocketOpts = (name) => { for (const opt of [...TLSSocketOptions, ...createSecureContextOptions]) { if (this.cfg[name] && this.cfg[name][opt] !== undefined) { // if the setting exists in tls.ini [name] certsByHost.set([name, opt], this.cfg[name][opt]) } else if (this.cfg.main[opt] !== undefined) { // save settings in tls.ini [main] to each CN certsByHost.set([name, opt], this.cfg.main[opt]) } else { // defaults switch (opt) { case 'sessionIdContext': certsByHost.set([name, opt], 'haraka') break case 'isServer': certsByHost.set([name, opt], true) break case 'key': certsByHost.set([name, opt], 'tls_key.pem') break case 'cert': certsByHost.set([name, opt], 'tls_cert.pem') break case 'dhparam': certsByHost.set([name, opt], 'dhparams.pem') break case 'SNICallback': certsByHost.set([name, opt], exports.SNICallback) break } } } } exports.load_default_opts = () => { const cfg = certsByHost['*'] if (cfg.dhparam && typeof cfg.dhparam === 'string') { log.debug(`loading dhparams from ${cfg.dhparam}`) certsByHost.set('*.dhparam', this.config.get(cfg.dhparam, 'binary')) } if (cfg.ca && typeof cfg.ca === 'string') { log.info(`loading CA certs from ${cfg.ca}`) certsByHost.set('*.ca', this.config.get(cfg.ca, 'binary')) } // make non-array key/cert option into Arrays with one entry if (!Array.isArray(cfg.key)) cfg.key = [cfg.key] if (!Array.isArray(cfg.cert)) cfg.cert = [cfg.cert] if (cfg.key.length !== cfg.cert.length) { log.error(`number of keys (${cfg.key.length}) not equal to certs (${cfg.cert.length}).`) } // if key file has already been loaded, it'll be a Buffer. if (typeof cfg.key[0] === 'string') { // turn key/cert file names into actual key/cert binary data const asArray = cfg.key.map((keyFileName) => { if (!keyFileName) return const key = this.config.get(keyFileName, 'binary') if (!key) { log.error(`tls key ${path.join(this.config.root_path, keyFileName)} could not be loaded.`) } return key }) certsByHost.set('*.key', asArray) } if (typeof cfg.cert[0] === 'string') { const asArray = cfg.cert.map((certFileName) => { if (!certFileName) return const cert = this.config.get(certFileName, 'binary') if (!cert) { log.error(`tls cert ${path.join(this.config.root_path, certFileName)} could not be loaded.`) } return cert }) certsByHost.set('*.cert', asArray) } // a reload without a usable key or cert must not keep serving the old context delete ctxByHost['*'] if (cfg.cert[0] && cfg.key[0]) { this.tls_valid = true // now that all opts are applied, generate TLS context this.ensureDhparams(() => { ctxByHost['*'] = createSecureContext(cfg) }) } } exports.SNICallback = function (servername, sniDone) { log.debug(`SNI servername: ${servername}`) sniDone(null, ctxByHost[servername] || ctxByHost['*']) } exports.get_certs_dir = async (tlsDir) => { const r = {} const watcher = async () => { exports.get_certs_dir(tlsDir) } const dirOpts = { type: 'binary', watchCb: watcher } const files = await this.config.getDir(tlsDir, dirOpts) for (const file of files) { try { r[file.path] = await exports.parse_x509(file.data.toString()) } catch (err) { log.debug(err.message) } } log.debug(`found ${Object.keys(r).length} files in config/tls`) if (Object.keys(r).length === 0) return const s = {} // certs by name (CN) for (const fp in r) { if (r[fp].expire && r[fp].expire < new Date()) { log.error(`${fp} expired on ${r[fp].expire}`) } // a file with a key and no cert, get name from file if (!r[fp].names) r[fp].names = [path.parse(fp).name] for (let name of r[fp].names) { if (name[0] === '_') name = name.replace('_', '*') // windows if (s[name] === undefined) s[name] = {} if (!s[name].key && r[fp].keys) s[name].key = r[fp].keys[0] if (!s[name].cert && r[fp].chain) { s[name].cert = r[fp].chain.join('\n') s[name].file = fp } } } for (const cn in s) { if (!s[cn].cert || !s[cn].key) { delete s[cn] continue } this.applySocketOpts(cn) // from tls.ini certsByHost.set([cn, 'cert'], Buffer.from(s[cn].cert)) certsByHost.set([cn, 'key'], Buffer.from(s[cn].key)) certsByHost.set([cn, 'dhparam'], certsByHost['*'].dhparam, true) // all opts are applied, generate TLS context try { ctxByHost[cn] = createSecureContext(certsByHost.get([cn])) } catch (err) { log.error(`CN '${cn}' loading got: ${err.message}`) delete ctxByHost[cn] delete certsByHost[cn] } } log.info(`found ${Object.keys(s).length} TLS certs in config/tls`) return certsByHost // used only by tests } function openssl(crt, ...params) { return new Promise((resolve) => { let crtTxt = '' let errTxt = '' const o = spawn('openssl', params, { timeout: 2000 }) o.stdout.on('data', (data) => { crtTxt += data }) o.stderr.on('data', (data) => { errTxt += data }) o.on('close', (code) => { if (code !== 0) { log.error(`openssl ${params.join(' ')} failed with code ${code}: ${errTxt.trim()}`) } resolve(crtTxt) }) o.stdin.write(crt) o.stdin.write('\n') }) } exports.getSocketOpts = async (name) => { // startup time, load the config/tls dir if (!certsByHost['*']) this.load_tls_ini() try { await this.get_certs_dir('tls') } catch (err) { if (err.code !== 'ENOENT') { log.error(err.message) } } return { ...(certsByHost[name] || certsByHost['*']) } } function pipe(cleartext, socket) { cleartext.socket = socket function onError() {} function onClose() { socket.removeListener('error', onError) socket.removeListener('close', onClose) } socket.on('error', onError) socket.on('close', onClose) } exports.ensureDhparams = (done) => { // empty/missing dhparams file if (certsByHost['*'].dhparam) { return done(null, certsByHost['*'].dhparam) } if (cluster.isWorker) return // only once, on the master process const filePath = this.cfg.main.dhparam || 'dhparams.pem' const fpResolved = path.resolve(exports.config.root_path, filePath) log.info(`Generating a 2048 bit dhparams file at ${fpResolved}`) const o = spawn('openssl', ['dhparam', '-out', fpResolved, '2048'], { timeout: 30000 }) o.stdout.on('data', (data) => { // normally empty output log.debug(data) }) o.stderr.on('data', () => { // this is the status gibberish `openssl dhparam` spews as it works }) o.on('close', (code) => { if (code !== 0) { return done(`Error code: ${code}`) } log.info(`Saved to ${fpResolved}`) const content = this.config.get(filePath, 'binary') certsByHost.set('*.dhparam', content) done(null, certsByHost['*'].dhparam) }) } exports.addOCSP = (server) => { if (!ocsp) { log.debug(`addOCSP: 'ocsp' not available`) return } if (server.listenerCount('OCSPRequest') > 0) { log.debug('OCSPRequest already listening') return } log.debug('adding OCSPRequest listener') server.on('OCSPRequest', (cert, issuer, ocr_cb) => { log.debug(`OCSPRequest: ${cert}`) ocsp.getOCSPURI(cert, async (err, uri) => { log.debug(`OCSP Request, URI: ${uri}, err=${err}`) if (err) return ocr_cb(err) if (uri === null) return ocr_cb() // not working OCSP server const req = ocsp.request.generate(cert, issuer) const cached = await ocspCache.probe(req.id) if (cached) { log.debug(`OCSP cache: ${util.inspect(cached)}`) return ocr_cb(null, cached.response) } const options = { url: uri, ocsp: req.data, } log.debug(`OCSP req:${util.inspect(req)}`) ocspCache.request(req.id, options, ocr_cb) }) }) } exports.shutdown = () => { if (ocsp) cleanOcspCache() } function cleanOcspCache() { log.debug(`Cleaning ocspCache. How many keys? ${Object.keys(ocspCache.cache).length}`) for (const key of Object.keys(ocspCache.cache)) { clearTimeout(ocspCache.cache[key].timer) } } exports.certsByHost = certsByHost exports.ocsp = ocsp exports.get_rejectUnauthorized = (rejectUnauthorized, port, port_list) => { // console.log(`rejectUnauthorized: ${rejectUnauthorized}, port ${port}, list: ${port_list}`) if (rejectUnauthorized) return true return !!port_list.includes(port) } function createServer(cb) { const server = net.createServer((cryptoSocket) => { const socket = new pluggableStream(cryptoSocket) exports.addOCSP(server) socket.upgrade = (cb2) => { log.debug('Upgrading to TLS') let secureContext try { // ctxByHost['*'] is unset until key, cert and dhparams load secureContext = ctxByHost['*'] ?? createSecureContext(certsByHost['*']) } catch (err) { // before socket.clean(), so the outer socket relays the error and the close return cryptoSocket.destroy(err) } socket.clean() cryptoSocket.removeAllListeners('data') const options = { ...certsByHost['*'], secureContext } options.server = server // TLSSocket needs server for SNI to work options.rejectUnauthorized = exports.get_rejectUnauthorized( options.rejectUnauthorized, cryptoSocket.localPort, exports.cfg.main.requireAuthorized, ) const cleartext = new tls.TLSSocket(cryptoSocket, options) pipe(cleartext, cryptoSocket) cleartext .on('error', (exception) => { exception.source = 'tls' socket.emit('error', exception) }) .on('secure', () => { log.debug('TLS secured.') socket.emit('secure') const cipher = cleartext.getCipher() if (!cipher) return cleartext.destroy() cipher.version = cleartext.getProtocol() if (cb2) cb2(cleartext.authorized, cleartext.authorizationError, cleartext.getPeerCertificate(), cipher) }) socket.cleartext = cleartext if (socket._timeout) { cleartext.setTimeout(socket._timeout) } cleartext.setKeepAlive(socket._keepalive) socket.attach(socket.cleartext) } cb(socket) }) return server } // Options that stay out of the SecureContext (socket-level net/tls options and Haraka's // own). Every other option keys the cache, so an unlisted option only costs a miss. const clientSocketOptions = new Set([ ...TLSSocketOptions, 'checkServerIdentity', 'enableTrace', 'force_tls_hosts', 'highWaterMark', 'host', 'minDHSize', 'no_tls_hosts', 'path', 'port', 'pskCallback', 'servername', 'socket', 'timeout', ]) const clientContexts = [] const maxClientContexts = 16 exports.clientSecureContext = (options) => { const entries = Object.entries(options) .filter(([name]) => !clientSocketOptions.has(name)) .sort(([a], [b]) => (a < b ? -1 : 1)) const i = clientContexts.findIndex( (c) => c.entries.length === entries.length && c.entries.every(([name, value], j) => name === entries[j][0] && Object.is(value, entries[j][1])), ) if (i !== -1) { const [hit] = clientContexts.splice(i, 1) clientContexts.unshift(hit) return hit.context } const context = tls.createSecureContext(options) clientContexts.unshift({ entries, context }) if (clientContexts.length > maxClientContexts) clientContexts.pop() return context } function getCertFor(host) { if (host && certsByHost[host]) return certsByHost[host] return certsByHost['*'] // the default TLS cert } function connect(conn_options = {}) { // called by outbound/client_pool, smtp_client, plugins/spamassassin,avg,clamd, // plugins/auth/auth_proxy const cryptoSocket = net.connect(conn_options) const socket = new pluggableStream(cryptoSocket) socket.upgrade = (options, cb2) => { if (exports.tls_valid) { const host = conn_options.host if (exports.cfg === undefined) exports.load_tls_ini() if (exports.cfg.mutual_auth_hosts[host]) { options = { ...options, ...getCertFor(exports.cfg.mutual_auth_hosts[host]) } } else if (exports.cfg.mutual_auth_hosts_exclude[host]) { // send no client cert } else if (exports.cfg.main.mutual_tls) { options = { ...options, ...getCertFor(host) } } } let secureContext try { secureContext = options.secureContext ?? exports.clientSecureContext(options) } catch (err) { return cryptoSocket.destroy(err) } socket.clean() cryptoSocket.removeAllListeners('data') options.socket = cryptoSocket const cleartext = tls.connect({ ...options, secureContext }) pipe(cleartext, cryptoSocket) cleartext.on('error', (err) => { err.source = 'tls' socket.emit('error', err) }) cleartext.once('secureConnect', () => { log.debug('client TLS secured.') const cipher = cleartext.getCipher() if (!cipher) return cleartext.destroy() cipher.version = cleartext.getProtocol() if (cb2) cb2(cleartext.authorized, cleartext.authorizationError, cleartext.getPeerCertificate(), cipher) }) socket.cleartext = cleartext if (socket._timeout) { cleartext.setTimeout(socket._timeout) } cleartext.setKeepAlive(socket._keepalive) socket.attach(socket.cleartext) log.debug('client TLS upgrade in progress, awaiting secured.') } return socket } exports.connect = connect exports.createConnection = connect exports.Server = createServer exports.createServer = createServer