2021-05-13 22:08:14 +08:00
|
|
|
'use strict';
|
|
|
|
|
|
|
|
const ObjectID = require('mongodb').ObjectID;
|
|
|
|
const fingerprint = require('key-fingerprint').fingerprint;
|
|
|
|
const crypto = require('crypto');
|
|
|
|
const tls = require('tls');
|
2021-05-16 01:29:11 +08:00
|
|
|
const forge = require('node-forge');
|
2021-05-13 22:08:14 +08:00
|
|
|
const tools = require('./tools');
|
2021-05-16 01:29:11 +08:00
|
|
|
const tlsOptions = require('../imap-core/lib/tls-options');
|
2021-05-13 22:08:14 +08:00
|
|
|
const { publish, CERT_CREATED, CERT_UPDATED, CERT_DELETED } = require('./events');
|
|
|
|
|
|
|
|
class CertHandler {
|
|
|
|
constructor(options) {
|
|
|
|
options = options || {};
|
|
|
|
this.cipher = options.cipher;
|
|
|
|
this.secret = options.secret;
|
|
|
|
|
|
|
|
this.database = options.database;
|
|
|
|
this.redis = options.redis;
|
|
|
|
|
2021-05-16 01:29:11 +08:00
|
|
|
this.ctxCache = new Map();
|
|
|
|
|
2021-05-13 22:08:14 +08:00
|
|
|
this.loggelf = options.loggelf || (() => false);
|
|
|
|
}
|
|
|
|
|
2021-05-16 01:29:11 +08:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
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 '';
|
|
|
|
}
|
|
|
|
|
2021-05-13 22:08:14 +08:00
|
|
|
async set(options) {
|
2021-05-16 01:29:11 +08:00
|
|
|
// if not set then resolve from certificate
|
|
|
|
let servername = options.servername && tools.normalizeDomain(options.servername);
|
2021-05-13 22:08:14 +08:00
|
|
|
const description = options.description;
|
|
|
|
|
|
|
|
let privateKey = options.privateKey;
|
|
|
|
const cert = options.cert;
|
2021-05-16 01:29:11 +08:00
|
|
|
const ca = options.ca;
|
2021-05-13 22:08:14 +08:00
|
|
|
|
|
|
|
let fp;
|
|
|
|
try {
|
|
|
|
fp = fingerprint(privateKey, 'sha256', true);
|
|
|
|
} catch (E) {
|
|
|
|
let err = new Error('Invalid or incompatible private key. ' + E.message);
|
|
|
|
err.code = 'InputValidationError';
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.secret) {
|
|
|
|
try {
|
|
|
|
let cipher = crypto.createCipher(this.cipher || 'aes192', this.secret);
|
|
|
|
privateKey = '$' + cipher.update(privateKey, 'utf8', 'hex');
|
|
|
|
privateKey += cipher.final('hex');
|
|
|
|
} catch (E) {
|
|
|
|
let err = new Error('Failed to encrypt private key. ' + E.message);
|
|
|
|
err.code = 'InternalConfigError';
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-05-16 01:29:11 +08:00
|
|
|
let primaryCert = cert;
|
|
|
|
let certEnd = primaryCert.match(/END CERTIFICATE-+/);
|
|
|
|
if (certEnd) {
|
|
|
|
primaryCert = primaryCert.substr(0, certEnd.index + certEnd[0].length);
|
|
|
|
}
|
|
|
|
|
2021-05-13 22:08:14 +08:00
|
|
|
let certData = {
|
|
|
|
privateKey,
|
|
|
|
cert,
|
2021-05-16 01:29:11 +08:00
|
|
|
ca,
|
2021-05-13 22:08:14 +08:00
|
|
|
fingerprint: fp,
|
|
|
|
updated: new Date()
|
|
|
|
};
|
|
|
|
|
2021-05-16 01:29:11 +08:00
|
|
|
try {
|
|
|
|
const parsedCert = forge.pki.certificateFromPem(primaryCert);
|
2021-05-20 00:43:13 +08:00
|
|
|
|
2021-05-16 01:29:11 +08:00
|
|
|
certData.expires = new Date(parsedCert.validity.notAfter.toISOString());
|
|
|
|
certData.altNames = this.getAltNames(parsedCert);
|
|
|
|
|
|
|
|
if (!servername) {
|
|
|
|
servername = this.getCertName(parsedCert);
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
// TODO: proper logging
|
|
|
|
console.error(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!servername) {
|
|
|
|
let err = new Error('Invalid or missing servername');
|
|
|
|
err.code = 'InputValidationError';
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!certData.altNames || !certData.altNames.length) {
|
|
|
|
certData.altNames = [servername];
|
|
|
|
}
|
|
|
|
|
2021-05-13 22:08:14 +08:00
|
|
|
if (description) {
|
|
|
|
certData.description = description;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
// should fail on invalid input
|
|
|
|
tls.createSecureContext({
|
|
|
|
key: privateKey,
|
2021-05-16 01:29:11 +08:00
|
|
|
cert,
|
|
|
|
ca
|
2021-05-13 22:08:14 +08:00
|
|
|
});
|
|
|
|
} catch (E) {
|
|
|
|
let err = new Error('Invalid or incompatible key and certificate. ' + E.message);
|
|
|
|
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,
|
|
|
|
returnOriginal: false
|
|
|
|
}
|
|
|
|
);
|
|
|
|
} catch (err) {
|
|
|
|
if (err) {
|
|
|
|
err.code = 'InternalDatabaseError';
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!r.value) {
|
|
|
|
let err = new Error('Failed to insert Cert key');
|
|
|
|
err.code = 'InternalDatabaseError';
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.redis) {
|
|
|
|
try {
|
|
|
|
if (r.lastErrorObject.upserted) {
|
|
|
|
await publish(this.redis, {
|
|
|
|
ev: CERT_CREATED,
|
|
|
|
cert: r.value._id.toString(),
|
|
|
|
servername,
|
|
|
|
fingerprint: fp
|
|
|
|
});
|
|
|
|
} else if (r.lastErrorObject.updatedExisting) {
|
|
|
|
await publish(this.redis, {
|
|
|
|
ev: CERT_UPDATED,
|
|
|
|
cert: r.value._id.toString(),
|
|
|
|
servername,
|
|
|
|
fingerprint: fp
|
|
|
|
});
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
// ignore?
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
id: r.value._id.toString(),
|
2021-05-16 01:29:11 +08:00
|
|
|
servername,
|
2021-05-13 22:08:14 +08:00
|
|
|
description: certData.description,
|
2021-05-16 01:29:11 +08:00
|
|
|
fingerprint: certData.fingerprint,
|
|
|
|
expires: certData.expires && certData.expires.toISOString(),
|
|
|
|
altNames: certData.altNames
|
2021-05-13 22:08:14 +08:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
async get(options, includePrivateKey) {
|
|
|
|
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.code = 'CertNotFound';
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
|
|
|
|
let certData;
|
|
|
|
try {
|
|
|
|
certData = await this.database.collection('certs').findOne(query);
|
|
|
|
} catch (err) {
|
|
|
|
err.code = 'InternalDatabaseError';
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
if (!certData) {
|
|
|
|
let err = new Error('Invalid or unknown cert');
|
|
|
|
err.code = 'CertNotFound';
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
|
|
|
|
let privateKey;
|
|
|
|
if (includePrivateKey) {
|
|
|
|
privateKey = this.decodeKey(certData.privateKey);
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
id: certData._id.toString(),
|
|
|
|
servername: certData.servername,
|
|
|
|
description: certData.description,
|
|
|
|
fingerprint: certData.fingerprint,
|
|
|
|
privateKey,
|
2021-05-16 01:29:11 +08:00
|
|
|
expires: certData.expires,
|
|
|
|
altNames: certData.altNames,
|
2021-05-13 22:08:14 +08:00
|
|
|
created: certData.created
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
decodeKey(privateKey) {
|
|
|
|
if (privateKey.charAt(0) === '$') {
|
|
|
|
if (this.secret) {
|
|
|
|
try {
|
|
|
|
let decipher = crypto.createDecipher(this.cipher || 'aes192', this.secret);
|
|
|
|
privateKey = decipher.update(privateKey.substr(1), 'hex', 'utf-8');
|
|
|
|
privateKey += decipher.final('utf8');
|
|
|
|
} catch (E) {
|
|
|
|
let err = new Error('Failed to decrypt private key. ' + E.message);
|
|
|
|
err.code = 'InternalConfigError';
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
let err = new Error('Can not use decrypted key');
|
|
|
|
err.code = 'InternalConfigError';
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return privateKey;
|
|
|
|
}
|
|
|
|
|
|
|
|
async del(options) {
|
|
|
|
let query = {};
|
|
|
|
|
|
|
|
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.code = 'CertNotFound';
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
|
|
|
|
// delete cert key from database
|
|
|
|
let r;
|
|
|
|
try {
|
|
|
|
r = await this.database.collection('certs').findOneAndDelete(query);
|
|
|
|
} catch (err) {
|
|
|
|
err.code = 'InternalDatabaseError';
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!r.value) {
|
|
|
|
let err = new Error('Invalid or unknown cert');
|
|
|
|
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
|
|
|
|
});
|
|
|
|
} catch (err) {
|
|
|
|
// ignore?
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
2021-05-16 01:29:11 +08:00
|
|
|
|
|
|
|
async getContextForServername(servername, serverOptions) {
|
|
|
|
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 };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// search for exact servername match at first
|
|
|
|
let certData = await this.database.collection('certs').findOne(query);
|
2021-05-20 19:47:20 +08:00
|
|
|
if (!certData || !certData.key || !certData.cert) {
|
2021-05-16 01:29:11 +08:00
|
|
|
if (cachedContext && cachedContext.context && cachedContext.entry && cachedContext.entry.servername === servername) {
|
|
|
|
// we have a valid cached context
|
|
|
|
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 } });
|
2021-05-20 19:47:20 +08:00
|
|
|
if (!certData || !certData.key || !certData.cert) {
|
2021-05-16 01:29:11 +08:00
|
|
|
// still nothing, return whatever we have
|
|
|
|
return (cachedContext && cachedContext.context) || false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// key might be encrypted
|
|
|
|
let privateKey = this.decodeKey(certData.privateKey);
|
|
|
|
|
|
|
|
let serviceCtxOpts = { key: privateKey, cert: certData.cert, ca: 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 });
|
|
|
|
|
|
|
|
return context;
|
|
|
|
}
|
2021-05-13 22:08:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = CertHandler;
|