'use strict'; const ObjectId = require('mongodb').ObjectId; const fingerprint = require('key-fingerprint').fingerprint; const crypto = require('crypto'); const tls = require('tls'); const forge = require('node-forge'); const tools = require('./tools'); const log = require('npmlog'); const tlsOptions = require('../imap-core/lib/tls-options'); const { publish, CERT_CREATED, CERT_UPDATED, CERT_DELETED } = require('./events'); const { encrypt, decrypt } = require('./encrypt'); const { SettingsHandler } = require('./settings-handler'); const { Resolver } = require('dns').promises; const resolver = new Resolver(); const { getCertificate } = require('./acme/certs'); const { promisify } = require('util'); const generateKeyPair = promisify(crypto.generateKeyPair); const CERT_RENEW_TTL = 30 * 24 * 3600 * 1000; const CERT_RENEW_DELAY = 24 * 3600 * 1000; // delete uninitialized certificates after 1 day const CERT_GARBAGE_TTL = 24 * 3600 * 1000; class CertHandler { constructor(options) { options = options || {}; this.cipher = options.cipher; this.secret = options.secret; this.settingsHandler = new SettingsHandler({ db: options.database }); this.database = options.database; this.redis = options.redis; this.users = options.users; this.acme = options.acmeConfig; this.ctxCache = new Map(); this.loggelf = options.loggelf || (() => false); } getAltNames(parsedCert) { let response = []; let altNames = parsedCert.extensions && parsedCert.extensions.find(ext => ext.id === '2.5.29.17'); let subject = parsedCert.subject && parsedCert.subject.attributes && parsedCert.subject.attributes.find(attr => attr.type === '2.5.4.3'); if (altNames && altNames.altNames && altNames.altNames.length) { response = altNames.altNames.map(an => an.value).filter(value => value); } if (!response.length && subject && subject.value) { response.push(subject.value); } response = response.map(name => tools.normalizeDomain(name)); return response; } async generateKey(keyBits, keyExponent, opts) { opts = opts || {}; const { privateKey /*, publicKey */ } = await generateKeyPair('rsa', { modulusLength: keyBits || 2048, // options publicExponent: keyExponent || 65537, publicKeyEncoding: { type: opts.publicKeyEncoding || 'spki', format: 'pem' }, privateKeyEncoding: { // jwk functions fail on other encodings (eg. pkcs8) type: opts.privateKeyEncoding || 'pkcs1', format: 'pem' } }); return privateKey; } getCertName(parsedCert) { let subject = parsedCert.subject && parsedCert.subject.attributes && parsedCert.subject.attributes.find(attr => attr.type === '2.5.4.3'); if (subject && subject.value) { return tools.normalizeDomain(subject.value); } let altNames = parsedCert.extensions && parsedCert.extensions.find(ext => ext.id === '2.5.29.17'); if (altNames && altNames.altNames && altNames.altNames.length) { let list = altNames.altNames.map(an => an.value && tools.normalizeDomain(an.value)).filter(value => value); return list[0]; } return ''; } prepareQuery(options) { let query = {}; options = options || {}; if (options.servername) { query.servername = tools.normalizeDomain(options.servername); } else if (options._id && tools.isId(options._id)) { query._id = new ObjectId(options._id); } else { let err = new Error('Invalid or unknown cert'); err.responseCode = 404; err.code = 'CertNotFound'; throw err; } return query; } async clearGarbage() { // delete expired and uninitialized SNI certificates let r = await this.database.collection('certs').deleteMany({ acme: true, updated: { $lt: new Date(Date.now() - CERT_GARBAGE_TTL) }, $or: [{ expires: { $exists: false } }, { expires: { $lt: new Date() } }], autogenerated: true }); if (r?.deletedCount) { log.verbose('Certs', 'Deleted uninitialized and expired autogenerated certificates. count=%s', r?.deletedCount); } } async getNextRenewal() { let r = await this.database.collection('certs').findOneAndUpdate( { acme: true, expires: { $lt: new Date(Date.now() + CERT_RENEW_TTL) }, $or: [ { '_acme.lastRenewalCheck': { $exists: false } }, { '_acme.lastRenewalCheck': { $lt: new Date(Date.now() - CERT_RENEW_DELAY) } } ] }, { $set: { '_acme.lastRenewalCheck': new Date() } }, { upsert: false, returnDocument: 'after', projection: { _id: true, autogenerated: true, expires: true, servername: true } } ); if (r?.value) { const certData = r.value; const now = new Date(); if (certData.autogenerated && certData.expires < now) { // delete expired automatic cert, do not try to renew it try { let r = await this.database.collection('certs').deleteOne({ _id: certData._id }); if (r?.deletedCount) { this.loggelf({ short_message: `Deleted autogenerated certificate ${certData.cervername}`, _sni_servername: certData.cervername, _cert_action: 'sni_autodelete' }); } } catch (err) { //ignore } return await this.getNextRenewal(); } // use getRecord to decrypt secrets return await this.getRecord({ _id: certData._id }, true); } return false; } async update(options, updates, updateOptions) { updateOptions = updateOptions || {}; let query = this.prepareQuery(options); let r; if (!updates || typeof updates !== 'object' || !Object.keys(updates).length) { // nothing to do here return false; } const changes = { $set: Object.assign({}, updates) }; let fp; if (updates.privateKey) { try { fp = fingerprint(updates.privateKey, 'sha256', true); } catch (E) { let err = new Error('Invalid or incompatible private key. ' + E.message); err.responseCode = 400; err.code = 'InputValidationError'; throw err; } let encodedPrivateKey = await encrypt(updates.privateKey, this.secret); changes.$set = Object.assign({}, updates, { fp, privateKey: encodedPrivateKey }); } if (updateOptions.certUpdated) { changes.$set['_acme.lastError'] = null; } if (updates.privateKey || updateOptions.certUpdated) { changes.$inc = { v: 1 }; changes.$set.updated = new Date(); } try { r = await this.database.collection('certs').findOneAndUpdate(query, changes, { upsert: false, returnDocument: 'after' }); } catch (err) { if (err) { err.responseCode = 500; err.code = 'InternalDatabaseError'; throw err; } } if (!r.value) { let err = new Error('Failed to update Cert data'); err.responseCode = 500; err.code = 'InternalDatabaseError'; throw err; } if (updateOptions.certUpdated) { this.loggelf({ short_message: `SNI cert updated for ${r.value.servername}`, _sni_servername: r.value.servername, _cert_action: 'update', _cert_expires: r.value.expires && r.value.expires.toISOString() }); } if (this.redis && updates.cert) { try { await publish(this.redis, { ev: CERT_UPDATED, cert: r.value._id.toString(), servername: r.value.servername, fingerprint: r.value.fp }); } catch (err) { // ignore? } } return true; } async resetPrivateKey(options, acme) { let query = this.prepareQuery(options); let privateKey = await this.generateKey(acme.keyBits, acme.keyExponent); let fp; try { fp = fingerprint(privateKey, 'sha256', true); } catch (E) { let err = new Error('Invalid or incompatible private key. ' + E.message); err.responseCode = 400; err.code = 'InputValidationError'; throw err; } let encodedPrivateKey = await encrypt(privateKey, this.secret); let r; try { r = await this.database.collection('certs').findOneAndUpdate( query, { $set: { fp, privateKey: encodedPrivateKey, updated: new Date() }, $inc: { v: 1 } }, { upsert: false, returnDocument: 'after' } ); } catch (err) { if (err) { err.responseCode = 500; err.code = 'InternalDatabaseError'; throw err; } } if (!r.value) { let err = new Error('Failed to insert Cert key'); err.responseCode = 500; err.code = 'InternalDatabaseError'; throw err; } return privateKey; } async set(options) { // if not set then resolve from certificate let servername = options.servername && tools.normalizeDomain(options.servername); const description = options.description; let privateKey = options.privateKey; let cert = options.cert; let ca = options.ca; let primaryCert; let certData = { updated: new Date(), acme: !!options.acme, autogenerated: !!options.autogenerated }; if (privateKey) { try { certData.fingerprint = fingerprint(privateKey, 'sha256', true); } catch (E) { let err = new Error('Invalid or incompatible private key. ' + E.message); err.responseCode = 400; err.code = 'InputValidationError'; throw err; } certData.privateKey = await encrypt(privateKey, this.secret); } if (cert) { primaryCert = cert; let certEnd = primaryCert.match(/END CERTIFICATE-+/); if (certEnd) { primaryCert = primaryCert.substr(0, certEnd.index + certEnd[0].length); } certData.cert = cert; } if (primaryCert) { try { const parsedCert = forge.pki.certificateFromPem(primaryCert); certData.expires = new Date(parsedCert.validity.notAfter.toISOString()); certData.altNames = this.getAltNames(parsedCert); if (!servername) { servername = this.getCertName(parsedCert); } } catch (err) { log.error('Certs', 'Failed to parse certificate. error=%s', err.stack); } } if (!servername) { let err = new Error('Invalid or missing servername'); err.responseCode = 400; err.code = 'InputValidationError'; throw err; } if (!certData.altNames || !certData.altNames.length) { certData.altNames = [servername]; } if (description) { certData.description = description; } if (privateKey && cert) { try { // should fail on invalid input tls.createSecureContext({ key: privateKey, cert, ca }); } catch (E) { let err = new Error('Invalid or incompatible key and certificate. ' + E.message); err.responseCode = 400; err.code = 'InputValidationError'; throw err; } } let r; try { r = await this.database.collection('certs').findOneAndUpdate( { servername }, { $set: certData, $inc: { v: 1 }, $setOnInsert: { servername, created: new Date() } }, { upsert: true, returnDocument: 'after' } ); } catch (err) { if (err) { err.responseCode = 500; err.code = 'InternalDatabaseError'; throw err; } } if (!r.value) { let err = new Error('Failed to insert Cert key'); err.responseCode = 500; err.code = 'InternalDatabaseError'; throw err; } if (r.lastErrorObject.upserted) { this.loggelf({ short_message: `SNI cert created for ${r.value.servername}`, _sni_servername: r.value.servername, _cert_action: 'create', _cert_expires: r.value.expires && r.value.expires.toISOString() }); } else if (r.lastErrorObject.updatedExisting) { this.loggelf({ short_message: `SNI cert updated for ${r.value.servername}`, _sni_servername: r.value.servername, _cert_action: 'update', _cert_expires: r.value.expires && r.value.expires.toISOString() }); } if (this.redis && certData.cert) { try { if (r.lastErrorObject.upserted) { await publish(this.redis, { ev: CERT_CREATED, cert: r.value._id.toString(), servername, fingerprint: certData.fingerprint || certData.fp }); } else if (r.lastErrorObject.updatedExisting) { await publish(this.redis, { ev: CERT_UPDATED, cert: r.value._id.toString(), servername, fingerprint: certData.fingerprint || certData.fp }); } } catch (err) { // ignore? } } return { id: r.value._id.toString(), servername, description: certData.description, fingerprint: certData.fingerprint || certData.fp, expires: certData.expires && certData.expires.toISOString(), altNames: certData.altNames, acme: certData.acme }; } async get(options, includePrivateKey) { let certData = await this.getRecord(options, includePrivateKey); let res = { id: certData._id.toString(), servername: certData.servername, description: certData.description, fingerprint: certData.fingerprint || certData.fp, expires: certData.expires, autogenerated: certData.autogenerated, altNames: certData.altNames, acme: !!certData.acme, hasCert: (!!certData.privateKey && certData.cert) || false, created: certData.created }; if (includePrivateKey && certData.privateKey) { res.privateKey = certData.privateKey; } return res; } async getRecord(options, includePrivateKey) { let query = this.prepareQuery(options); let certData; try { certData = await this.database.collection('certs').findOne(query); } catch (err) { err.responseCode = 500; err.code = 'InternalDatabaseError'; throw err; } if (!certData) { let err = new Error('Invalid or unknown cert'); err.responseCode = 404; err.code = 'CertNotFound'; throw err; } if (includePrivateKey) { certData.privateKey = await decrypt(certData.privateKey, this.secret, this.cipher); } else { delete certData.privateKey; } return certData; } async del(options) { let query = this.prepareQuery(options); // delete cert key from database let r; try { r = await this.database.collection('certs').findOneAndDelete(query); } catch (err) { err.responseCode = 500; err.code = 'InternalDatabaseError'; throw err; } if (!r.value) { let err = new Error('Invalid or unknown cert'); err.responseCode = 404; err.code = 'CertNotFound'; throw err; } try { await publish(this.redis, { ev: CERT_DELETED, cert: r.value._id, servername: r.value.servername, fingerprint: r.value.fingerprint || r.value.fp }); } catch (err) { // ignore? } return true; } async getContextForServername(servername, serverOptions, meta) { meta = meta || {}; let query = { servername }; let cachedContext = false; if (this.ctxCache.has(servername)) { cachedContext = this.ctxCache.get(servername); if (cachedContext.entry && cachedContext.entry.v) { // check for updates query.v = { $ne: cachedContext.entry.v }; } } let sendLogs = payload => { if (!payload || typeof payload !== 'object' || !Object.keys(payload).length) { return; } this.loggelf( Object.assign( { short_message: `SNI request for ${servername}`, _sni_servername: servername, _sni_source: meta.source }, payload || {} ) ); }; // search for exact servername match at first let certData = await this.database.collection('certs').findOne(query); if (!certData || !certData.privateKey || !certData.cert) { if (cachedContext && cachedContext.context && cachedContext.entry && cachedContext.entry.servername === servername) { // we have a valid cached context sendLogs({ _sni_found: cachedContext.entry.servername, _sni_match: 'yes', _sni_cache: 'hit' }); return cachedContext.context; } // try altNames as well const altQuery = { $or: [{ altNames: servername }] }; if (servername.indexOf('.') >= 0) { let wcMatch = '*' + servername.substr(servername.indexOf('.')); altQuery.$or.push({ altNames: wcMatch }); } if (query.v) { altQuery.v = query.v; } certData = await this.database.collection('certs').findOne(altQuery, { sort: { expires: -1 } }); if (!certData || !certData.privateKey || !certData.cert) { // try to generate a new ACME certificate try { certData = await this.autogenerateAcmeCertificate(servername); } catch (err) { log.error('Certs', 'Failed to generate certificate. domain=%s error=%s', servername, err.stack); } if (!certData || !certData.privateKey || !certData.cert) { // still nothing, return whatever we have sendLogs({ _sni_match: 'no', _sni_cache: 'miss' }); return (cachedContext && cachedContext.context) || false; } } } // key might be encrypted let privateKey = await decrypt(certData.privateKey, this.secret, this.cipher); let serviceCtxOpts = { key: privateKey, cert: tools.buildCertChain(certData.cert, certData.ca) }; for (let key of ['dhparam']) { if (serverOptions[key]) { serviceCtxOpts[key] = serverOptions[key]; } } let ctxOptions = tlsOptions(serviceCtxOpts); let context = tls.createSecureContext(ctxOptions); this.ctxCache.set(servername, { entry: certData, context }); sendLogs({ _sni_found: certData.servername, _sni_match: 'yes', _sni_cache: 'miss' }); return context; } async precheckAcmeCertificate(domain) { const dotPos = domain.indexOf('.'); if (dotPos < 0) { // not a FQDN return false; } const subdomain = domain.substring(0, dotPos).toLowerCase().trim(); const maindomain = tools.normalizeDomain(domain.substring(dotPos + 1)); if (!this.acme.autogenerate?.cnameMapping?.hasOwnProperty(subdomain)) { log.verbose('Certs', 'Skip ACME. reason="unsupported subdomain" action=precheck domain=%s', domain); return false; } let subdomainTargets = [].concat(this.acme.autogenerate?.cnameMapping?.[subdomain] || []); if (!subdomainTargets.length) { // unsupported subdomain log.verbose('Certs', 'Skip ACME. reason="unsupported subdomain" action=precheck domain=%s', domain); return false; } // CNAME check let resolved; try { resolved = await resolver.resolveCname(domain); } catch (err) { log.error('Certs', 'DNS CNAME query failed. action=precheck domain=%s error=%s', domain, err.stack); return false; } if (!resolved || !resolved.length) { log.verbose('Certs', 'Skip ACME. reason="empty CNAME result" action=precheck domain=%s', domain); return false; } for (let row of resolved) { if (!subdomainTargets.includes(row)) { log.verbose('Certs', 'Skip ACME. reason="unknown CNAME target" action=precheck domain=%s target=%s', domain, row); return false; } } // CAA check const caaDomains = this.acme.caaDomains?.map(domain => tools.normalizeDomain(domain)).filter(d => d); let parts = domain.split('.'); for (let i = 0; i < parts.length - 1; i++) { let subdomain = parts.slice(i).join('.'); let caaRes; try { caaRes = await resolver.resolveCaa(subdomain); } catch (err) { // assume not found } if (caaRes?.length && !caaRes.some(r => caaDomains.includes(tools.normalizeDomain(r?.issue)))) { log.verbose('Certs', 'Skip ACME. reason="LE not listed in the CAA record". action=precheck domain=%s subdomain=%s', domain, subdomain); return false; } else if (caaRes?.length) { log.verbose('Certs', 'CAA record found. action=precheck domain=%s subdomain=%s caa=%s', domain, subdomain, caaRes.join(',')); break; } } // Address check const addressMatchRegex = tools.escapeRegexStr(`@${maindomain}`); const addressData = await this.users.collection('addresses').findOne({ addrview: { $regex: `${addressMatchRegex}$` } }); if (!addressData) { log.verbose('Certs', 'Skip ACME. reason="No addresses found for the domain". action=precheck domain=%s subdomain=%s', domain, subdomain); return false; } return true; } async autogenerateAcmeCertificate(servername) { let domain = tools.normalizeDomain(servername); if (!this.acme.autogenerate?.enabled) { // can not create autogenerated TLS certificates log.verbose('Certs', 'Skip ACME. reason="Certificate autogeneration not enabled" action=precheck domain=%s', domain); return false; } let valid = await this.precheckAcmeCertificate(domain); if (!valid) { return false; } log.verbose('Certs', 'ACME precheck passed. action=precheck domain=%s', domain); this.loggelf({ short_message: `Autogenerating TLS certificate for ${domain}`, _sni_servername: domain, _cert_action: 'sni_autogenerate' }); // add row to db let certInsertResult = await this.set({ servername, autogenerated: true, acme: true }); if (certInsertResult) { let certData = await getCertificate(servername, this.acme, this); log.verbose('Certs', 'ACME certificate result. servername=%s status=%s', servername, certData && certData.status); return certData || false; } return false; } } module.exports = CertHandler;