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() {