'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 { promisify } = require('util'); const generateKeyPair = promisify(crypto.generateKeyPair); const CERT_RENEW_TTL = 30 * 24 * 3600 * 1000; const CERT_RENEW_DELAY = 24 * 3600 * 100; class CertHandler { constructor(options) { options = options || {}; this.cipher = options.cipher; this.secret = options.secret; this.database = options.database; this.redis = options.redis; 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 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 } } ); if (r && r.value) { // use getRecord to decrypt secrets return await this.getRecord({ _id: r.value._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 }; 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.message); } } 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, altNames: certData.altNames, acme: !!certData.acme, hasCert: (!!certData.privateKe && 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) { // 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; } } module.exports = CertHandler;