mirror of
https://github.com/nodemailer/wildduck.git
synced 2024-12-26 09:50:47 +08:00
614 lines
19 KiB
JavaScript
614 lines
19 KiB
JavaScript
'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;
|