mirror of
https://github.com/nodemailer/wildduck.git
synced 2025-01-27 10:18:25 +08:00
Allow encrypting DKIM keys in rest
This commit is contained in:
parent
cfe1e4ff63
commit
6fcbe6d72d
4 changed files with 279 additions and 155 deletions
|
@ -29,6 +29,13 @@ bugsnagCode=""
|
|||
[dbs]
|
||||
# @include "dbs.toml"
|
||||
|
||||
[dkim]
|
||||
# If enabled then encrypt DKIM keys with the secret password. By default DKIM keys
|
||||
# are not encrypted and stored as cleartext. Once set up do not change these values,
|
||||
# otherwise decrypting DKIM keys is going to fail
|
||||
#cipher="aes192"
|
||||
#secret="a secret cat"
|
||||
|
||||
[totp]
|
||||
# If enabled then encrypt TOTP seed tokens with the secret password. By default TOTP seeds
|
||||
# are not encrypted and stored as cleartext. Once set up do not change these values,
|
||||
|
|
182
lib/api/dkim.js
182
lib/api/dkim.js
|
@ -1,14 +1,18 @@
|
|||
'use strict';
|
||||
|
||||
const config = require('wild-config');
|
||||
const Joi = require('../joi');
|
||||
const MongoPaging = require('mongo-cursor-pagination-node6');
|
||||
const ObjectID = require('mongodb').ObjectID;
|
||||
const tools = require('../tools');
|
||||
const fingerprint = require('key-fingerprint').fingerprint;
|
||||
const pki = require('node-forge').pki;
|
||||
const crypto = require('crypto');
|
||||
const DkimHandler = require('../dkim-handler');
|
||||
|
||||
module.exports = (db, server) => {
|
||||
const dkimHandler = new DkimHandler({
|
||||
cipher: config.dkim.cipher,
|
||||
secret: config.dkim.secret,
|
||||
database: db.database
|
||||
});
|
||||
|
||||
/**
|
||||
* @api {get} /dkim List registered DKIM keys
|
||||
* @apiName GetDkim
|
||||
|
@ -121,7 +125,7 @@ module.exports = (db, server) => {
|
|||
}
|
||||
: {};
|
||||
|
||||
db.users.collection('dkim').count(filter, (err, total) => {
|
||||
db.database.collection('dkim').count(filter, (err, total) => {
|
||||
if (err) {
|
||||
res.json({
|
||||
error: err.message
|
||||
|
@ -142,7 +146,7 @@ module.exports = (db, server) => {
|
|||
opts.previous = pagePrevious;
|
||||
}
|
||||
|
||||
MongoPaging.find(db.users.collection('dkim'), opts, (err, result) => {
|
||||
MongoPaging.find(db.database.collection('dkim'), opts, (err, result) => {
|
||||
if (err) {
|
||||
res.json({
|
||||
error: err.message
|
||||
|
@ -275,89 +279,20 @@ module.exports = (db, server) => {
|
|||
return next();
|
||||
}
|
||||
|
||||
let domain = tools.normalizeDomain(req.params.domain);
|
||||
let selector = req.params.selector;
|
||||
let description = req.params.description;
|
||||
|
||||
let fp, publicKeyPem;
|
||||
try {
|
||||
fp = fingerprint(result.value.privateKey, 'sha256', true);
|
||||
let privateKey = pki.privateKeyFromPem(result.value.privateKey);
|
||||
let publicKey = pki.setRsaPublicKey(privateKey.n, privateKey.e);
|
||||
publicKeyPem = pki.publicKeyToPem(publicKey);
|
||||
if (!publicKeyPem) {
|
||||
throw new Error('Was not able to extract public key from private key');
|
||||
}
|
||||
|
||||
let ciphered = crypto.publicEncrypt(publicKeyPem, Buffer.from('secretvalue'));
|
||||
let deciphered = crypto.privateDecrypt(result.value.privateKey, ciphered);
|
||||
if (deciphered.toString() !== 'secretvalue') {
|
||||
throw new Error('Was not able to use key for encryption');
|
||||
}
|
||||
} catch (E) {
|
||||
res.json({
|
||||
error: 'Invalid or incompatible private key',
|
||||
code: 'InputValidationError'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
let dkimData = {
|
||||
domain,
|
||||
selector,
|
||||
privateKey: result.value.privateKey,
|
||||
publicKey: publicKeyPem,
|
||||
fingerprint: fp,
|
||||
created: new Date(),
|
||||
latest: true
|
||||
};
|
||||
|
||||
if (description) {
|
||||
dkimData.description = description;
|
||||
}
|
||||
|
||||
db.users.collection('dkim').findOneAndReplace(
|
||||
{
|
||||
domain
|
||||
},
|
||||
dkimData,
|
||||
{
|
||||
upsert: true,
|
||||
returnOriginal: false
|
||||
},
|
||||
(err, r) => {
|
||||
if (err) {
|
||||
res.json({
|
||||
error: 'MongoDB Error: ' + err.message,
|
||||
code: 'InternalDatabaseError'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!r.value) {
|
||||
res.json({
|
||||
error: 'Failed to insert DKIM key',
|
||||
code: 'InternalDatabaseError'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
dkimHandler.set(result.value, (err, response) => {
|
||||
if (err) {
|
||||
res.json({
|
||||
success: true,
|
||||
id: r.value._id,
|
||||
domain: dkimData.domain,
|
||||
selector: dkimData.selector,
|
||||
description: dkimData.description,
|
||||
fingerprint: dkimData.fingerprint,
|
||||
publicKey: dkimData.publicKey,
|
||||
dnsTxt: {
|
||||
name: dkimData.selector + '._domainkey.' + dkimData.domain,
|
||||
value: 'v=DKIM1;t=s;p=' + dkimData.publicKey.replace(/^-.*-$/gm, '').replace(/\s/g, '')
|
||||
}
|
||||
error: err.message,
|
||||
code: err.code
|
||||
});
|
||||
return next();
|
||||
}
|
||||
);
|
||||
if (response) {
|
||||
response.success = true;
|
||||
}
|
||||
res.json(response);
|
||||
return next();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -438,44 +373,20 @@ module.exports = (db, server) => {
|
|||
|
||||
let dkim = new ObjectID(result.value.dkim);
|
||||
|
||||
db.users.collection('dkim').findOne(
|
||||
{
|
||||
_id: dkim
|
||||
},
|
||||
(err, dkimData) => {
|
||||
if (err) {
|
||||
res.json({
|
||||
error: 'MongoDB Error: ' + err.message,
|
||||
code: 'InternalDatabaseError'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
if (!dkimData) {
|
||||
res.status(404);
|
||||
res.json({
|
||||
error: 'Invalid or unknown DKIM key'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
dkimHandler.get(dkim, false, (err, response) => {
|
||||
if (err) {
|
||||
res.json({
|
||||
success: true,
|
||||
id: dkimData._id,
|
||||
domain: dkimData.domain,
|
||||
selector: dkimData.selector,
|
||||
description: dkimData.description,
|
||||
fingerprint: dkimData.fingerprint,
|
||||
publicKey: dkimData.publicKey,
|
||||
dnsTxt: {
|
||||
name: dkimData.selector + '._domainkey.' + dkimData.domain,
|
||||
value: 'v=DKIM1;t=s;p=' + dkimData.publicKey.replace(/^-.*-$/gm, '').replace(/\s/g, '')
|
||||
},
|
||||
created: dkimData.created
|
||||
error: err.message,
|
||||
code: err.code
|
||||
});
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
if (response) {
|
||||
response.success = true;
|
||||
}
|
||||
res.json(response);
|
||||
return next();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -535,33 +446,18 @@ module.exports = (db, server) => {
|
|||
|
||||
let dkim = new ObjectID(result.value.dkim);
|
||||
|
||||
// delete address from email address registry
|
||||
db.users.collection('dkim').deleteOne(
|
||||
{
|
||||
_id: dkim
|
||||
},
|
||||
(err, r) => {
|
||||
if (err) {
|
||||
res.json({
|
||||
error: 'MongoDB Error: ' + err.message,
|
||||
code: 'InternalDatabaseError'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!r.deletedCount) {
|
||||
res.status(404);
|
||||
res.json({
|
||||
error: 'Invalid or unknown DKIM key'
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
dkimHandler.del(dkim, (err, response) => {
|
||||
if (err) {
|
||||
res.json({
|
||||
success: !!r.deletedCount
|
||||
error: err.message,
|
||||
code: err.code
|
||||
});
|
||||
return next();
|
||||
}
|
||||
);
|
||||
res.json({
|
||||
success: response
|
||||
});
|
||||
return next();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
193
lib/dkim-handler.js
Normal file
193
lib/dkim-handler.js
Normal file
|
@ -0,0 +1,193 @@
|
|||
'use strict';
|
||||
|
||||
const ObjectID = require('mongodb').ObjectID;
|
||||
const fingerprint = require('key-fingerprint').fingerprint;
|
||||
const pki = require('node-forge').pki;
|
||||
const crypto = require('crypto');
|
||||
const tools = require('./tools');
|
||||
|
||||
class DkimHandler {
|
||||
constructor(options) {
|
||||
options = options || {};
|
||||
this.cipher = options.cipher;
|
||||
this.secret = options.secret;
|
||||
|
||||
this.database = options.database;
|
||||
}
|
||||
|
||||
set(options, callback) {
|
||||
const domain = tools.normalizeDomain(options.domain);
|
||||
const selector = options.selector;
|
||||
const description = options.description;
|
||||
|
||||
let privateKeyPem = options.privateKey;
|
||||
|
||||
let fp, publicKeyPem;
|
||||
try {
|
||||
fp = fingerprint(privateKeyPem, 'sha256', true);
|
||||
let privateKey = pki.privateKeyFromPem(privateKeyPem);
|
||||
let publicKey = pki.setRsaPublicKey(privateKey.n, privateKey.e);
|
||||
publicKeyPem = pki.publicKeyToPem(publicKey);
|
||||
if (!publicKeyPem) {
|
||||
throw new Error('Was not able to extract public key from private key');
|
||||
}
|
||||
|
||||
let ciphered = crypto.publicEncrypt(publicKeyPem, Buffer.from('secretvalue'));
|
||||
let deciphered = crypto.privateDecrypt(privateKeyPem, ciphered);
|
||||
if (deciphered.toString() !== 'secretvalue') {
|
||||
throw new Error('Was not able to use key for encryption');
|
||||
}
|
||||
} catch (E) {
|
||||
let err = new Error('Invalid or incompatible private key. ' + E.message);
|
||||
err.code = 'InputValidationError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (this.secret) {
|
||||
try {
|
||||
let cipher = crypto.createCipher(this.cipher || 'aes192', this.secret);
|
||||
privateKeyPem = '$' + cipher.update(privateKeyPem, 'utf8', 'hex');
|
||||
privateKeyPem += cipher.final('hex');
|
||||
} catch (E) {
|
||||
let err = new Error('Failed to encrypt private key. ' + E.message);
|
||||
err.code = 'InternalConfigError';
|
||||
return callback(err);
|
||||
}
|
||||
}
|
||||
|
||||
let dkimData = {
|
||||
domain,
|
||||
selector,
|
||||
privateKey: privateKeyPem,
|
||||
publicKey: publicKeyPem,
|
||||
fingerprint: fp,
|
||||
created: new Date(),
|
||||
latest: true
|
||||
};
|
||||
|
||||
if (description) {
|
||||
dkimData.description = description;
|
||||
}
|
||||
|
||||
this.database.collection('dkim').findOneAndReplace(
|
||||
{
|
||||
domain
|
||||
},
|
||||
dkimData,
|
||||
{
|
||||
upsert: true,
|
||||
returnOriginal: false
|
||||
},
|
||||
(err, r) => {
|
||||
if (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!r.value) {
|
||||
let err = new Error('Failed to insert DKIM key');
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
return callback(null, {
|
||||
id: r.value._id,
|
||||
domain: dkimData.domain,
|
||||
selector: dkimData.selector,
|
||||
description: dkimData.description,
|
||||
fingerprint: dkimData.fingerprint,
|
||||
publicKey: dkimData.publicKey,
|
||||
dnsTxt: {
|
||||
name: dkimData.selector + '._domainkey.' + dkimData.domain,
|
||||
value: 'v=DKIM1;t=s;p=' + dkimData.publicKey.replace(/^-.*-$/gm, '').replace(/\s/g, '')
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
get(domain, includePrivateKey, callback) {
|
||||
let query = {};
|
||||
if (ObjectID.isValid(domain)) {
|
||||
query._id = domain;
|
||||
} else {
|
||||
query.domain = tools.normalizeDomain(domain);
|
||||
}
|
||||
|
||||
this.database.collection('dkim').findOne(query, (err, dkimData) => {
|
||||
if (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
if (!dkimData) {
|
||||
let err = new Error('Invalid or unknown DKIM key');
|
||||
err.code = 'KeyNotFound';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let privateKey;
|
||||
if (includePrivateKey) {
|
||||
privateKey = dkimData.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';
|
||||
return callback(err);
|
||||
}
|
||||
} else {
|
||||
let err = new Error('Can not use decrypted key');
|
||||
err.code = 'InternalConfigError';
|
||||
return callback(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callback(null, {
|
||||
id: dkimData._id,
|
||||
domain: dkimData.domain,
|
||||
selector: dkimData.selector,
|
||||
description: dkimData.description,
|
||||
fingerprint: dkimData.fingerprint,
|
||||
publicKey: dkimData.publicKey,
|
||||
privateKey,
|
||||
dnsTxt: {
|
||||
name: dkimData.selector + '._domainkey.' + dkimData.domain,
|
||||
value: 'v=DKIM1;t=s;p=' + dkimData.publicKey.replace(/^-.*-$/gm, '').replace(/\s/g, '')
|
||||
},
|
||||
created: dkimData.created
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
del(domain, callback) {
|
||||
let query = {};
|
||||
if (ObjectID.isValid(domain)) {
|
||||
query._id = domain;
|
||||
} else {
|
||||
query.domain = tools.normalizeDomain(domain);
|
||||
}
|
||||
|
||||
// delete address from email address registry
|
||||
this.database.collection('dkim').deleteOne(query, (err, r) => {
|
||||
if (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!r.deletedCount) {
|
||||
let err = new Error('Invalid or unknown DKIM key');
|
||||
err.code = 'KeyNotFound';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
return callback(null, !!r.deletedCount);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DkimHandler;
|
|
@ -1074,9 +1074,16 @@ class UserHandler {
|
|||
if (userData.seed) {
|
||||
let secret = userData.seed;
|
||||
if (userData.seed.charAt(0) === '$' && config.totp && config.totp.secret) {
|
||||
let decipher = crypto.createDecipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
secret = decipher.update(userData.seed.substr(1), 'hex', 'utf-8');
|
||||
secret += decipher.final('utf8');
|
||||
try {
|
||||
let decipher = crypto.createDecipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
secret = decipher.update(userData.seed.substr(1), 'hex', 'utf-8');
|
||||
secret += decipher.final('utf8');
|
||||
} catch (E) {
|
||||
log.error('DB', 'TOTPFAIL decipher failed id=%s error=%s', user, E.message);
|
||||
let err = new Error('Can not use decrypted secret');
|
||||
err.code = 'InternalConfigError';
|
||||
return callback(err);
|
||||
}
|
||||
}
|
||||
|
||||
let otpauth_url = speakeasy.otpauthURL({
|
||||
|
@ -1104,9 +1111,16 @@ class UserHandler {
|
|||
|
||||
let seed = secret.base32;
|
||||
if (config.totp && config.totp.secret) {
|
||||
let cipher = crypto.createCipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
seed = '$' + cipher.update(seed, 'utf8', 'hex');
|
||||
seed += cipher.final('hex');
|
||||
try {
|
||||
let cipher = crypto.createCipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
seed = '$' + cipher.update(seed, 'utf8', 'hex');
|
||||
seed += cipher.final('hex');
|
||||
} catch (E) {
|
||||
log.error('DB', 'TOTPFAIL cipher failed id=%s error=%s', user, E.message);
|
||||
let err = new Error('Database Error, failed to update user');
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
}
|
||||
|
||||
return this.users.collection('users').findOneAndUpdate(
|
||||
|
@ -1197,9 +1211,16 @@ class UserHandler {
|
|||
|
||||
let secret = userData.seed;
|
||||
if (userData.seed.charAt(0) === '$' && config.totp && config.totp.secret) {
|
||||
let decipher = crypto.createDecipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
secret = decipher.update(userData.seed.substr(1), 'hex', 'utf-8');
|
||||
secret += decipher.final('utf8');
|
||||
try {
|
||||
let decipher = crypto.createDecipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
secret = decipher.update(userData.seed.substr(1), 'hex', 'utf-8');
|
||||
secret += decipher.final('utf8');
|
||||
} catch (E) {
|
||||
log.error('DB', 'TOTPFAIL decipher failed id=%s error=%s', user, E.message);
|
||||
let err = new Error('Can not use decrypted secret');
|
||||
err.code = 'InternalConfigError';
|
||||
return callback(err);
|
||||
}
|
||||
}
|
||||
|
||||
let verified = speakeasy.totp.verify({
|
||||
|
@ -1416,9 +1437,16 @@ class UserHandler {
|
|||
|
||||
let secret = userData.seed;
|
||||
if (userData.seed.charAt(0) === '$' && config.totp && config.totp.secret) {
|
||||
let decipher = crypto.createDecipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
secret = decipher.update(userData.seed.substr(1), 'hex', 'utf-8');
|
||||
secret += decipher.final('utf8');
|
||||
try {
|
||||
let decipher = crypto.createDecipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
secret = decipher.update(userData.seed.substr(1), 'hex', 'utf-8');
|
||||
secret += decipher.final('utf8');
|
||||
} catch (E) {
|
||||
log.error('DB', 'TOTPFAIL decipher failed id=%s error=%s', user, E.message);
|
||||
let err = new Error('Can not use decrypted secret');
|
||||
err.code = 'InternalConfigError';
|
||||
return callback(err);
|
||||
}
|
||||
}
|
||||
|
||||
let verified = speakeasy.totp.verify({
|
||||
|
|
Loading…
Reference in a new issue