Added support for PGP encrypting cleartext messages

This commit is contained in:
Andris Reinman 2017-08-03 15:02:02 +03:00
parent 2b7d5a5fe9
commit 0c96919679
5 changed files with 437 additions and 236 deletions

View file

@ -5,6 +5,7 @@ const Joi = require('joi');
const MongoPaging = require('mongo-cursor-pagination');
const ObjectID = require('mongodb').ObjectID;
const tools = require('../tools');
const openpgp = require('openpgp');
module.exports = (db, server, userHandler) => {
server.get({ name: 'users', path: '/users' }, (req, res, next) => {
@ -138,6 +139,9 @@ module.exports = (db, server, userHandler) => {
recipients: Joi.number().min(0).default(0),
forwards: Joi.number().min(0).default(0),
pubKey: Joi.string().empty('').trim().regex(/^-----BEGIN PGP PUBLIC KEY BLOCK-----/, 'PGP key format'),
encryptMessages: Joi.boolean().truthy(['Y', 'true', 'yes', 1]).default(false),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
@ -167,21 +171,33 @@ module.exports = (db, server, userHandler) => {
result.value.forward = forward;
}
userHandler.create(result.value, (err, id) => {
if ('pubKey' in req.params && !result.value.pubKey) {
result.value.pubKey = '';
}
checkPubKey(result.value.pubKey, err => {
if (err) {
res.json({
error: err.message,
username: result.value.username
error: 'PGP key validation failed. ' + err.message
});
return next();
}
userHandler.create(result.value, (err, id) => {
if (err) {
res.json({
error: err.message,
username: result.value.username
});
return next();
}
res.json({
success: !!id,
id
res.json({
success: !!id,
id
});
return next();
});
return next();
});
});
@ -256,6 +272,9 @@ module.exports = (db, server, userHandler) => {
enabled2fa: userData.enabled2fa,
encryptMessages: userData.encryptMessages,
pubKey: userData.pubKey,
forward: userData.forward,
targetUrl: userData.targetUrl,
@ -300,6 +319,9 @@ module.exports = (db, server, userHandler) => {
forward: Joi.string().empty('').email(),
targetUrl: Joi.string().empty('').max(256),
pubKey: Joi.string().empty('').trim().regex(/^-----BEGIN PGP PUBLIC KEY BLOCK-----/, 'PGP key format'),
encryptMessages: Joi.boolean().empty('').truthy(['Y', 'true', 'yes', 1]),
retention: Joi.number().min(0),
quota: Joi.number().min(0),
recipients: Joi.number().min(0),
@ -345,17 +367,30 @@ module.exports = (db, server, userHandler) => {
result.value.name = '';
}
userHandler.update(user, result.value, (err, success) => {
if (!result.value.pubKey && 'pubKey' in req.params) {
result.value.pubKey = '';
}
checkPubKey(result.value.pubKey, err => {
if (err) {
res.json({
error: err.message
error: 'PGP key validation failed. ' + err.message
});
return next();
}
res.json({
success
userHandler.update(user, result.value, (err, success) => {
if (err) {
res.json({
error: err.message
});
return next();
}
res.json({
success
});
return next();
});
return next();
});
});
@ -472,3 +507,32 @@ module.exports = (db, server, userHandler) => {
});
});
};
function checkPubKey(pubKey, done) {
if (!pubKey) {
return done();
}
// try to encrypt something with that key
let armored;
try {
armored = openpgp.key.readArmored(pubKey).keys;
} catch (E) {
return done(E);
}
openpgp
.encrypt({
data: 'Hello, World!',
publicKeys: armored
})
.then(ciphertext => {
if (/^-----BEGIN PGP MESSAGE/.test(ciphertext.data)) {
// everything checks out
return done();
}
return done(new Error('Unexpected message'));
})
.catch(err => done(err));
}

View file

@ -9,11 +9,14 @@ const libmime = require('libmime');
const counters = require('./counters');
const consts = require('./consts');
const tools = require('./tools');
const openpgp = require('openpgp');
const parseDate = require('../imap-core/lib/parse-date');
// index only the following headers for SEARCH
const INDEXED_HEADERS = ['to', 'cc', 'subject', 'from', 'sender', 'reply-to', 'message-id', 'thread-index', 'x-rspamd-spam', 'x-spam-status'];
openpgp.config.commentstring = 'Plaintext message encrypted by Wild Duck Mail Server';
class MessageHandler {
constructor(options) {
this.database = options.database;
@ -1159,6 +1162,126 @@ class MessageHandler {
processNext();
});
}
encryptMessage(pubKey, raw, callback) {
if (!pubKey) {
return callback(null, false);
}
let lastBytes = [];
let headerEnd = raw.length;
let headerLength = 0;
// split the message into header and body
for (let i = 0, len = raw.length; i < len; i++) {
lastBytes.unshift(raw[i]);
if (lastBytes.length > 10) {
lastBytes.length = 4;
}
if (lastBytes.length < 2) {
continue;
}
let pos = 0;
if (lastBytes[pos] !== 0x0a) {
continue;
}
pos++;
if (lastBytes[pos] === 0x0d) {
pos++;
}
if (lastBytes[pos] !== 0x0a) {
continue;
}
pos++;
if (lastBytes[pos] === 0x0d) {
pos++;
}
// we have a match!'
headerEnd = i + 1 - pos;
headerLength = pos;
break;
}
let header = raw.slice(0, headerEnd);
let breaker = headerLength ? raw.slice(headerEnd, headerEnd + headerLength) : new Buffer(0);
let body = headerEnd + headerLength < raw.length ? raw.slice(headerEnd + headerLength) : new Buffer(0);
// modify headers
let headers = [];
let bodyHeaders = [];
let lastHeader = false;
let boundary = 'nm_' + crypto.randomBytes(14).toString('hex');
let headerLines = header.toString('binary').split('\r\n');
// use for, so we could escape from it if needed
for (let i = 0, len = headerLines.length; i < len; i++) {
let line = headerLines[i];
if (!i || !lastHeader || !/^\s/.test(line)) {
lastHeader = [line];
if (/^content-type:/i.test(line)) {
let parts = line.split(':');
let value = parts.slice(1).join(':');
if (value.split(';').shift().trim().toLowerCase() === 'multipart/encrypted') {
// message is already encrypted, do nothing
return callback(null, false);
}
bodyHeaders.push(lastHeader);
} else if (/^content-transfer-encoding:/i.test(line)) {
bodyHeaders.push(lastHeader);
} else {
headers.push(lastHeader);
}
} else {
lastHeader.push(line);
}
}
headers.push(['Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";'], [' boundary="' + boundary + '"']);
headers.push(['Content-Description: OpenPGP encrypted message']);
headers.push(['Content-Transfer-Encoding: 7bit']);
headers = Buffer.from(headers.map(line => line.join('\r\n')).join('\r\n'), 'binary');
bodyHeaders = Buffer.from(bodyHeaders.map(line => line.join('\r\n')).join('\r\n'), 'binary');
openpgp
.encrypt({
data: Buffer.concat([Buffer.from(bodyHeaders + '\r\n\r\n'), body]),
publicKeys: openpgp.key.readArmored(pubKey).keys
})
.then(ciphertext => {
let text =
'This is an OpenPGP/MIME encrypted message\r\n\r\n' +
'--' +
boundary +
'\r\n' +
'Content-Type: application/pgp-encrypted\r\n' +
'Content-Transfer-Encoding: 7bit\r\n' +
'\r\n' +
'Version: 1\r\n' +
'\r\n' +
'--' +
boundary +
'\r\n' +
'Content-Type: application/octet-stream; name=encrypted.asc\r\n' +
'Content-Disposition: inline; filename=encrypted.asc\r\n' +
'Content-Transfer-Encoding: 7bit\r\n' +
'\r\n' +
ciphertext.data +
'\r\n--' +
boundary +
'--\r\n';
callback(null, Buffer.concat([headers, breaker, Buffer.from(text)]));
})
.catch(err => {
if (err) {
// ignore
}
// encryption failed, keep message as is
callback(null, false);
});
}
}
module.exports = MessageHandler;

View file

@ -373,8 +373,12 @@ class UserHandler {
forwards: data.forwards || 0,
// autoreply status
// off by default, can be changed later by user through the API
autoreply: false,
pubKey: data.pubKey || '',
encryptMessages: !!data.encryptMessages,
// default retention for user mailboxes
retention: data.retention || 0,

449
lmtp.js
View file

@ -73,7 +73,9 @@ const serverOptions = {
forwards: true,
forward: true,
targetUrl: true,
autoreply: true
autoreply: true,
encryptMessages: true,
pubKey: true
}
}, (err, user) => {
if (err) {
@ -148,252 +150,259 @@ const serverOptions = {
chunklen += header.length;
let raw = Buffer.concat(chunks, chunklen);
let prepared = messageHandler.prepareMessage({
raw
});
let maildata = messageHandler.indexer.getMaildata(prepared.id, prepared.mimeTree);
// default flags are empty
let flags = [];
// default mailbox target is INBOX
let mailboxQueryKey = 'path';
let mailboxQueryValue = 'INBOX';
db.database.collection('filters').find({ user: user._id }).sort({ _id: 1 }).toArray((err, filters) => {
if (err) {
// ignore, as filtering is not so important
messageHandler.encryptMessage(user.encryptMessages ? user.pubKey : false, raw, (err, encrypted) => {
if (!err && encrypted) {
raw = encrypted;
}
// append generic spam header check to the filters
filters = (filters || []).concat(
spamHeader
? {
id: 'SPAM',
query: {
headers: {
[spamHeader]: 'Yes'
}
},
action: {
// only applies if any other filter does not already mark message as spam or ham
spam: true
}
}
: []
);
let forwardTargets = new Set();
let forwardTargetUrls = new Set();
let matchingFilters = [];
let filterActions = new Map();
let prepared = messageHandler.prepareMessage({
raw
});
let maildata = messageHandler.indexer.getMaildata(prepared.id, prepared.mimeTree);
filters
// apply all filters to the message
.map(filter => checkFilter(filter, prepared, maildata))
// remove all unmatched filters
.filter(filter => filter)
// apply filter actions
.forEach(filter => {
matchingFilters.push(filter.id);
// default flags are empty
let flags = [];
// apply matching filter
if (!filterActions) {
filterActions = filter.action;
} else {
Object.keys(filter.action).forEach(key => {
if (key === 'forward') {
forwardTargets.add(filter.action[key]);
return;
}
// default mailbox target is INBOX
let mailboxQueryKey = 'path';
let mailboxQueryValue = 'INBOX';
if (key === 'targetUrl') {
forwardTargetUrls.add(filter.action[key]);
return;
}
// if a previous filter already has set a value then do not touch it
if (!filterActions.has(key)) {
filterActions.set(key, filter.action[key]);
}
});
}
});
let forwardMessage = done => {
if (user.forward && !filterActions.get('delete')) {
// forward to default recipient only if the message is not deleted
forwardTargets.add(user.forward);
}
if (user.targetUrl && !filterActions.get('delete')) {
// forward to default URL only if the message is not deleted
forwardTargetUrls.add(user.targetUrl);
}
// never forward messages marked as spam
if ((!forwardTargets.size && !forwardTargetUrls.size) || filterActions.get('spam')) {
return setImmediate(done);
}
// check limiting counters
messageHandler.counters.ttlcounter(
'wdf:' + user._id.toString(),
forwardTargets.size + forwardTargetUrls.size,
user.forwards,
(err, result) => {
if (err) {
// failed checks
log.error('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + user._id.toString(), err.message);
} else if (!result.success) {
log.silly('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + user._id.toString(), 'Precondition failed');
return done();
}
forward(
{
user,
sender,
recipient,
forward: forwardTargets.size ? Array.from(forwardTargets) : false,
targetUrl: forwardTargetUrls.size ? Array.from(forwardTargetUrls) : false,
chunks
},
done
);
}
);
};
let sendAutoreply = done => {
// never reply to messages marked as spam
if (!sender || !user.autoreply || filterActions.get('spam')) {
return setImmediate(done);
}
autoreply(
{
user,
sender,
recipient,
chunks,
messageHandler
},
done
);
};
forwardMessage((err, id) => {
db.database.collection('filters').find({ user: user._id }).sort({ _id: 1 }).toArray((err, filters) => {
if (err) {
log.error(
'LMTP',
'%s FRWRDFAIL from=%s to=%s target=%s error=%s',
prepared.id.toString(),
sender,
recipient,
Array.from(forwardTargets).concat(forwardTargetUrls).join(','),
err.message
);
} else if (id) {
log.silly(
'LMTP',
'%s FRWRDOK id=%s from=%s to=%s target=%s',
prepared.id.toString(),
id,
sender,
recipient,
Array.from(forwardTargets).concat(forwardTargetUrls).join(',')
);
// ignore, as filtering is not so important
}
// append generic spam header check to the filters
filters = (filters || []).concat(
spamHeader
? {
id: 'SPAM',
query: {
headers: {
[spamHeader]: 'Yes'
}
},
action: {
// only applies if any other filter does not already mark message as spam or ham
spam: true
}
}
: []
);
sendAutoreply((err, id) => {
if (err) {
log.error('LMTP', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
} else if (id) {
log.silly('LMTP', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), id, '<>', sender);
}
let forwardTargets = new Set();
let forwardTargetUrls = new Set();
let matchingFilters = [];
let filterActions = new Map();
if (filterActions.get('delete')) {
// nothing to do with the message, just continue
responses.push({
user,
response: 'Message dropped by policy as ' + prepared.id.toString()
});
prepared = false;
maildata = false;
return storeNext();
}
filters
// apply all filters to the message
.map(filter => checkFilter(filter, prepared, maildata))
// remove all unmatched filters
.filter(filter => filter)
// apply filter actions
.forEach(filter => {
matchingFilters.push(filter.id);
// apply filter results to the message
filterActions.forEach((value, key) => {
switch (key) {
case 'spam':
if (value > 0) {
// positive value is spam
mailboxQueryKey = 'specialUse';
mailboxQueryValue = '\\Junk';
// apply matching filter
if (!filterActions) {
filterActions = filter.action;
} else {
Object.keys(filter.action).forEach(key => {
if (key === 'forward') {
forwardTargets.add(filter.action[key]);
return;
}
break;
case 'seen':
if (value) {
flags.push('\\Seen');
if (key === 'targetUrl') {
forwardTargetUrls.add(filter.action[key]);
return;
}
break;
case 'flag':
if (value) {
flags.push('\\Flagged');
// if a previous filter already has set a value then do not touch it
if (!filterActions.has(key)) {
filterActions.set(key, filter.action[key]);
}
break;
case 'mailbox':
if (value) {
// positive value is spam
mailboxQueryKey = 'mailbox';
mailboxQueryValue = value;
}
break;
});
}
});
let messageOptions = {
user: (user && user._id) || user,
[mailboxQueryKey]: mailboxQueryValue,
let forwardMessage = done => {
if (user.forward && !filterActions.get('delete')) {
// forward to default recipient only if the message is not deleted
forwardTargets.add(user.forward);
}
prepared,
maildata,
if (user.targetUrl && !filterActions.get('delete')) {
// forward to default URL only if the message is not deleted
forwardTargetUrls.add(user.targetUrl);
}
meta: {
source: 'LMTP',
from: sender,
to: recipient,
origin: session.remoteAddress,
originhost: session.clientHostname,
transhost: session.hostNameAppearsAs,
transtype: session.transmissionType,
time: Date.now()
},
// never forward messages marked as spam
if ((!forwardTargets.size && !forwardTargetUrls.size) || filterActions.get('spam')) {
return setImmediate(done);
}
filters: matchingFilters,
// check limiting counters
messageHandler.counters.ttlcounter(
'wdf:' + user._id.toString(),
forwardTargets.size + forwardTargetUrls.size,
user.forwards,
(err, result) => {
if (err) {
// failed checks
log.error('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + user._id.toString(), err.message);
} else if (!result.success) {
log.silly('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + user._id.toString(), 'Precondition failed');
return done();
}
date: false,
flags,
forward(
{
user,
sender,
recipient,
// if similar message exists, then skip
skipExisting: true
};
forward: forwardTargets.size ? Array.from(forwardTargets) : false,
targetUrl: forwardTargetUrls.size ? Array.from(forwardTargetUrls) : false,
messageHandler.add(messageOptions, (err, inserted, info) => {
// remove Delivered-To
chunks.shift();
chunklen -= header.length;
chunks
},
done
);
}
);
};
// push to response list
responses.push({
let sendAutoreply = done => {
// never reply to messages marked as spam
if (!sender || !user.autoreply || filterActions.get('spam')) {
return setImmediate(done);
}
autoreply(
{
user,
response: err ? err : 'Message stored as ' + info.id.toString()
sender,
recipient,
chunks,
messageHandler
},
done
);
};
forwardMessage((err, id) => {
if (err) {
log.error(
'LMTP',
'%s FRWRDFAIL from=%s to=%s target=%s error=%s',
prepared.id.toString(),
sender,
recipient,
Array.from(forwardTargets).concat(forwardTargetUrls).join(','),
err.message
);
} else if (id) {
log.silly(
'LMTP',
'%s FRWRDOK id=%s from=%s to=%s target=%s',
prepared.id.toString(),
id,
sender,
recipient,
Array.from(forwardTargets).concat(forwardTargetUrls).join(',')
);
}
sendAutoreply((err, id) => {
if (err) {
log.error('LMTP', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
} else if (id) {
log.silly('LMTP', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), id, '<>', sender);
}
if (filterActions.get('delete')) {
// nothing to do with the message, just continue
responses.push({
user,
response: 'Message dropped by policy as ' + prepared.id.toString()
});
prepared = false;
maildata = false;
return storeNext();
}
// apply filter results to the message
filterActions.forEach((value, key) => {
switch (key) {
case 'spam':
if (value > 0) {
// positive value is spam
mailboxQueryKey = 'specialUse';
mailboxQueryValue = '\\Junk';
}
break;
case 'seen':
if (value) {
flags.push('\\Seen');
}
break;
case 'flag':
if (value) {
flags.push('\\Flagged');
}
break;
case 'mailbox':
if (value) {
// positive value is spam
mailboxQueryKey = 'mailbox';
mailboxQueryValue = value;
}
break;
}
});
storeNext();
let messageOptions = {
user: (user && user._id) || user,
[mailboxQueryKey]: mailboxQueryValue,
prepared,
maildata,
meta: {
source: 'LMTP',
from: sender,
to: recipient,
origin: session.remoteAddress,
originhost: session.clientHostname,
transhost: session.hostNameAppearsAs,
transtype: session.transmissionType,
time: Date.now()
},
filters: matchingFilters,
date: false,
flags,
// if similar message exists, then skip
skipExisting: true
};
messageHandler.add(messageOptions, (err, inserted, info) => {
// remove Delivered-To
chunks.shift();
chunklen -= header.length;
// push to response list
responses.push({
user,
response: err ? err : 'Message stored as ' + info.id.toString()
});
storeNext();
});
});
});
});

View file

@ -19,8 +19,8 @@
"grunt-mocha-test": "^0.13.2",
"grunt-shell-spawn": "^0.3.10",
"grunt-wait": "^0.1.0",
"icedfrisby": "^1.2.0",
"mocha": "^3.4.2"
"icedfrisby": "^1.3.0",
"mocha": "^3.5.0"
},
"dependencies": {
"addressparser": "^1.0.1",
@ -30,7 +30,7 @@
"html-to-text": "^3.3.0",
"iconv-lite": "^0.4.18",
"joi": "^10.6.0",
"js-yaml": "^3.9.0",
"js-yaml": "^3.9.1",
"libbase64": "^0.2.0",
"libmime": "^3.1.0",
"libqp": "^1.1.0",
@ -41,6 +41,7 @@
"node-redis-scripty": "0.0.5",
"nodemailer": "^4.0.1",
"npmlog": "^4.1.2",
"openpgp": "^2.5.8",
"qrcode": "^0.9.0",
"redfour": "^1.0.2",
"redis": "^2.7.1",