diff --git a/.travis.yml b/.travis.yml index 2953dd1..ebe8eba 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,6 @@ language: node_js node_js: - - "0.11" - "0.10" - - "0.8" before_install: - gem install sass - npm install -g grunt-cli diff --git a/src/js/app-config.js b/src/js/app-config.js index bb72e3f..e2ec9b7 100644 --- a/src/js/app-config.js +++ b/src/js/app-config.js @@ -44,5 +44,14 @@ define([], function() { webSite: 'http://whiteout.io' }; + /** + * Contants are maintained here. + */ + app.constants = { + verificationSubject: 'New public key uploaded', + verificationUrlPrefix: 'https://keys.whiteout.io/verify/', + verificationUuidLength: 36 + }; + return app; }); \ No newline at end of file diff --git a/src/js/dao/email-dao.js b/src/js/dao/email-dao.js index 8f0492b..87c807a 100644 --- a/src/js/dao/email-dao.js +++ b/src/js/dao/email-dao.js @@ -3,7 +3,8 @@ define(function(require) { var _ = require('underscore'), util = require('cryptoLib/util'), - str = require('js/app-config').string; + str = require('js/app-config').string, + consts = require('js/app-config').constants; /** * A high-level Data-Access Api for handling Email synchronization @@ -208,7 +209,7 @@ define(function(require) { */ EmailDAO.prototype.listMessages = function(options, callback) { var self = this, - encryptedList = []; + cleartextList = []; // validate options if (!options.folder) { @@ -227,63 +228,124 @@ define(function(require) { return; } - // find encrypted items - emails.forEach(function(i) { - if (typeof i.body === 'string' && i.body.indexOf(str.cryptPrefix) !== -1 && i.body.indexOf(str.cryptSuffix) !== -1) { - // parse ct object from ascii armored message block - encryptedList.push(parseMessageBlock(i)); - } - }); - - if (encryptedList.length === 0) { - callback(null, []); + if (emails.length === 0) { + callback(null, cleartextList); return; } - // decrypt items - decryptList(encryptedList, callback); + var after = _.after(emails.length, function() { + callback(null, cleartextList); + }); + + _.each(emails, function(email) { + handleMail(email, after); + }); }); + function handleMail(email, localCallback) { + // remove subject filter prefix + email.subject = email.subject.split(str.subjectPrefix)[1]; + + // encrypted mail + if (isPGPMail(email)) { + email = parseMessageBlock(email); + decrypt(email, localCallback); + return; + } + + // verification mail + if (isVerificationMail(email)) { + verify(email, localCallback); + return; + } + + // cleartext mail + cleartextList.push(email); + localCallback(); + } + + function isPGPMail(email) { + return typeof email.body === 'string' && email.body.indexOf(str.cryptPrefix) !== -1 && email.body.indexOf(str.cryptSuffix) !== -1; + } + + function isVerificationMail(email) { + return email.subject === consts.verificationSubject; + } + function parseMessageBlock(email) { var messageBlock; // parse email body for encrypted message block - try { - // get ascii armored message block by prefix and suffix - messageBlock = email.body.split(str.cryptPrefix)[1].split(str.cryptSuffix)[0]; - // add prefix and suffix again - email.body = str.cryptPrefix + messageBlock + str.cryptSuffix; - email.subject = email.subject.split(str.subjectPrefix)[1]; - } catch (e) { - callback({ - errMsg: 'Error parsing encrypted message block!' - }); - return; - } + // get ascii armored message block by prefix and suffix + messageBlock = email.body.split(str.cryptPrefix)[1].split(str.cryptSuffix)[0]; + // add prefix and suffix again + email.body = str.cryptPrefix + messageBlock + str.cryptSuffix; return email; } - function decryptList(list, callback) { - var after = _.after(list.length, function() { - callback(null, list); + function decrypt(email, localCallback) { + // fetch public key required to verify signatures + var sender = email.from[0].address; + self._keychain.getReceiverPublicKey(sender, function(err, senderPubkey) { + if (err) { + callback(err); + return; + } + + // decrypt and verfiy signatures + self._crypto.decrypt(email.body, senderPubkey.publicKey, function(err, decrypted) { + if (err) { + callback(err); + return; + } + + email.body = decrypted; + cleartextList.push(email); + localCallback(); + }); }); + } - list.forEach(function(i) { - // gather public keys required to verify signatures - var sender = i.from[0].address; - self._keychain.getReceiverPublicKey(sender, function(err, senderPubkey) { + function verify(email, localCallback) { + var uuid, index; - // decrypt and verfiy signatures - self._crypto.decrypt(i.body, senderPubkey.publicKey, function(err, decrypted) { - if (err) { - callback(err); - return; - } + if (!email.unread) { + // don't bother if the email was already marked as read + localCallback(); + return; + } - i.body = decrypted; + index = email.body.indexOf(consts.verificationUrlPrefix); + if (index === -1) { + localCallback(); + return; + } - after(); + uuid = email.body.substr(index + consts.verificationUrlPrefix.length, consts.verificationUuidLength); + self._keychain.verifyPublicKey(uuid, function(err) { + if (err) { + console.error('Unable to verify public key: ' + err.errMsg); + localCallback(); + return; + } + + // public key has been verified, mark the message as read, delete it, and ignore it in the future + self.imapMarkMessageRead({ + folder: options.folder, + uid: email.uid + }, function(err) { + if (err) { + // if marking the mail as read failed, don't bother + localCallback(); + return; + } + + self.imapDeleteMessage({ + folder: options.folder, + uid: email.uid + }, function() { + localCallback(); }); }); }); @@ -363,11 +425,6 @@ define(function(require) { return; } - if (typeof message.body !== 'string' || message.body.indexOf(str.cryptPrefix) === -1 || message.body.indexOf(str.cryptSuffix) === -1) { - after(); - return; - } - // set gotten attributes like body to message object containing list meta data like 'unread' or 'replied' header.id = message.id; header.body = message.body; diff --git a/src/js/dao/keychain-dao.js b/src/js/dao/keychain-dao.js index ce2e681..c98a760 100644 --- a/src/js/dao/keychain-dao.js +++ b/src/js/dao/keychain-dao.js @@ -12,6 +12,15 @@ define(function(require) { this._publicKeyDao = publicKeyDao; }; + /** + * Verifies the public key of a user o nthe public key store + * @param {String} uuid The uuid to verify the key + * @param {Function} callback(error) Callback with an optional error object when the verification is done. If the was an error, the error object contains the information for it. + */ + KeychainDAO.prototype.verifyPublicKey = function(uuid, callback) { + this._publicKeyDao.verify(uuid, callback); + }; + /** * Get an array of public keys by looking in local storage and * fetching missing keys from the cloud service. diff --git a/src/js/dao/publickey-dao.js b/src/js/dao/publickey-dao.js index aae6520..ace7709 100644 --- a/src/js/dao/publickey-dao.js +++ b/src/js/dao/publickey-dao.js @@ -5,13 +5,34 @@ define(function() { this._restDao = restDao; }; + /** + * Verify the public key behind the given uuid + */ + PublicKeyDAO.prototype.verify = function(uuid, callback) { + var uri = '/verify/' + uuid; + + this._restDao.get({ + uri: uri, + type: 'text' + }, function(err) { + if (err) { + callback(err); + return; + } + + callback(); + }); + }; + /** * Find the user's corresponding public key */ PublicKeyDAO.prototype.get = function(keyId, callback) { var uri = '/publickey/key/' + keyId; - this._restDao.get(uri, function(err, key) { + this._restDao.get({ + uri: uri + }, function(err, key) { if (err) { callback(err); return; @@ -34,7 +55,9 @@ define(function() { PublicKeyDAO.prototype.getByUserId = function(userId, callback) { var uri = '/publickey/user/' + userId; - this._restDao.get(uri, function(err, keys) { + this._restDao.get({ + uri: uri + }, function(err, keys) { // not found if (err && err.code === 404) { callback(); diff --git a/src/js/dao/rest-dao.js b/src/js/dao/rest-dao.js index f9d3779..12df9b4 100644 --- a/src/js/dao/rest-dao.js +++ b/src/js/dao/rest-dao.js @@ -14,14 +14,42 @@ define(function(require) { /** * GET (read) request + * @param {String} options.uri URI relative to the base uri to perform the GET request with. + * @param {String} options.type (optional) The type of data that you're expecting back from the server: json, xml, text. Default: json. */ - RestDAO.prototype.get = function(uri, callback) { + RestDAO.prototype.get = function(options, callback) { + var acceptHeader; + + if (typeof options.uri === 'undefined') { + callback({ + code: 400, + errMsg: 'Bad Request! URI is a mandatory parameter.' + }); + return; + } + + options.type = options.type || 'json'; + + if (options.type === 'json') { + acceptHeader = 'application/json'; + } else if (options.type === 'xml') { + acceptHeader = 'application/xml'; + } else if (options.type === 'text') { + acceptHeader = 'text/plain'; + } else { + callback({ + code: 400, + errMsg: 'Bad Request! Unhandled data type.' + }); + return; + } + $.ajax({ - url: this._baseUri + uri, + url: this._baseUri + options.uri, type: 'GET', - dataType: 'json', + dataType: options.type, headers: { - 'Accept': 'application/json', + 'Accept': acceptHeader }, success: function(res) { callback(null, res); diff --git a/test/new-unit/email-dao-test.js b/test/new-unit/email-dao-test.js index 02ca4b0..8dc3566 100644 --- a/test/new-unit/email-dao-test.js +++ b/test/new-unit/email-dao-test.js @@ -16,7 +16,7 @@ define(function(require) { asymKeySize: 512 }; - var dummyMail; + var dummyMail, verificationMail, plaintextMail; var publicKey = "-----BEGIN PUBLIC KEY-----\r\n" + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCxy+Te5dyeWd7g0P+8LNO7fZDQ\r\n" + "g96xTb1J6pYE/pPTMlqhB6BRItIYjZ1US5q2vk5Zk/5KasBHAc9RbCqvh9v4XFEY\r\n" + "JVmTXC4p8ft1LYuNWIaDk+R3dyYXmRNct/JC4tks2+8fD3aOvpt0WNn3R75/FGBt\r\n" + "h4BgojAXDE+PRQtcVQIDAQAB\r\n" + "-----END PUBLIC KEY-----"; @@ -37,6 +37,28 @@ define(function(require) { }], // list of receivers subject: "[whiteout] Hello", // Subject line body: "Hello world" // plaintext body + }, verificationMail = { + from: [{ + name: 'Whiteout Test', + address: 'whiteout.test@t-online.de' + }], // sender address + to: [{ + address: 'safewithme.testuser@gmail.com' + }], // list of receivers + subject: "[whiteout] New public key uploaded", // Subject line + body: "https://keys.whiteout.io/verify/OMFG_FUCKING_BASTARD_UUID_FROM_HELL!", // plaintext body + unread: true + }, plaintextMail = { + from: [{ + name: 'Whiteout Test', + address: 'whiteout.test@t-online.de' + }], // sender address + to: [{ + address: 'safewithme.testuser@gmail.com' + }], // list of receivers + subject: "OMG SO PLAIN TEXT", // Subject line + body: "yo dawg, we be all plaintext and stuff...", // plaintext body + unread: true }; account = { @@ -578,7 +600,7 @@ define(function(require) { describe('IMAP: list messages from local storage', function() { it('should work', function(done) { dummyMail.body = app.string.cryptPrefix + btoa('asdf') + app.string.cryptSuffix; - devicestorageStub.listItems.yields(null, [dummyMail, dummyMail]); + devicestorageStub.listItems.yields(null, [dummyMail]); keychainStub.getReceiverPublicKey.yields(null, { _id: "fcf8b4aa-5d09-4089-8b4f-e3bc5091daf3", userId: "safewithme.testuser@gmail.com", @@ -589,13 +611,124 @@ define(function(require) { emailDao.listMessages({ folder: 'INBOX', offset: 0, - num: 2 + num: 1 }, function(err, emails) { expect(err).to.not.exist; expect(devicestorageStub.listItems.calledOnce).to.be.true; - expect(keychainStub.getReceiverPublicKey.calledTwice).to.be.true; - expect(pgpStub.decrypt.calledTwice).to.be.true; - expect(emails.length).to.equal(2); + expect(keychainStub.getReceiverPublicKey.calledOnce).to.be.true; + expect(pgpStub.decrypt.calledOnce).to.be.true; + expect(emails.length).to.equal(1); + done(); + }); + }); + }); + + describe('Plain text', function() { + it('should display plaintext mails with [whiteout] prefix', function(done) { + devicestorageStub.listItems.yields(null, [plaintextMail]); + + emailDao.listMessages({ + folder: 'INBOX', + offset: 0, + num: 1 + }, function(err, emails) { + expect(err).to.not.exist; + expect(emails.length).to.equal(1); + expect(devicestorageStub.listItems.calledOnce).to.be.true; + done(); + }); + }); + }); + + describe('Verification', function() { + it('should verify pending public keys', function(done) { + devicestorageStub.listItems.yields(null, [verificationMail]); + keychainStub.verifyPublicKey.yields(); + imapClientStub.updateFlags.yields(); + imapClientStub.deleteMessage.yields(); + devicestorageStub.removeList.yields(); + + emailDao.listMessages({ + folder: 'INBOX', + offset: 0, + num: 1 + }, function(err, emails) { + expect(err).to.not.exist; + expect(emails.length).to.equal(0); + expect(devicestorageStub.listItems.calledOnce).to.be.true; + expect(keychainStub.verifyPublicKey.calledOnce).to.be.true; + expect(imapClientStub.updateFlags.calledOnce).to.be.true; + expect(imapClientStub.deleteMessage.calledOnce).to.be.true; + expect(devicestorageStub.removeList.calledOnce).to.be.true; + done(); + }); + }); + + it('should not verify pending public keys if the mail was read', function(done) { + verificationMail.unread = false; + devicestorageStub.listItems.yields(null, [verificationMail]); + + emailDao.listMessages({ + folder: 'INBOX', + offset: 0, + num: 1 + }, function(err) { + expect(err).to.not.exist; + expect(devicestorageStub.listItems.calledOnce).to.be.true; + verificationMail.unread = true; + done(); + }); + }); + + it('should not verify pending public keys if the mail contains erroneous links', function(done) { + var properBody = verificationMail.body; + verificationMail.body = 'UGA UGA!'; + devicestorageStub.listItems.yields(null, [verificationMail]); + + emailDao.listMessages({ + folder: 'INBOX', + offset: 0, + num: 1 + }, function(err) { + expect(err).to.not.exist; + expect(devicestorageStub.listItems.calledOnce).to.be.true; + verificationMail.body = properBody; + done(); + }); + }); + it('should not mark verification mails read if verification fails', function(done) { + devicestorageStub.listItems.yields(null, [verificationMail]); + keychainStub.verifyPublicKey.yields({ + errMsg: 'snafu.' + }); + + emailDao.listMessages({ + folder: 'INBOX', + offset: 0, + num: 1 + }, function(err) { + expect(err).to.not.exist; + expect(devicestorageStub.listItems.calledOnce).to.be.true; + expect(keychainStub.verifyPublicKey.calledOnce).to.be.true; + done(); + }); + }); + it('should not delete verification mails read if marking read fails', function(done) { + devicestorageStub.listItems.yields(null, [verificationMail]); + keychainStub.verifyPublicKey.yields(); + imapClientStub.updateFlags.yields({ + errMsg: 'snafu.' + }); + + emailDao.listMessages({ + folder: 'INBOX', + offset: 0, + num: 1 + }, function(err) { + expect(err).to.not.exist; + expect(devicestorageStub.listItems.calledOnce).to.be.true; + expect(keychainStub.verifyPublicKey.calledOnce).to.be.true; + expect(imapClientStub.updateFlags.calledOnce).to.be.true; done(); }); }); diff --git a/test/new-unit/keychain-dao-test.js b/test/new-unit/keychain-dao-test.js index c67b80e..442f43a 100644 --- a/test/new-unit/keychain-dao-test.js +++ b/test/new-unit/keychain-dao-test.js @@ -20,6 +20,18 @@ define(function(require) { afterEach(function() {}); + describe('verify public key', function() { + it('should verify public key', function(done) { + var uuid = 'asdfasdfasdfasdf'; + pubkeyDaoStub.verify.yields(); + + keychainDao.verifyPublicKey(uuid, function() { + expect(pubkeyDaoStub.verify.calledWith(uuid)).to.be.true; + done(); + }); + }); + }); + describe('lookup public key', function() { it('should fail', function(done) { keychainDao.lookupPublicKey(undefined, function(err, key) { diff --git a/test/new-unit/publickey-dao-test.js b/test/new-unit/publickey-dao-test.js index c82d730..3ac5705 100644 --- a/test/new-unit/publickey-dao-test.js +++ b/test/new-unit/publickey-dao-test.js @@ -45,6 +45,30 @@ define(function(require) { }); }); + describe('verify', function() { + it('should fail', function(done) { + restDaoStub.get.yields(42); + + pubkeyDao.get('id', function(err) { + expect(err).to.exist; + done(); + }); + }); + + it('should work', function(done) { + var uuid = 'c621e328-8548-40a1-8309-adf1955e98a9'; + restDaoStub.get.yields(null); + + pubkeyDao.verify(uuid, function(err) { + expect(err).to.not.exist; + expect(restDaoStub.get.calledWith(sinon.match(function(arg){ + return arg.uri === '/verify/' + uuid && arg.type === 'text'; + }))).to.be.true; + done(); + }); + }); + }); + describe('get by userId', function() { it('should fail', function(done) { restDaoStub.get.yields(42); diff --git a/test/new-unit/rest-dao-test.js b/test/new-unit/rest-dao-test.js index 9e004e9..de461ff 100644 --- a/test/new-unit/rest-dao-test.js +++ b/test/new-unit/rest-dao-test.js @@ -39,7 +39,100 @@ define(function(require) { }); describe('get', function() { - it('should fail', function(done) { + it('should work with json as default type', function(done) { + $.ajax.restore(); + var spy = sinon.stub($, 'ajax').yieldsTo('success', { + foo: 'bar' + }); + + restDao.get({ + uri: '/asdf', + type: 'json' + }, function(err, data) { + expect(err).to.not.exist; + expect(data.foo).to.equal('bar'); + expect(spy.calledWith(sinon.match(function(request) { + return request.headers.Accept === 'application/json' && request.dataType === 'json'; + }))).to.be.true; + done(); + }); + }); + + it('should work with json', function(done) { + $.ajax.restore(); + var spy = sinon.stub($, 'ajax').yieldsTo('success', { + foo: 'bar' + }); + + restDao.get({ + uri: '/asdf', + type: 'json' + }, function(err, data) { + expect(err).to.not.exist; + expect(data.foo).to.equal('bar'); + expect(spy.calledWith(sinon.match(function(request) { + return request.headers.Accept === 'application/json' && request.dataType === 'json'; + }))).to.be.true; + done(); + }); + }); + + it('should work with plain text', function(done) { + $.ajax.restore(); + var spy = sinon.stub($, 'ajax').yieldsTo('success', 'foobar!'); + + restDao.get({ + uri: '/asdf', + type: 'text' + }, function(err, data) { + expect(err).to.not.exist; + expect(data).to.equal('foobar!'); + expect(spy.calledWith(sinon.match(function(request) { + return request.headers.Accept === 'text/plain' && request.dataType === 'text'; + }))).to.be.true; + done(); + }); + }); + + it('should work with xml', function(done) { + $.ajax.restore(); + var spy = sinon.stub($, 'ajax').yieldsTo('success', 'bar'); + + restDao.get({ + uri: '/asdf', + type: 'xml' + }, function(err, data) { + expect(err).to.not.exist; + expect(data).to.equal('bar'); // that's probably not right, but in the unit test, it is :) + expect(spy.calledWith(sinon.match(function(request) { + return request.headers.Accept === 'application/xml' && request.dataType === 'xml'; + }))).to.be.true; + done(); + }); + }); + + it('should fail for missing uri parameter', function(done) { + restDao.get({}, function(err, data) { + expect(err).to.exist; + expect(err.code).to.equal(400); + expect(data).to.not.exist; + done(); + }); + }); + + it('should fail for unhandled data type', function(done) { + restDao.get({ + uri: '/asdf', + type: 'snafu' + }, function(err, data) { + expect(err).to.exist; + expect(err.code).to.equal(400); + expect(data).to.not.exist; + done(); + }); + }); + + it('should fail for server error', function(done) { $.ajax.restore(); sinon.stub($, 'ajax').yieldsTo('error', { status: 500 @@ -47,21 +140,15 @@ define(function(require) { statusText: 'Internal error' }, {}); - restDao.get('/asdf', function(err, data) { + restDao.get({ + uri: '/asdf' + }, function(err, data) { expect(err).to.exist; expect(err.code).to.equal(500); expect(data).to.not.exist; done(); }); }); - - it('should work', function(done) { - restDao.get('/asdf', function(err, data) { - expect(err).to.not.exist; - expect(data.foo).to.equal('bar'); - done(); - }); - }); }); describe('put', function() {