mirror of
https://github.com/nodemailer/wildduck.git
synced 2024-11-10 17:47:07 +08:00
3d7d0a6d6a
* dkim, add support for ed25519 * add support for raw ED25519 private key * magic value make variable. Remove unnecessary variables. Refactor * add new tests for ED25519
297 lines
9.3 KiB
JavaScript
297 lines
9.3 KiB
JavaScript
'use strict';
|
|
|
|
const ObjectId = require('mongodb').ObjectId;
|
|
const fingerprint = require('key-fingerprint').fingerprint;
|
|
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);
|
|
|
|
const ASN1_PADDING = 'MC4CAQAwBQYDK2VwBCIEIA==';
|
|
|
|
class DkimHandler {
|
|
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 generateKey(keyBits, keyExponent) {
|
|
const { privateKey, publicKey } = await generateKeyPair('rsa', {
|
|
modulusLength: keyBits || 2048, // options
|
|
publicExponent: keyExponent || 65537,
|
|
publicKeyEncoding: {
|
|
type: 'spki',
|
|
format: 'pem'
|
|
},
|
|
privateKeyEncoding: {
|
|
type: 'pkcs8',
|
|
format: 'pem'
|
|
}
|
|
});
|
|
|
|
return { privateKey, publicKey };
|
|
}
|
|
|
|
async set(options) {
|
|
const domain = tools.normalizeDomain(options.domain);
|
|
const selector = options.selector;
|
|
const description = options.description;
|
|
|
|
let privateKeyPem = options.privateKey;
|
|
let publicKeyPem;
|
|
let publicKeyDer;
|
|
|
|
if (!privateKeyPem) {
|
|
let keyPair = await this.generateKey();
|
|
if (!keyPair || !keyPair.privateKey || !keyPair.publicKey) {
|
|
let err = new Error('Failed to generate key pair');
|
|
err.responseCode = 500;
|
|
err.code = 'KeyGenereateError';
|
|
throw err;
|
|
}
|
|
privateKeyPem = keyPair.privateKey;
|
|
publicKeyPem = keyPair.publicKey;
|
|
}
|
|
|
|
if (!publicKeyPem) {
|
|
// extract public key from private key
|
|
|
|
// 1) check that privateKeyPem is ED25519 raw key, which length is 44
|
|
if (privateKeyPem.length === 44) {
|
|
// privateKeyPem is actually a raw ED25519 base64 string with length of 44
|
|
// convert raw ED25519 key to PEM formatted private key
|
|
privateKeyPem = `-----BEGIN PRIVATE KEY-----
|
|
${Buffer.concat([Buffer.from(ASN1_PADDING, 'base64'), Buffer.from(privateKeyPem, 'base64')]).toString('base64')}
|
|
-----END PRIVATE KEY-----`;
|
|
}
|
|
|
|
const publicKey = crypto.createPublicKey({ key: privateKeyPem, format: 'pem' });
|
|
|
|
publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' });
|
|
|
|
if (publicKey.asymmetricKeyType === 'ed25519') {
|
|
publicKeyDer = publicKey.export({ format: 'der', type: 'spki' }).subarray(12).toString('base64');
|
|
} else if (publicKey.asymmetricKeyType === 'rsa') {
|
|
publicKeyDer = publicKey.export({ format: 'der', type: 'spki' }).toString('base64');
|
|
}
|
|
|
|
if (!publicKeyPem && !publicKeyDer) {
|
|
let err = new Error('Failed to generate public key');
|
|
err.responseCode = 500;
|
|
err.code = 'KeyGenereateError';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
let fp;
|
|
try {
|
|
fp = fingerprint(privateKeyPem, 'sha256', true);
|
|
|
|
const testData = Buffer.from('secretvalue');
|
|
const signature = crypto.sign(null, testData, privateKeyPem);
|
|
const verificationResult = crypto.verify(null, testData, publicKeyPem, signature);
|
|
|
|
if (!verificationResult) {
|
|
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.responseCode = 400;
|
|
err.code = 'InputValidationError';
|
|
throw err;
|
|
}
|
|
|
|
// encrypt if needed
|
|
privateKeyPem = await encrypt(privateKeyPem, this.secret);
|
|
|
|
let dkimData = {
|
|
domain,
|
|
selector,
|
|
privateKey: privateKeyPem,
|
|
publicKey: publicKeyPem,
|
|
publicKeyDer,
|
|
fingerprint: fp,
|
|
created: new Date(),
|
|
latest: true
|
|
};
|
|
|
|
if (description) {
|
|
dkimData.description = description;
|
|
}
|
|
|
|
let r;
|
|
|
|
try {
|
|
r = await this.database.collection('dkim').findOneAndReplace(
|
|
{
|
|
domain
|
|
},
|
|
dkimData,
|
|
{
|
|
upsert: true,
|
|
returnDocument: 'after'
|
|
}
|
|
);
|
|
} catch (err) {
|
|
err.responseCode = 500;
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!r.value) {
|
|
let err = new Error('Failed to insert DKIM key');
|
|
err.responseCode = 500;
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (this.redis) {
|
|
if (r.lastErrorObject.upserted) {
|
|
try {
|
|
await publish(this.redis, {
|
|
ev: DKIM_CREATED,
|
|
dkim: r.value._id,
|
|
domain,
|
|
selector,
|
|
fingerprint: fp
|
|
});
|
|
} catch (err) {
|
|
// ignore?
|
|
}
|
|
} else if (r.lastErrorObject.updatedExisting) {
|
|
try {
|
|
await publish(this.redis, {
|
|
ev: DKIM_UPDATED,
|
|
dkim: r.value._id,
|
|
domain,
|
|
selector,
|
|
fingerprint: fp
|
|
});
|
|
} catch (err) {
|
|
// ignore?
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
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.publicKeyDer
|
|
}
|
|
};
|
|
}
|
|
|
|
async get(options, includePrivateKey) {
|
|
let query = {};
|
|
options = options || {};
|
|
|
|
if (options.domain) {
|
|
query.domain = tools.normalizeDomain(options.domain);
|
|
} else if (options._id && tools.isId(options._id)) {
|
|
query._id = new ObjectId(options._id);
|
|
} else {
|
|
let err = new Error('Invalid or unknown DKIM key');
|
|
err.responseCode = 404;
|
|
err.code = 'DkimNotFound';
|
|
throw err;
|
|
}
|
|
|
|
let dkimData;
|
|
try {
|
|
dkimData = await this.database.collection('dkim').findOne(query);
|
|
} catch (err) {
|
|
err.responseCode = 500;
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
if (!dkimData) {
|
|
let err = new Error('Invalid or unknown DKIM key');
|
|
err.responseCode = 404;
|
|
err.code = 'DkimNotFound';
|
|
throw err;
|
|
}
|
|
|
|
let privateKey;
|
|
if (includePrivateKey) {
|
|
privateKey = await decrypt(dkimData.privateKey, this.secret, this.cipher);
|
|
}
|
|
|
|
return {
|
|
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
|
|
};
|
|
}
|
|
|
|
async del(options) {
|
|
let query = {};
|
|
|
|
if (options.domain) {
|
|
query.domain = tools.normalizeDomain(options.domain);
|
|
} else if (options._id && tools.isId(options._id)) {
|
|
query._id = new ObjectId(options._id);
|
|
} else {
|
|
let err = new Error('Invalid or unknown DKIM key');
|
|
err.responseCode = 404;
|
|
err.code = 'DkimNotFound';
|
|
throw err;
|
|
}
|
|
|
|
// delete dkim key from database
|
|
let r;
|
|
try {
|
|
r = await this.database.collection('dkim').findOneAndDelete(query);
|
|
} catch (err) {
|
|
err.responseCode = 500;
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!r.value) {
|
|
let err = new Error('Invalid or unknown DKIM key');
|
|
err.responseCode = 404;
|
|
err.code = 'DkimNotFound';
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
await publish(this.redis, {
|
|
ev: DKIM_DELETED,
|
|
dkim: r.value._id,
|
|
domain: r.value.domain,
|
|
selector: r.value.selector,
|
|
fingerprint: r.value.fingerprint
|
|
});
|
|
} catch (err) {
|
|
// ignore?
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
module.exports = DkimHandler;
|