mirror of
https://github.com/nodemailer/wildduck.git
synced 2025-09-06 21:24:37 +08:00
refactored encrypted fields
This commit is contained in:
parent
78b774865c
commit
6d3badf1db
7 changed files with 247 additions and 184 deletions
|
@ -29,8 +29,8 @@ maxForwards=2000
|
|||
# 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,
|
||||
# otherwise decrypting totp seeds is going to fail
|
||||
#cipher="aes192"
|
||||
#secret="a secret cat"
|
||||
#cipher="aes192" # only for decrypting legacy values (if there are any)
|
||||
|
||||
[u2f]
|
||||
# Fully qualified URL of your website (must use HTTPS!)
|
||||
|
@ -84,9 +84,9 @@ maxForwards=2000
|
|||
# @include "acme.toml"
|
||||
|
||||
[certs]
|
||||
# Encrypt stored TLS private keys
|
||||
#cipher="aes192"
|
||||
# Encrypt stored TLS private keys with the following password (disabled by default):
|
||||
#secret="a secret cat"
|
||||
#cipher="aes192" # only for decrypting legacy values (if there are any)
|
||||
|
||||
[plugins]
|
||||
# @include "plugins/*.toml"
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
# 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"
|
||||
#cipher="aes192" # only for decrypting legacy values (if there are any)
|
||||
|
||||
# If true then also adds a signature for the outbound domain
|
||||
# Affects WildDuck ZoneMTA plugin only
|
||||
|
|
|
@ -9,6 +9,7 @@ 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);
|
||||
|
@ -148,7 +149,7 @@ class CertHandler {
|
|||
throw err;
|
||||
}
|
||||
|
||||
let encodedPrivateKey = await this.encodeKey(updates.privateKey);
|
||||
let encodedPrivateKey = await encrypt(updates.privateKey, this.secret);
|
||||
|
||||
changes.$set = Object.assign({}, updates, { fp, privateKey: encodedPrivateKey });
|
||||
|
||||
|
@ -210,7 +211,7 @@ class CertHandler {
|
|||
throw err;
|
||||
}
|
||||
|
||||
let encodedPrivateKey = await this.encodeKey(privateKey);
|
||||
let encodedPrivateKey = await encrypt(privateKey, this.secret);
|
||||
|
||||
let r;
|
||||
try {
|
||||
|
@ -273,7 +274,7 @@ class CertHandler {
|
|||
throw err;
|
||||
}
|
||||
|
||||
certData.privateKey = await this.encodeKey(privateKey);
|
||||
certData.privateKey = await encrypt(privateKey, this.secret);
|
||||
}
|
||||
|
||||
if (cert) {
|
||||
|
@ -434,7 +435,7 @@ class CertHandler {
|
|||
}
|
||||
|
||||
if (includePrivateKey) {
|
||||
certData.privateKey = await this.decodeKey(certData.privateKey);
|
||||
certData.privateKey = await decrypt(certData.privateKey, this.secret, this.cipher);
|
||||
} else {
|
||||
delete certData.privateKey;
|
||||
}
|
||||
|
@ -442,55 +443,6 @@ class CertHandler {
|
|||
return certData;
|
||||
}
|
||||
|
||||
async encodeKey(privateKey) {
|
||||
if (!privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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.responseCode = 500;
|
||||
err.code = 'InternalConfigError';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
async decodeKey(privateKey) {
|
||||
if (!privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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.responseCode = 500;
|
||||
err.code = 'InternalConfigError';
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
let err = new Error('Can not use decrypted key');
|
||||
err.responseCode = 500;
|
||||
err.code = 'InternalConfigError';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
async del(options) {
|
||||
let query = this.prepareQuery(options);
|
||||
|
||||
|
@ -567,7 +519,7 @@ class CertHandler {
|
|||
}
|
||||
|
||||
// key might be encrypted
|
||||
let privateKey = await this.decodeKey(certData.privateKey);
|
||||
let privateKey = await decrypt(certData.privateKey, this.secret, this.cipher);
|
||||
|
||||
let serviceCtxOpts = { key: privateKey, cert: certData.cert, ca: certData.ca };
|
||||
for (let key of ['dhparam']) {
|
||||
|
|
|
@ -8,6 +8,7 @@ const forge = require('node-forge');
|
|||
const crypto = require('crypto');
|
||||
const tools = require('./tools');
|
||||
const { publish, DKIM_CREATED, DKIM_UPDATED, DKIM_DELETED } = require('./events');
|
||||
const { encrypt, decrypt } = require('./encrypt');
|
||||
|
||||
const { promisify } = require('util');
|
||||
const generateKeyPair = promisify(crypto.generateKeyPair);
|
||||
|
@ -112,90 +113,81 @@ class DkimHandler {
|
|||
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.responseCode = 500;
|
||||
err.code = 'InternalConfigError';
|
||||
return callback(err);
|
||||
}
|
||||
}
|
||||
encrypt(privateKeyPem, this.secret)
|
||||
.then(privateKeyPem => {
|
||||
let dkimData = {
|
||||
domain,
|
||||
selector,
|
||||
privateKey: privateKeyPem,
|
||||
publicKey: publicKeyPem,
|
||||
fingerprint: fp,
|
||||
created: new Date(),
|
||||
latest: true
|
||||
};
|
||||
|
||||
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,
|
||||
returnDocument: 'after'
|
||||
},
|
||||
(err, r) => {
|
||||
if (err) {
|
||||
err.responseCode = 500;
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
if (description) {
|
||||
dkimData.description = description;
|
||||
}
|
||||
|
||||
if (!r.value) {
|
||||
let err = new Error('Failed to insert DKIM key');
|
||||
err.responseCode = 500;
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
this.database.collection('dkim').findOneAndReplace(
|
||||
{
|
||||
domain
|
||||
},
|
||||
dkimData,
|
||||
{
|
||||
upsert: true,
|
||||
returnDocument: 'after'
|
||||
},
|
||||
(err, r) => {
|
||||
if (err) {
|
||||
err.responseCode = 500;
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (this.redis) {
|
||||
if (r.lastErrorObject.upserted) {
|
||||
publish(this.redis, {
|
||||
ev: DKIM_CREATED,
|
||||
dkim: r.value._id,
|
||||
domain,
|
||||
selector,
|
||||
fingerprint: fp
|
||||
}).catch(() => false);
|
||||
} else if (r.lastErrorObject.updatedExisting) {
|
||||
publish(this.redis, {
|
||||
ev: DKIM_UPDATED,
|
||||
dkim: r.value._id,
|
||||
domain,
|
||||
selector,
|
||||
fingerprint: fp
|
||||
}).catch(() => false);
|
||||
if (!r.value) {
|
||||
let err = new Error('Failed to insert DKIM key');
|
||||
err.responseCode = 500;
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (this.redis) {
|
||||
if (r.lastErrorObject.upserted) {
|
||||
publish(this.redis, {
|
||||
ev: DKIM_CREATED,
|
||||
dkim: r.value._id,
|
||||
domain,
|
||||
selector,
|
||||
fingerprint: fp
|
||||
}).catch(() => false);
|
||||
} else if (r.lastErrorObject.updatedExisting) {
|
||||
publish(this.redis, {
|
||||
ev: DKIM_UPDATED,
|
||||
dkim: r.value._id,
|
||||
domain,
|
||||
selector,
|
||||
fingerprint: fp
|
||||
}).catch(() => false);
|
||||
}
|
||||
}
|
||||
|
||||
return callback(null, {
|
||||
id: r.value._id.toString(),
|
||||
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, '')
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return callback(null, {
|
||||
id: r.value._id.toString(),
|
||||
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, '')
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
);
|
||||
})
|
||||
.catch(err => callback(err));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -228,43 +220,35 @@ class DkimHandler {
|
|||
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.responseCode = 500;
|
||||
err.code = 'InternalConfigError';
|
||||
return callback(err);
|
||||
}
|
||||
} else {
|
||||
let err = new Error('Can not use decrypted key');
|
||||
err.responseCode = 500;
|
||||
err.code = 'InternalConfigError';
|
||||
return callback(err);
|
||||
}
|
||||
let loadPrivateKey = next => {
|
||||
if (!includePrivateKey) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
callback(null, {
|
||||
id: dkimData._id.toString(),
|
||||
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
|
||||
decrypt(dkimData.privateKey, this.secret, this.cipher)
|
||||
.then(privateKey => next(null, privateKey))
|
||||
.catch(err => next(err));
|
||||
};
|
||||
|
||||
loadPrivateKey((err, privateKey) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
callback(null, {
|
||||
id: dkimData._id.toString(),
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
133
lib/encrypt.js
Normal file
133
lib/encrypt.js
Normal file
|
@ -0,0 +1,133 @@
|
|||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const assert = require('assert');
|
||||
|
||||
function parseEncryptedData(encryptedData, defaultCipher) {
|
||||
encryptedData = (encryptedData || '').toString();
|
||||
if (!encryptedData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (encryptedData.charAt(0) !== '$') {
|
||||
// cleartext
|
||||
return {
|
||||
format: 'cleartext',
|
||||
data: encryptedData
|
||||
};
|
||||
}
|
||||
|
||||
if (encryptedData.lastIndexOf('$') === 0) {
|
||||
// legacy
|
||||
return {
|
||||
format: 'legacy',
|
||||
cipher: defaultCipher || 'aes192',
|
||||
data: Buffer.from(encryptedData.substr(1), 'hex')
|
||||
};
|
||||
}
|
||||
|
||||
let [, format, cipher, authTag, iv, salt, encryptedText] = encryptedData.split('$');
|
||||
if (!format || !cipher || !authTag || !iv || !encryptedText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
authTag = Buffer.from(authTag, 'hex');
|
||||
iv = Buffer.from(iv, 'hex');
|
||||
salt = Buffer.from(salt, 'hex');
|
||||
encryptedText = Buffer.from(encryptedText, 'hex');
|
||||
|
||||
return {
|
||||
format,
|
||||
cipher,
|
||||
authTag,
|
||||
iv,
|
||||
salt,
|
||||
data: encryptedText
|
||||
};
|
||||
}
|
||||
|
||||
function getKeyFromPassword(password, salt, keyLen) {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(password, salt, keyLen, (err, result) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
if (!result) {
|
||||
return reject(new Error('Failed to hash key'));
|
||||
}
|
||||
return resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function decrypt(encryptedData, secret, defaultCipher) {
|
||||
const decryptData = parseEncryptedData(encryptedData, defaultCipher);
|
||||
if (!decryptData) {
|
||||
return encryptedData;
|
||||
}
|
||||
|
||||
switch (decryptData.format) {
|
||||
case 'cleartext':
|
||||
return encryptedData;
|
||||
|
||||
case 'legacy':
|
||||
try {
|
||||
let decipher = crypto.createDecipher(decryptData.cipher, secret);
|
||||
return Buffer.concat([decipher.update(decryptData.data), decipher.final()]).toString('utf-8');
|
||||
} catch (E) {
|
||||
let err = new Error('Failed to decrypt data. ' + E.message);
|
||||
err.responseCode = 500;
|
||||
err.code = 'InternalConfigError';
|
||||
throw err;
|
||||
}
|
||||
|
||||
case 'wd01':
|
||||
try {
|
||||
assert.strictEqual(decryptData.authTag.length, 16, 'Invalid auth tag length');
|
||||
assert.strictEqual(decryptData.iv.length, 12, 'Invalid iv length');
|
||||
assert.strictEqual(decryptData.salt.length, 16, 'Invalid salt length');
|
||||
|
||||
// convert password to 32B key
|
||||
const key = await getKeyFromPassword(secret, decryptData.salt, 32);
|
||||
|
||||
const decipher = crypto.createDecipheriv(decryptData.cipher, key, decryptData.iv, { authTagLength: decryptData.authTag.length });
|
||||
decipher.setAuthTag(decryptData.authTag);
|
||||
return Buffer.concat([decipher.update(decryptData.data), decipher.final()]).toString('utf-8');
|
||||
} catch (E) {
|
||||
let err = new Error('Failed to decrypt data. ' + E.message);
|
||||
err.responseCode = 500;
|
||||
err.code = 'InternalConfigError';
|
||||
throw err;
|
||||
}
|
||||
|
||||
default: {
|
||||
let err = new Error('Unknown encryption format: ' + decryptData.format);
|
||||
err.responseCode = 500;
|
||||
err.code = 'InternalConfigError';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function encrypt(cleartext, secret) {
|
||||
if (!secret) {
|
||||
return cleartext;
|
||||
}
|
||||
|
||||
const iv = crypto.randomBytes(12);
|
||||
const salt = crypto.randomBytes(16);
|
||||
|
||||
const key = await getKeyFromPassword(secret, salt, 32);
|
||||
|
||||
const format = 'wd01';
|
||||
const algo = 'aes-256-gcm';
|
||||
|
||||
const cipher = crypto.createCipheriv(algo, key, iv, { authTagLength: 16 });
|
||||
const encryptedText = Buffer.concat([cipher.update(cleartext), cipher.final()]);
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return ['', format, algo].concat([authTag, iv, salt, encryptedText].map(buf => buf.toString('hex'))).join('$');
|
||||
}
|
||||
|
||||
module.exports = { encrypt, decrypt };
|
|
@ -20,6 +20,7 @@ const UserCache = require('./user-cache');
|
|||
const isemail = require('isemail');
|
||||
const util = require('util');
|
||||
const TaskHandler = require('./task-handler');
|
||||
const { encrypt, decrypt } = require('./encrypt');
|
||||
|
||||
const {
|
||||
publish,
|
||||
|
@ -1789,9 +1790,7 @@ class UserHandler {
|
|||
let seed = secret.base32;
|
||||
if (config.totp && config.totp.secret) {
|
||||
try {
|
||||
let cipher = crypto.createCipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
seed = '$' + cipher.update(seed, 'utf8', 'hex');
|
||||
seed += cipher.final('hex');
|
||||
seed = await encrypt(seed, config.totp.secret);
|
||||
} 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');
|
||||
|
@ -1903,11 +1902,9 @@ class UserHandler {
|
|||
}
|
||||
|
||||
let secret = userData.pendingSeed;
|
||||
if (secret.charAt(0) === '$' && config.totp && config.totp.secret) {
|
||||
if (config.totp && config.totp.secret) {
|
||||
try {
|
||||
let decipher = crypto.createDecipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
secret = decipher.update(secret.substr(1), 'hex', 'utf-8');
|
||||
secret += decipher.final('utf8');
|
||||
secret = await decrypt(secret, config.totp.secret, config.totp.cipher);
|
||||
} catch (E) {
|
||||
log.error('DB', 'TOTPFAIL decipher failed id=%s error=%s', user, E.message);
|
||||
let err = new Error('Can not use decrypted secret');
|
||||
|
@ -2192,9 +2189,7 @@ class UserHandler {
|
|||
let secret = userData.seed;
|
||||
if (userData.seed.charAt(0) === '$' && config.totp && config.totp.secret) {
|
||||
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');
|
||||
secret = await decrypt(userData.seed, config.totp.secret, config.totp.cipher);
|
||||
} catch (E) {
|
||||
log.error('DB', 'TOTPFAIL decipher failed id=%s error=%s', user, E.message);
|
||||
let err = new Error('Can not use decrypted secret');
|
||||
|
|
|
@ -45,8 +45,7 @@ port=24
|
|||
disableSTARTTLS=true" > /etc/wildduck/lmtp.toml
|
||||
|
||||
# make sure that DKIM keys are not stored to database as cleartext
|
||||
#echo "secret=\"$DKIM_SECRET\"
|
||||
#cipher=\"aes192\"" >> /etc/wildduck/dkim.toml
|
||||
echo "secret=\"$DKIM_SECRET\"" >> /etc/wildduck/dkim.toml
|
||||
|
||||
echo "user=\"wildduck\"
|
||||
group=\"wildduck\"
|
||||
|
|
Loading…
Add table
Reference in a new issue