From 0c969196797847084c4365616d991d3daabb1561 Mon Sep 17 00:00:00 2001 From: Andris Reinman Date: Thu, 3 Aug 2017 15:02:02 +0300 Subject: [PATCH] Added support for PGP encrypting cleartext messages --- lib/api/users.js | 90 +++++++-- lib/message-handler.js | 123 +++++++++++ lib/user-handler.js | 4 + lmtp.js | 449 +++++++++++++++++++++-------------------- package.json | 7 +- 5 files changed, 437 insertions(+), 236 deletions(-) diff --git a/lib/api/users.js b/lib/api/users.js index 22f46d59..7da92c31 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -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)); +} diff --git a/lib/message-handler.js b/lib/message-handler.js index 27df7e83..7a27d225 100644 --- a/lib/message-handler.js +++ b/lib/message-handler.js @@ -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; diff --git a/lib/user-handler.js b/lib/user-handler.js index aecbe63a..18dcb295 100644 --- a/lib/user-handler.js +++ b/lib/user-handler.js @@ -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, diff --git a/lmtp.js b/lmtp.js index e9239488..9a9008b5 100644 --- a/lmtp.js +++ b/lmtp.js @@ -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(); + }); }); }); }); diff --git a/package.json b/package.json index bfc45ffe..d678a9db 100644 --- a/package.json +++ b/package.json @@ -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",