refactored encrypted fields

This commit is contained in:
Andris Reinman 2021-07-05 15:24:01 +03:00
parent 78b774865c
commit 6d3badf1db
7 changed files with 247 additions and 184 deletions

View file

@ -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"

View file

@ -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

View file

@ -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']) {

View file

@ -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
View 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 };

View file

@ -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');

View file

@ -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\"