[WO-85] introduce silent public key verification

This commit is contained in:
Felix Hammerl 2013-11-08 09:29:04 +01:00
parent 6e144a23e1
commit 14919847e3
9 changed files with 451 additions and 69 deletions

View File

@ -41,7 +41,17 @@ define([], function() {
cryptPrefix: '-----BEGIN PGP MESSAGE-----',
cryptSuffix: '-----END PGP MESSAGE-----',
signature: 'Sent securely from whiteout mail',
webSite: 'http://whiteout.io'
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;

View File

@ -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,123 @@ 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) {
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 +424,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;

View File

@ -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.

View File

@ -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();

View File

@ -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);

View File

@ -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();
});
});

View File

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

View File

@ -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);

View File

@ -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', '<foo>bar</foo>');
restDao.get({
uri: '/asdf',
type: 'xml'
}, function(err, data) {
expect(err).to.not.exist;
expect(data).to.equal('<foo>bar</foo>'); // 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() {