Allow using SNI TLS certificates with IMAP/POP3

This commit is contained in:
Andris Reinman 2021-05-13 17:08:14 +03:00
parent 4edad37888
commit eec5d66094
20 changed files with 1055 additions and 72 deletions

2
api.js
View file

@ -39,6 +39,7 @@ const submitRoutes = require('./lib/api/submit');
const auditRoutes = require('./lib/api/audit');
const domainaliasRoutes = require('./lib/api/domainaliases');
const dkimRoutes = require('./lib/api/dkim');
const certsRoutes = require('./lib/api/certs');
const webhooksRoutes = require('./lib/api/webhooks');
let userHandler;
@ -500,6 +501,7 @@ module.exports = done => {
auditRoutes(db, server, auditHandler);
domainaliasRoutes(db, server);
dkimRoutes(db, server);
certsRoutes(db, server);
webhooksRoutes(db, server);
server.on('error', err => {

View file

@ -80,6 +80,11 @@ maxForwards=2000
[dkim]
# @include "dkim.toml"
[certs]
# Encrypt stored TLS private keys
#cipher="aes192"
#secret="a secret cat"
[plugins]
# @include "plugins/*.toml"

View file

@ -66,6 +66,7 @@ secure=true
## If certificate path is not defined, use global or built-in self-signed certs
#key="/path/to/server/key.pem"
#cert="/path/to/server/cert.pem"
#dhparam="/path/to/server/dhparam.pem"
## You can also define extra options for specific TLS settings:

View file

@ -75,6 +75,13 @@
"delete:any": ["*"]
},
"certs": {
"create:any": ["*"],
"read:any": ["*"],
"update:any": ["*"],
"delete:any": ["*"]
},
"dkim": {
"create:any": ["*"],
"read:any": ["*"],
@ -152,6 +159,13 @@
"delete:any": ["*"]
},
"certs": {
"create:any": ["*"],
"read:any": ["*"],
"update:any": ["*"],
"delete:any": ["*"]
},
"dkim": {
"create:any": ["*"],
"read:any": ["*"],

View file

@ -2,3 +2,4 @@
#key="/path/to/server/key.pem"
#ca=["/path/to/server/ca1.pem", "/path/to/server/ca2.pem"]
#cert="/path/to/server/cert.pem"
#dhparam="/path/to/server/dhparam.pem"

View file

@ -13,20 +13,28 @@ tags:
- name: Addresses
- name: ApplicationPasswords
- name: Archive
description: Archive includes all deleted messages. Once messages are old enough then these are permanenetly deleted from the archive as well. Until then you can restore the deleted messages.
- name: Audit
description: 'Auditing allows to monitor an email account. All existing, deleted and new emails are copied to the auditing system. See also https://github.com/nodemailer/wildduck-audit-manager'
- name: Authentication
- name: Autoreplies
- name: Certs
description: WildDuck allows to register TLS certificates to be used with SNI connections. These certificates are used by IMAP and POP3 servers when a SNI capable client establishes a TLS connection.
- name: DKIM
description: Whenever an email is sent WildDuck checks if there is a DKIM key registered for the domain name of the sender address and uses it to sign the message.
- name: DomainAccess
description: Add sender domain names to allowlist (messages are all accepted) or blocklist (messages are sent to Spam folder)
- name: DomainAliases
- name: Filters
- name: Mailboxes
- name: Messages
- name: Storage
description: Storage allows easier attachment handling when composing Draft messages. Instead of uploading the attachmnent with every draft update, you store the attachment to the Storage and then link stored file for the Draft.
- name: Submission
- name: TwoFactorAuth
- name: Users
- name: Webhooks
paths:
'/addresses/forwarded/{address}':
delete:
@ -751,6 +759,7 @@ paths:
required: true
schema:
type: string
'/dkim/{dkim}':
delete:
tags:
@ -854,7 +863,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ResolveDkimResponse'
$ref: '#/components/schemas/ResolveIdResponse'
parameters:
- name: domain
in: path
@ -862,6 +871,120 @@ paths:
required: true
schema:
type: string
'/certs/{cert}':
delete:
tags:
- Certs
summary: Delete a TLS certificate
operationId: deleteTlsCert
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
get:
tags:
- Certs
summary: Request TLS certificate information
operationId: getTLSCerticate
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/GetTLSCertResult'
parameters:
- name: cert
in: path
description: ID of the TLS certificate
required: true
schema:
type: string
/certs:
get:
tags:
- Certs
summary: List registered TLS certificates
operationId: getTLSCerticates
parameters:
- name: query
in: query
description: Partial match of a servername
schema:
type: string
- name: limit
in: query
description: How many records to return
schema:
type: number
- name: page
in: query
description: 'Current page number. Informational only, page numbers start from 1'
schema:
type: number
- name: next
in: query
description: 'Cursor value for next page, retrieved from nextCursor response value'
schema:
type: number
- name: previous
in: query
description: 'Cursor value for previous page, retrieved from previousCursor response value'
schema:
type: number
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/GetTLSCertsResponse'
post:
tags:
- Certs
summary: Create or update TLS certificate for server name
description: Add a new TLS certificate for a server name or update existing one. There can be single certificate key registered for each server name.
operationId: updateTLSCertificate
requestBody:
description: Add a new TLS certificate for a server name or update existing one. There can be single certificate key registered for each server name.
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateTLSCertRequest'
required: true
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateTLSCertResponse'
'/certs/resolve/{servername}':
get:
tags:
- Certs
summary: Resolve ID for a server name
operationId: resolveTLSCertificate
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/ResolveIdResponse'
parameters:
- name: servername
in: path
description: Server name
required: true
schema:
type: string
'/domainaccess/{domain}':
delete:
tags:
@ -1062,7 +1185,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ResolveDomainAliasResponse'
$ref: '#/components/schemas/ResolveIdResponse'
parameters:
- name: alias
in: path
@ -2204,7 +2327,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ResolveUserResponse'
$ref: '#/components/schemas/ResolveIdResponse'
parameters:
- name: username
in: path
@ -3736,6 +3859,7 @@ components:
type: string
description: Datestring of the Event time
format: date-time
GetAutoreplyResponse:
type: object
properties:
@ -3774,6 +3898,7 @@ components:
- text
- start
- end
GetDkimKeyResponse:
required:
- success
@ -3814,6 +3939,7 @@ components:
type: string
description: Datestring
format: date-time
GetDkimKeysResponse:
required:
- success
@ -3843,7 +3969,39 @@ components:
type: array
items:
$ref: '#/components/schemas/GetDkimKeysResult'
description: Aliases listing
description: DKIM listing
GetTLSCertsResponse:
required:
- success
- total
- page
- previousCursor
- nextCursor
- results
type: object
properties:
success:
type: boolean
description: Indicates successful response
total:
type: number
description: How many results were found
page:
type: number
description: Current page number. Derived from page query argument
previousCursor:
type: string
description: Either a cursor string or false if there are not any previous results
nextCursor:
type: string
description: Either a cursor string or false if there are not any next results
results:
type: array
items:
$ref: '#/components/schemas/GetTLSCertResult'
description: Certificate listing
UpdateDkimKeyResponse:
required:
- success
@ -3879,7 +4037,32 @@ components:
description: 'Public key in DNS format (no prefix/suffix, single line)'
dnsTxt:
$ref: '#/components/schemas/DnsTxt'
ResolveDkimResponse:
UpdateTLSCertResponse:
required:
- success
- id
- servername
- fingerprint
type: object
properties:
success:
type: boolean
description: Indicates successful response
id:
type: string
description: ID of the certificate
domain:
type: string
description: The server name this certificate applies to
description:
type: string
description: Key description
fingerprint:
type: string
description: Key fingerprint (SHA1)
ResolveIdResponse:
required:
- success
- id
@ -3890,7 +4073,8 @@ components:
description: Indicates successful response
id:
type: string
description: DKIM unique ID (24 byte hex)
description: Unique ID (24 byte hex)
GetAllowedDomainResponse:
required:
- success
@ -3998,6 +4182,7 @@ components:
items:
$ref: '#/components/schemas/GetDomainAliasesResult'
description: Aliases listing
CreateDomainAliasResponse:
required:
- success
@ -4010,18 +4195,7 @@ components:
id:
type: string
description: ID of the Domain Alias
ResolveDomainAliasResponse:
required:
- success
- id
type: object
properties:
success:
type: boolean
description: Indicates successful response
id:
type: string
description: Alias unique ID (24 byte hex)
GetFilterResponse:
required:
- success
@ -4513,18 +4687,7 @@ components:
suspended:
type: boolean
description: If true then the user can not authenticate
ResolveUserResponse:
required:
- success
- id
type: object
properties:
success:
type: boolean
description: Indicates successful response
id:
type: string
description: Users unique ID (24 byte hex)
GetUsersResponse:
required:
- success
@ -4907,6 +5070,7 @@ components:
type: string
description: Datestring of the end of the autoreply or boolean false to disable end checks
format: date-time
UpdateDkimKeyRequest:
required:
- domain
@ -4925,6 +5089,27 @@ components:
privateKey:
type: string
description: 'Pem formatted DKIM private key. If not set then a new 2048 bit RSA key is generated, beware though that it can take several seconds to complete.'
UpdateTLSCertRequest:
required:
- servername
- privateKey
- cert
type: object
properties:
servername:
type: string
description: Server name this TLS certificate applies to.
cert:
type: string
description: PEM formatted TLS certificate or a certificate bundle
privateKey:
type: string
description: PEM formatted TLS private key
description:
type: string
description: Certificate description
CreateAllowedDomainRequest:
required:
- domain
@ -5666,6 +5851,7 @@ components:
type: string
description: Datestring of the Event time
format: date-time
GetDkimKeysResult:
required:
- id
@ -5695,6 +5881,33 @@ components:
type: string
description: Datestring
format: date-time
GetTLSCertResult:
required:
- id
- servername
- description
- fingerprint
- created
type: object
properties:
id:
type: string
description: ID of the certificate
domain:
type: string
description: The server name this certificate applies to
description:
type: string
description: Key description
fingerprint:
type: string
description: Key fingerprint (SHA1)
created:
type: string
description: Datestring
format: date-time
GetAllowedDomainResult:
required:
- id

View file

@ -37,9 +37,22 @@ function upgrade(connection) {
secureContext,
isServer: true,
server: connection._server.server,
SNICallback: (servername, cb) => {
cb(null, connection._server.secureContext.get(connection._server._normalizeHostname(servername)) || connection._server.secureContext.get('*'));
// eslint-disable-next-line new-cap
connection._server.options.SNICallback(servername, (err, context) => {
if (err) {
connection._server.logger.error(
{
tnx: 'sni',
servername,
err
},
'Failed to fetch SNI context for servername %s',
servername
);
}
return cb(null, context || connection._server.secureContext.get('*'));
});
}
};

View file

@ -127,12 +127,14 @@ class IMAPConnection extends EventEmitter {
this.logger.info(
{
tnx: 'connect',
cid: this.id
cid: this.id,
servername: this._socket && this._socket.servername
},
'[%s] %s from %s to %s:%s',
'[%s] %s from %s to %s %s:%s',
this.id,
this.secure ? 'Secure connection' : 'Connection',
this.session.clientHostname,
(this._socket && this._socket.servername) || os.hostname(),
this._socket && this._socket.localAddress,
this._socket && this._socket.localPort
);

View file

@ -233,9 +233,7 @@ class IMAPServer extends EventEmitter {
socket.unshift(remainder);
}
let header = Buffer.concat(chunks, chunklen)
.toString()
.trim();
let header = Buffer.concat(chunks, chunklen).toString().trim();
let params = (header || '').toString().split(' ');
let commandName = params.shift().toUpperCase();
@ -288,7 +286,23 @@ class IMAPServer extends EventEmitter {
secureContext: this.secureContext.get('*'),
isServer: true,
server: this.server,
SNICallback: this.options.SNICallback
SNICallback: (servername, cb) => {
// eslint-disable-next-line new-cap
this.options.SNICallback(this._normalizeHostname(servername), (err, context) => {
if (err) {
this.logger.error(
{
tnx: 'sni',
servername,
err
},
'Failed to fetch SNI context for servername %s',
servername
);
}
return cb(null, context || this.secureContext.get('*'));
});
}
};
let remoteAddress = socket.remoteAddress;
@ -336,6 +350,7 @@ class IMAPServer extends EventEmitter {
return onError();
}
};
tlsSocket.once('close', onCloseError);
tlsSocket.once('error', onError);
tlsSocket.once('_tlsError', onError);
@ -397,7 +412,7 @@ class IMAPServer extends EventEmitter {
if (typeof this.options.SNICallback !== 'function') {
// create default SNI handler
this.options.SNICallback = (servername, cb) => {
cb(null, this.secureContext.get(this._normalizeHostname(servername)) || this.secureContext.get('*'));
cb(null, this.secureContext.get(servername));
};
}
}

View file

@ -54,11 +54,7 @@ const tlsDefaults = {
'-----END CERTIFICATE-----',
honorCipherOrder: true,
requestOCSP: false,
sessionIdContext: crypto
.createHash('sha1')
.update(process.argv.join(' '))
.digest('hex')
.slice(0, 32)
sessionIdContext: crypto.createHash('sha1').update(process.argv.join(' ')).digest('hex').slice(0, 32)
};
/**
@ -68,19 +64,5 @@ const tlsDefaults = {
* @returns {Object} Object with mixed TLS values
*/
function getTLSOptions(opts) {
let result = {};
opts = opts || {};
Object.keys(opts).forEach(key => {
result[key] = opts[key];
});
Object.keys(tlsDefaults).forEach(key => {
if (!(key in result)) {
result[key] = tlsDefaults[key];
}
});
return result;
return Object.assign({}, tlsDefaults, opts || {});
}

View file

@ -82,7 +82,14 @@ let createInterface = (ifaceOptions, callback) => {
enableCompression: !!config.imap.enableCompression,
skipFetchLog: config.log.skipFetchLog
skipFetchLog: config.log.skipFetchLog,
SNICallback(servername, cb) {
certs
.getContextForServername(servername, serverOptions)
.then(context => cb(null, context))
.catch(err => cb(err));
}
};
certs.loadTLSOptions(serverOptions, 'imap');

View file

@ -595,6 +595,20 @@ indexes:
task: 1
user: 1
- collection: certs
index:
name: servername
unique: true
key:
servername: 1
- collection: certs
index:
name: servername_version
key:
servername: 1
v: 1
- collection: audits
index:
name: user_expire_time

385
lib/api/certs.js Normal file
View file

@ -0,0 +1,385 @@
'use strict';
const config = require('wild-config');
const Joi = require('joi');
const MongoPaging = require('mongo-cursor-pagination');
const ObjectID = require('mongodb').ObjectID;
const CertHandler = require('../cert-handler');
const tools = require('../tools');
const roles = require('../roles');
const { nextPageCursorSchema, previousPageCursorSchema, pageNrSchema, sessSchema, sessIPSchema } = require('../schemas');
module.exports = (db, server) => {
const certHandler = new CertHandler({
cipher: config.certs && config.certs.cipher,
secret: config.certs && config.certs.secret,
database: db.database,
redis: db.redis
});
server.get(
{ name: 'cert', path: '/certs' },
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
query: Joi.string().empty('').trim().max(255),
limit: Joi.number().default(20).min(1).max(250),
next: nextPageCursorSchema,
previous: previousPageCursorSchema,
page: pageNrSchema,
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true,
allowUnknown: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
// permissions check
req.validate(roles.can(req.role).readAny('certs'));
let query = result.value.query;
let limit = result.value.limit;
let page = result.value.page;
let pageNext = result.value.next;
let pagePrevious = result.value.previous;
let filter = query
? {
servername: {
$regex: query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'),
$options: ''
}
}
: {};
let total = await db.database.collection('certs').countDocuments(filter);
let opts = {
limit,
query: filter,
paginatedField: 'servername',
sortAscending: true
};
if (pageNext) {
opts.next = pageNext;
} else if ((!page || page > 1) && pagePrevious) {
opts.previous = pagePrevious;
}
let listing;
try {
listing = await MongoPaging.find(db.database.collection('certs'), opts);
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!listing.hasPrevious) {
page = 1;
}
let response = {
success: true,
query,
total,
page,
previousCursor: listing.hasPrevious ? listing.previous : false,
nextCursor: listing.hasNext ? listing.next : false,
results: (listing.results || []).map(certData => ({
id: certData._id.toString(),
servername: certData.servername,
description: certData.description,
fingerprint: certData.fingerprint,
created: certData.created
}))
};
res.json(response);
return next();
})
);
server.get(
'/certs/resolve/:servername',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
servername: Joi.string()
.max(255)
//.hostname()
.required(),
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
// permissions check
req.validate(roles.can(req.role).readAny('certs'));
let servername = tools.normalizeDomain(result.value.servername);
let certData;
try {
certData = await db.database.collection('certs').findOne(
{
servername
},
{
projection: { _id: 1 }
}
);
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!certData) {
res.status(404);
res.json({
error: 'This servername does not exist',
code: 'CertNotFound'
});
return next();
}
res.json({
success: true,
id: certData._id.toString()
});
return next();
})
);
server.post(
'/certs',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
servername: Joi.string().max(255).hostname().required(),
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(),
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
// permissions check
req.validate(roles.can(req.role).createAny('certs'));
let response;
try {
response = await certHandler.set(result.value);
} catch (err) {
switch (err.code) {
case 'InputValidationError':
res.status(400);
break;
case 'CertNotFound':
res.status(404);
break;
default:
res.status(500);
}
res.json({
error: err.message,
code: err.code
});
return next();
}
if (response) {
response.success = true;
}
res.json(response);
return next();
})
);
server.get(
'/certs/:certs',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
certs: Joi.string().hex().lowercase().length(24).required(),
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
// permissions check
req.validate(roles.can(req.role).readAny('certs'));
let certs = new ObjectID(result.value.certs);
let response;
try {
response = await certHandler.get({ _id: certs }, false);
} catch (err) {
switch (err.code) {
case 'InputValidationError':
res.status(400);
break;
case 'CertNotFound':
res.status(404);
break;
default:
res.status(500);
}
res.json({
error: err.message,
code: err.code
});
return next();
}
if (response) {
response.success = true;
}
res.json(response);
return next();
})
);
server.del(
'/certs/:certs',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
certs: Joi.string().hex().lowercase().length(24).required(),
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
// permissions check
req.validate(roles.can(req.role).deleteAny('certs'));
let certs = new ObjectID(result.value.certs);
let response;
try {
response = await certHandler.del({ _id: certs });
} catch (err) {
switch (err.code) {
case 'InputValidationError':
res.status(400);
break;
case 'CertNotFound':
res.status(404);
break;
default:
res.status(500);
}
res.json({
error: err.message,
code: err.code
});
return next();
}
res.json({
success: response
});
return next();
})
);
};

234
lib/cert-handler.js Normal file
View file

@ -0,0 +1,234 @@
'use strict';
const ObjectID = require('mongodb').ObjectID;
const fingerprint = require('key-fingerprint').fingerprint;
const crypto = require('crypto');
const tls = require('tls');
const tools = require('./tools');
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;
this.loggelf = options.loggelf || (() => false);
}
async set(options) {
const servername = tools.normalizeDomain(options.servername);
const description = options.description;
let privateKey = options.privateKey;
const cert = options.cert;
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;
}
}
let certData = {
privateKey,
cert,
fingerprint: fp,
updated: new Date()
};
if (description) {
certData.description = description;
}
try {
// should fail on invalid input
tls.createSecureContext({
key: privateKey,
cert
});
} 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(),
servername: certData.servername,
description: certData.description,
fingerprint: certData.fingerprint
};
}
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,
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;
}
}
module.exports = CertHandler;

View file

@ -2,9 +2,16 @@
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;
module.exports.reload = () => {
// load certificate files
@ -31,7 +38,7 @@ module.exports.reload = () => {
return;
}
let key, cert, ca;
let key, cert, ca, dhparam;
if (tlsconf.key) {
key = fs.readFileSync(tlsconf.key, 'ascii');
@ -45,6 +52,10 @@ module.exports.reload = () => {
cert = fs.readFileSync(tlsconf.cert, 'ascii');
}
if (tlsconf.dhparam) {
dhparam = fs.readFileSync(tlsconf.dhparam, 'ascii');
}
if (tlsconf.ca) {
ca = [].concat(tlsconf.ca || []).map(ca => fs.readFileSync(ca, 'ascii'));
if (!ca.length) {
@ -55,7 +66,8 @@ module.exports.reload = () => {
certs.set(type || 'default', {
key,
cert,
ca
ca,
dhparam
});
});
@ -74,19 +86,25 @@ module.exports.get = type => (certs.has(type) ? certs.get(type) : certs.get('def
module.exports.loadTLSOptions = (serverOptions, name) => {
Object.keys(config[name].tls || {}).forEach(key => {
if (!['key', 'cert', 'ca'].includes(key)) {
if (!['key', 'cert', 'ca', 'dhparam'].includes(key)) {
serverOptions[key] = config[name].tls[key];
}
});
let serverCerts = certs.get(name);
let serverCerts = module.exports.get(name);
if (serverCerts) {
serverOptions.key = serverCerts.key;
if (serverCerts.ca) {
serverOptions.ca = serverCerts.ca;
}
serverOptions.cert = serverCerts.cert;
if (serverCerts.dhparam) {
serverOptions.dhparam = serverCerts.dhparam;
}
}
};
@ -94,6 +112,50 @@ module.exports.registerReload = (server, name) => {
servers.push({ 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,
secret: config.certs && config.certs.secret,
database: db.database,
redis: db.redis
});
}
// 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;
};
config.on('reload', () => {
module.exports.reload();
servers.forEach(entry => {
@ -105,6 +167,9 @@ config.on('reload', () => {
certOptions.ca = serverCerts.ca;
}
certOptions.cert = serverCerts.cert;
if (serverCerts.dhparam) {
certOptions.dhparam = serverCerts.dhparam;
}
entry.server.updateSecureContext(certOptions);
}
});

View file

@ -11,6 +11,9 @@ module.exports = {
DKIM_CREATED: 'dkim.created',
DKIM_UPDATED: 'dkim.updated',
DKIM_DELETED: 'dkim.deleted',
CERT_CREATED: 'cert.created',
CERT_UPDATED: 'cert.updated',
CERT_DELETED: 'cert.deleted',
DOMAINALIAS_CREATED: 'domainalias.created',
DOMAINALIAS_DELETED: 'domainalias.deleted',
ADDRESS_USER_CREATED: 'address.user.created',

View file

@ -5,6 +5,7 @@ const EventEmitter = require('events');
const base32 = require('base32.js');
const packageData = require('../../package.json');
const DataStream = require('nodemailer/lib/smtp-connection/data-stream');
const os = require('os');
const SOCKET_TIMEOUT = 60 * 1000;
@ -52,8 +53,11 @@ class POP3Connection extends EventEmitter {
cid: this.id,
host: this.remoteAddress
},
'Connection from %s',
this.remoteAddress
'Connection from %s to %s %s:%s',
this.remoteAddress,
(this._socket && this._socket.servername) || os.hostname(),
this._socket && this._socket.localAddress,
this._socket && this._socket.localPort
);
this.send(
'+OK ' +

View file

@ -78,7 +78,23 @@ class POP3Server extends EventEmitter {
secureContext: this.secureContext.get('*'),
isServer: true,
server: this.server,
SNICallback: this.options.SNICallback
SNICallback: (servername, cb) => {
// eslint-disable-next-line new-cap
this.options.SNICallback(this._normalizeHostname(servername), (err, context) => {
if (err) {
this.logger.error(
{
tnx: 'sni',
servername,
err
},
'Failed to fetch SNI context for servername %s',
servername
);
}
return cb(null, context || this.secureContext.get('*'));
});
}
};
let remoteAddress = socket.remoteAddress;
@ -187,7 +203,7 @@ class POP3Server extends EventEmitter {
if (typeof this.options.SNICallback !== 'function') {
// create default SNI handler
this.options.SNICallback = (servername, cb) => {
cb(null, this.secureContext.get(this._normalizeHostname(servername)) || this.secureContext.get('*'));
cb(null, this.secureContext.get(servername));
};
}
}

View file

@ -16,10 +16,10 @@
"author": "Andris Reinman",
"license": "EUPL-1.2",
"devDependencies": {
"ajv": "8.2.0",
"ajv": "8.3.0",
"chai": "4.3.4",
"docsify-cli": "4.4.3",
"eslint": "7.25.0",
"eslint": "7.26.0",
"eslint-config-nodemailer": "1.2.0",
"eslint-config-prettier": "8.3.0",
"grunt": "1.4.0",
@ -30,7 +30,7 @@
"grunt-wait": "0.3.0",
"imapflow": "1.0.57",
"mailparser": "3.2.0",
"mocha": "8.3.2",
"mocha": "8.4.0",
"request": "2.88.2",
"supertest": "6.1.3"
},
@ -41,7 +41,7 @@
"axios": "0.21.1",
"base32.js": "0.1.0",
"bcryptjs": "2.4.3",
"bull": "3.22.4",
"bull": "3.22.5",
"gelf": "2.0.1",
"generate-password": "1.6.0",
"he": "1.2.0",

View file

@ -39,6 +39,13 @@ const serverOptions = {
version: config.pop3.version || packageData.version
},
SNICallback(servername, cb) {
certs
.getContextForServername(servername, serverOptions)
.then(context => cb(null, context))
.catch(err => cb(err));
},
// log to console
logger: {
info(...args) {