mirror of
https://github.com/nodemailer/wildduck.git
synced 2025-09-07 05:35:12 +08:00
updates for cert handling
This commit is contained in:
parent
880293f696
commit
a03c97a943
13 changed files with 207 additions and 64 deletions
19
api.js
19
api.js
|
@ -135,11 +135,22 @@ let certOptions = {};
|
|||
certs.loadTLSOptions(certOptions, 'api');
|
||||
|
||||
if (config.api.secure && certOptions.key) {
|
||||
serverOptions.key = certOptions.key;
|
||||
if (certOptions.ca) {
|
||||
serverOptions.ca = certOptions.ca;
|
||||
let httpsServerOptions = {};
|
||||
|
||||
httpsServerOptions.key = certOptions.key;
|
||||
if (httpsServerOptions.ca) {
|
||||
httpsServerOptions.ca = certOptions.ca;
|
||||
}
|
||||
serverOptions.certificate = certOptions.cert;
|
||||
httpsServerOptions.certificate = certOptions.cert;
|
||||
httpsServerOptions.SNICallback = (servername, cb) => {
|
||||
console.log('SNI', servername);
|
||||
certs
|
||||
.getContextForServername(servername, httpsServerOptions)
|
||||
.then(context => cb(null, context))
|
||||
.catch(err => cb(err));
|
||||
};
|
||||
|
||||
serverOptions.httpsServerOptions = httpsServerOptions;
|
||||
}
|
||||
|
||||
const server = restify.createServer(serverOptions);
|
||||
|
|
|
@ -4059,7 +4059,7 @@ components:
|
|||
type: string
|
||||
description: ID of the certificate
|
||||
example: '609d201236d1d936948f23b1'
|
||||
domain:
|
||||
servername:
|
||||
type: string
|
||||
description: The server name this certificate applies to
|
||||
example: 'imap.example.com'
|
||||
|
@ -4071,6 +4071,16 @@ components:
|
|||
type: string
|
||||
description: Key fingerprint (SHA1)
|
||||
example: '59:8b:ed:11:5b:4f:ce:b4:e5:1a:2f:35:b1:6f:7d:93:40:c8:2f:9c:38:3b:cd:f4:04:92:a1:0e:17:2c:3f:f3'
|
||||
expires:
|
||||
type: date-time
|
||||
description: Certificate expiration time
|
||||
example: '2021-06-26T21:55:55.000Z'
|
||||
altNames:
|
||||
type: array
|
||||
description: Hostnames listed in the certificate
|
||||
items:
|
||||
type: string
|
||||
example: ['example.com', 'www.example.com']
|
||||
|
||||
ResolveIdResponse:
|
||||
required:
|
||||
|
@ -5121,6 +5131,13 @@ components:
|
|||
type: string
|
||||
description: 'PEM formatted TLS certificate or a certificate bundle with concatenated certificate and CA chain'
|
||||
example: "-----BEGIN CERTIFICATE-----\nMIIDEDCCAfg..."
|
||||
ca:
|
||||
type: array
|
||||
description: 'CA chain certificates. Not needed if `cert` value is a bundle'
|
||||
items:
|
||||
type: string
|
||||
description: 'PEM formatted TLS certificate'
|
||||
example: "-----BEGIN CERTIFICATE-----\nMIIDEDCCAfgs..."
|
||||
description:
|
||||
type: string
|
||||
description: Certificate description
|
||||
|
@ -5911,7 +5928,7 @@ components:
|
|||
type: string
|
||||
description: ID of the certificate
|
||||
example: '609d201236d1d936948f23b1'
|
||||
domain:
|
||||
servername:
|
||||
type: string
|
||||
description: The server name this certificate applies to
|
||||
example: 'imap.example.com'
|
||||
|
@ -5928,6 +5945,16 @@ components:
|
|||
description: Datestring
|
||||
format: date-time
|
||||
example: '2021-05-13T20:06:46.179Z'
|
||||
expires:
|
||||
type: date-time
|
||||
description: Certificate expiration time
|
||||
example: '2021-06-26T21:55:55.000Z'
|
||||
altNames:
|
||||
type: array
|
||||
description: Hostnames listed in the certificate
|
||||
items:
|
||||
type: string
|
||||
example: ['example.com', 'www.example.com']
|
||||
|
||||
GetAllowedDomainResult:
|
||||
required:
|
||||
|
|
|
@ -7,7 +7,7 @@ const IMAPConnection = require('./imap-connection').IMAPConnection;
|
|||
const tlsOptions = require('./tls-options');
|
||||
const EventEmitter = require('events').EventEmitter;
|
||||
const shared = require('nodemailer/lib/shared');
|
||||
const punycode = require('punycode');
|
||||
const punycode = require('punycode/');
|
||||
const base32 = require('base32.js');
|
||||
const errors = require('../../lib/errors.js');
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
const Indexer = require('./indexer/indexer');
|
||||
const libmime = require('libmime');
|
||||
const punycode = require('punycode');
|
||||
const punycode = require('punycode/');
|
||||
const iconv = require('iconv-lite');
|
||||
|
||||
module.exports.systemFlagsFormatted = ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen'];
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const libmime = require('libmime');
|
||||
const punycode = require('punycode');
|
||||
const punycode = require('punycode/');
|
||||
|
||||
// This module converts message structure into an ENVELOPE object
|
||||
|
||||
|
@ -11,7 +11,7 @@ const punycode = require('punycode');
|
|||
* @param {Object} message A parsed mime tree node
|
||||
* @return {Object} ENVELOPE compatible object
|
||||
*/
|
||||
module.exports = function(header) {
|
||||
module.exports = function (header) {
|
||||
let subject = Array.isArray(header.subject) ? header.subject.reverse().filter(line => line.trim()) : header.subject;
|
||||
subject = Buffer.from(subject || '', 'binary').toString();
|
||||
|
||||
|
|
|
@ -609,6 +609,13 @@ indexes:
|
|||
servername: 1
|
||||
v: 1
|
||||
|
||||
- collection: certs
|
||||
index:
|
||||
name: servername_alt
|
||||
key:
|
||||
altNames: 1
|
||||
v: 1
|
||||
|
||||
- collection: audits
|
||||
index:
|
||||
name: user_expire_time
|
||||
|
|
|
@ -109,6 +109,8 @@ module.exports = (db, server) => {
|
|||
servername: certData.servername,
|
||||
description: certData.description,
|
||||
fingerprint: certData.fingerprint,
|
||||
expires: certData.expires,
|
||||
altNames: certData.altNames,
|
||||
created: certData.created
|
||||
}))
|
||||
};
|
||||
|
@ -124,10 +126,7 @@ module.exports = (db, server) => {
|
|||
res.charSet('utf-8');
|
||||
|
||||
const schema = Joi.object().keys({
|
||||
servername: Joi.string()
|
||||
.max(255)
|
||||
//.hostname()
|
||||
.required(),
|
||||
servername: Joi.string().hostname(),
|
||||
sess: sessSchema,
|
||||
ip: sessIPSchema
|
||||
});
|
||||
|
@ -196,17 +195,16 @@ module.exports = (db, server) => {
|
|||
res.charSet('utf-8');
|
||||
|
||||
const schema = Joi.object().keys({
|
||||
servername: Joi.string().max(255).hostname().required(),
|
||||
servername: Joi.string().empty('').hostname().required().label('ServerName'),
|
||||
privateKey: Joi.string()
|
||||
.empty('')
|
||||
.trim()
|
||||
.regex(/^-----BEGIN (RSA )?PRIVATE KEY-----/, 'Certificate key format')
|
||||
.required(),
|
||||
cert: Joi.string().empty('').trim().required(),
|
||||
description: Joi.string()
|
||||
.max(255)
|
||||
//.hostname()
|
||||
.trim(),
|
||||
.required()
|
||||
.label('PrivateKey'),
|
||||
cert: Joi.string().empty('').trim().required().label('Certificate'),
|
||||
ca: Joi.array().items(Joi.string().empty('').trim().label('CACert')).label('CACertList'),
|
||||
description: Joi.string().empty('').max(1024).trim().label('Description'),
|
||||
sess: sessSchema,
|
||||
ip: sessIPSchema
|
||||
});
|
||||
|
|
|
@ -4,7 +4,9 @@ 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 tlsOptions = require('../imap-core/lib/tls-options');
|
||||
const { publish, CERT_CREATED, CERT_UPDATED, CERT_DELETED } = require('./events');
|
||||
|
||||
class CertHandler {
|
||||
|
@ -16,15 +18,51 @@ class CertHandler {
|
|||
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;
|
||||
}
|
||||
|
||||
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 '';
|
||||
}
|
||||
|
||||
async set(options) {
|
||||
const servername = tools.normalizeDomain(options.servername);
|
||||
// if not set then resolve from certificate
|
||||
let servername = options.servername && tools.normalizeDomain(options.servername);
|
||||
const description = options.description;
|
||||
|
||||
let privateKey = options.privateKey;
|
||||
const cert = options.cert;
|
||||
const ca = options.ca;
|
||||
|
||||
let fp;
|
||||
try {
|
||||
|
@ -47,13 +85,43 @@ class CertHandler {
|
|||
}
|
||||
}
|
||||
|
||||
let primaryCert = cert;
|
||||
let certEnd = primaryCert.match(/END CERTIFICATE-+/);
|
||||
if (certEnd) {
|
||||
primaryCert = primaryCert.substr(0, certEnd.index + certEnd[0].length);
|
||||
}
|
||||
|
||||
let certData = {
|
||||
privateKey,
|
||||
cert,
|
||||
ca,
|
||||
fingerprint: fp,
|
||||
updated: new Date()
|
||||
};
|
||||
|
||||
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) {
|
||||
// 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];
|
||||
}
|
||||
|
||||
if (description) {
|
||||
certData.description = description;
|
||||
}
|
||||
|
@ -62,7 +130,8 @@ class CertHandler {
|
|||
// should fail on invalid input
|
||||
tls.createSecureContext({
|
||||
key: privateKey,
|
||||
cert
|
||||
cert,
|
||||
ca
|
||||
});
|
||||
} catch (E) {
|
||||
let err = new Error('Invalid or incompatible key and certificate. ' + E.message);
|
||||
|
@ -119,9 +188,11 @@ class CertHandler {
|
|||
|
||||
return {
|
||||
id: r.value._id.toString(),
|
||||
servername: certData.servername,
|
||||
servername,
|
||||
description: certData.description,
|
||||
fingerprint: certData.fingerprint
|
||||
fingerprint: certData.fingerprint,
|
||||
expires: certData.expires && certData.expires.toISOString(),
|
||||
altNames: certData.altNames
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -163,6 +234,8 @@ class CertHandler {
|
|||
description: certData.description,
|
||||
fingerprint: certData.fingerprint,
|
||||
privateKey,
|
||||
expires: certData.expires,
|
||||
altNames: certData.altNames,
|
||||
created: certData.created
|
||||
};
|
||||
}
|
||||
|
@ -229,6 +302,66 @@ class CertHandler {
|
|||
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
if (!certData) {
|
||||
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 } });
|
||||
if (!certData) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CertHandler;
|
||||
|
|
36
lib/certs.js
36
lib/certs.js
|
@ -2,14 +2,11 @@
|
|||
|
||||
const config = require('wild-config');
|
||||
const fs = require('fs');
|
||||
const tls = require('tls');
|
||||
const db = require('./db');
|
||||
const tlsOptions = require('../imap-core/lib/tls-options');
|
||||
const CertHandler = require('./cert-handler');
|
||||
|
||||
const certs = new Map();
|
||||
const servers = [];
|
||||
const ctxCache = new Map();
|
||||
|
||||
let certHandler;
|
||||
|
||||
|
@ -113,21 +110,6 @@ module.exports.registerReload = (server, name) => {
|
|||
};
|
||||
|
||||
module.exports.getContextForServername = async (servername, serverOptions) => {
|
||||
const query = { servername };
|
||||
let cachedContext = false;
|
||||
if (ctxCache.has(servername)) {
|
||||
cachedContext = ctxCache.get(servername);
|
||||
if (cachedContext.entry && cachedContext.entry.v) {
|
||||
// check for updates
|
||||
query.v = { $ne: cachedContext.entry.v };
|
||||
}
|
||||
}
|
||||
|
||||
const certData = await db.database.collection('certs').findOne(query);
|
||||
if (!certData) {
|
||||
return (cachedContext && cachedContext.context) || false;
|
||||
}
|
||||
|
||||
if (!certHandler) {
|
||||
certHandler = new CertHandler({
|
||||
cipher: config.certs && config.certs.cipher,
|
||||
|
@ -137,23 +119,7 @@ module.exports.getContextForServername = async (servername, serverOptions) => {
|
|||
});
|
||||
}
|
||||
|
||||
// key might be encrypted
|
||||
let privateKey = certHandler.decodeKey(certData.privateKey);
|
||||
|
||||
let serviceCtxOpts = { key: privateKey, cert: certData.cert };
|
||||
for (let key of ['dhparam']) {
|
||||
if (serverOptions[key]) {
|
||||
serviceCtxOpts[key] = serverOptions[key];
|
||||
}
|
||||
}
|
||||
|
||||
let ctxOptions = tlsOptions(serviceCtxOpts);
|
||||
|
||||
let context = tls.createSecureContext(ctxOptions);
|
||||
|
||||
ctxCache.set(servername, { entry: certData, context });
|
||||
|
||||
return context;
|
||||
return certHandler.getContextForServername(servername, serverOptions);
|
||||
};
|
||||
|
||||
config.on('reload', () => {
|
||||
|
|
|
@ -9,7 +9,7 @@ const uuid = require('uuid');
|
|||
const os = require('os');
|
||||
const hostname = os.hostname().toLowerCase();
|
||||
const addressparser = require('nodemailer/lib/addressparser');
|
||||
const punycode = require('punycode');
|
||||
const punycode = require('punycode/');
|
||||
const crypto = require('crypto');
|
||||
const tools = require('./tools');
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ const crypto = require('crypto');
|
|||
const tlsOptions = require('../../imap-core/lib/tls-options');
|
||||
const shared = require('nodemailer/lib/shared');
|
||||
const POP3Connection = require('./connection');
|
||||
const punycode = require('punycode');
|
||||
const punycode = require('punycode/');
|
||||
const base32 = require('base32.js');
|
||||
const errors = require('../errors');
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const os = require('os');
|
||||
const punycode = require('punycode');
|
||||
const punycode = require('punycode/');
|
||||
const libmime = require('libmime');
|
||||
const consts = require('./consts');
|
||||
const errors = require('./errors');
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
"author": "Andris Reinman",
|
||||
"license": "EUPL-1.2",
|
||||
"devDependencies": {
|
||||
"ajv": "8.3.0",
|
||||
"ajv": "8.4.0",
|
||||
"chai": "4.3.4",
|
||||
"docsify-cli": "4.4.3",
|
||||
"eslint": "7.26.0",
|
||||
|
@ -67,6 +67,7 @@
|
|||
"npmlog": "4.1.2",
|
||||
"openpgp": "4.10.10",
|
||||
"pem": "1.14.4",
|
||||
"punycode": "^2.1.1",
|
||||
"pwnedpasswords": "1.0.5",
|
||||
"qrcode": "1.4.4",
|
||||
"restify": "8.5.1",
|
||||
|
|
Loading…
Add table
Reference in a new issue