Ctrl K
ComunidadGuías…

tls_socket.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'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