From 1d4a9414bb74e78471590cf52fdede596a86e23a Mon Sep 17 00:00:00 2001 From: Felix Hammerl Date: Fri, 20 Feb 2015 17:55:11 +0100 Subject: [PATCH] [WO-860] Introduce publickey-verifier --- Gruntfile.js | 5 +- src/js/app.js | 4 + src/js/controller/login/login-initial.js | 13 +- src/js/controller/login/login-new-device.js | 28 ++- .../login/login-verify-public-key.js | 100 ++++++++ src/js/email/email.js | 84 +------ src/js/service/index.js | 3 +- src/js/service/keychain.js | 20 +- src/js/service/publickey-verifier.js | 233 ++++++++++++++++++ src/tpl/login-verify-public-key.html | 36 +++ test/integration/publickey-verifier-test.js | 153 ++++++++++++ .../login/login-initial-ctrl-test.js | 9 +- .../login/login-new-device-ctrl-test.js | 18 +- .../login-verify-public-key-ctrl-test.js | 113 +++++++++ test/unit/email/email-dao-test.js | 127 +--------- test/unit/service/keychain-dao-test.js | 23 ++ test/unit/service/publickey-verifier-test.js | 210 ++++++++++++++++ 17 files changed, 952 insertions(+), 227 deletions(-) create mode 100644 src/js/controller/login/login-verify-public-key.js create mode 100644 src/js/service/publickey-verifier.js create mode 100644 src/tpl/login-verify-public-key.html create mode 100644 test/integration/publickey-verifier-test.js create mode 100644 test/unit/controller/login/login-verify-public-key-ctrl-test.js create mode 100644 test/unit/service/publickey-verifier-test.js diff --git a/Gruntfile.js b/Gruntfile.js index bc0aac7..ff51b07 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -178,6 +178,7 @@ module.exports = function(grunt) { 'test/unit/service/newsletter-service-test.js', 'test/unit/service/mail-config-service-test.js', 'test/unit/service/invitation-dao-test.js', + 'test/unit/service/publickey-verifier-test.js', 'test/unit/email/outbox-bo-test.js', 'test/unit/email/email-dao-test.js', 'test/unit/email/account-test.js', @@ -189,6 +190,7 @@ module.exports = function(grunt) { 'test/unit/controller/login/login-initial-ctrl-test.js', 'test/unit/controller/login/login-new-device-ctrl-test.js', 'test/unit/controller/login/login-privatekey-download-ctrl-test.js', + 'test/unit/controller/login/login-verify-public-key-ctrl-test.js', 'test/unit/controller/login/login-set-credentials-ctrl-test.js', 'test/unit/controller/login/login-ctrl-test.js', 'test/unit/controller/app/dialog-ctrl-test.js', @@ -210,7 +212,8 @@ module.exports = function(grunt) { files: { 'test/integration/index.browserified.js': [ 'test/main.js', - 'test/integration/email-dao-test.js' + 'test/integration/email-dao-test.js', + 'test/integration/publickey-verifier-test.js' ] }, options: browserifyOpt diff --git a/src/js/app.js b/src/js/app.js index 2137932..a935a83 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -68,6 +68,10 @@ app.config(function($routeProvider, $animateProvider) { templateUrl: 'tpl/login-set-credentials.html', controller: require('./controller/login/login-set-credentials') }); + $routeProvider.when('/login-verify-public-key', { + templateUrl: 'tpl/login-verify-public-key.html', + controller: require('./controller/login/login-verify-public-key') + }); $routeProvider.when('/login-existing', { templateUrl: 'tpl/login-existing.html', controller: require('./controller/login/login-existing') diff --git a/src/js/controller/login/login-initial.js b/src/js/controller/login/login-initial.js index d7c015a..656fb53 100644 --- a/src/js/controller/login/login-initial.js +++ b/src/js/controller/login/login-initial.js @@ -1,6 +1,6 @@ 'use strict'; -var LoginInitialCtrl = function($scope, $location, $routeParams, $q, newsletter, email, auth) { +var LoginInitialCtrl = function($scope, $location, $routeParams, $q, newsletter, email, auth, publickeyVerifier) { !$routeParams.dev && !auth.isInitialized() && $location.path('/'); // init app var emailAddress = auth.emailAddress; @@ -60,13 +60,10 @@ var LoginInitialCtrl = function($scope, $location, $routeParams, $q, newsletter, passphrase: undefined }); - }).then(function() { - // persist credentials locally - return auth.storeCredentials(); - - }).then(function() { - // go to main account screen - $location.path('/account'); + }).then(function(keypair) { + // go to public key verification + publickeyVerifier.keypair = keypair; + $location.path('/login-verify-public-key'); }).catch(displayError); }; diff --git a/src/js/controller/login/login-new-device.js b/src/js/controller/login/login-new-device.js index 533717b..98bbf4f 100644 --- a/src/js/controller/login/login-new-device.js +++ b/src/js/controller/login/login-new-device.js @@ -1,6 +1,6 @@ 'use strict'; -var LoginExistingCtrl = function($scope, $location, $routeParams, $q, email, auth, pgp, keychain) { +var LoginExistingCtrl = function($scope, $location, $routeParams, $q, email, auth, pgp, keychain, publickeyVerifier) { !$routeParams.dev && !auth.isInitialized() && $location.path('/'); // init app $scope.incorrect = false; @@ -12,6 +12,7 @@ var LoginExistingCtrl = function($scope, $location, $routeParams, $q, email, aut } var userId = auth.emailAddress, + pubKeyNeedsVerification = false, keypair; return $q(function(resolve) { @@ -61,6 +62,7 @@ var LoginExistingCtrl = function($scope, $location, $routeParams, $q, email, aut userIds: pubKeyParams.userIds, publicKey: $scope.key.publicKeyArmored }; + pubKeyNeedsVerification = true; // this public key needs to be authenticated } // import and validate keypair @@ -72,17 +74,21 @@ var LoginExistingCtrl = function($scope, $location, $routeParams, $q, email, aut throw err; }); - }).then(function() { - // perist keys locally - return keychain.putUserKeyPair(keypair); + }).then(function(keypair) { + if (!pubKeyNeedsVerification) { + // persist credentials and key and go to main account screen + return keychain.putUserKeyPair(keypair).then(function() { + return auth.storeCredentials(); + }).then(function() { + $location.path('/account'); + }); + } - }).then(function() { - // persist credentials locally - return auth.storeCredentials(); - - }).then(function() { - // go to main account screen - $location.path('/account'); + // go to public key verification + publickeyVerifier.keypair = keypair; + return keychain.uploadPublicKey(keypair.publicKey).then(function() { + $location.path('/login-verify-public-key'); + }); }).catch(displayError); }; diff --git a/src/js/controller/login/login-verify-public-key.js b/src/js/controller/login/login-verify-public-key.js new file mode 100644 index 0000000..8c75396 --- /dev/null +++ b/src/js/controller/login/login-verify-public-key.js @@ -0,0 +1,100 @@ +'use strict'; + +var RETRY_INTERVAL = 10000; + +var PublicKeyVerifierCtrl = function($scope, $location, $q, $timeout, $interval, auth, publickeyVerifier, keychain) { + $scope.retries = 0; + + /** + * Runs a verification attempt + */ + $scope.verify = function() { + if ($scope.busy) { + return; + } + + $scope.busy = true; + disarmTimeouts(); + + return $q(function(resolve) { + // updates the GUI + resolve(); + + }).then(function() { + // pre-flight check: is there already a public key for the user? + return keychain.getUserKeyPair(auth.emailAddress); + + }).then(function(keypair) { + if (!keypair || !keypair.publicKey) { + // no pubkey, need to do the roundtrip + return verifyImap(); + } + + // pubkey has already been verified, we're done here + return success(); + + }).catch(function(error) { + $scope.busy = false; + $scope.errMsg = error.message; // display error + + scheduleVerification(); // schedule next verification attempt + }); + + function verifyImap() { + // retrieve the credentials + return auth.getCredentials().then(function(credentials) { + return publickeyVerifier.configure(credentials); // configure imap + + }).then(function() { + return publickeyVerifier.verify(); // connect to imap to look for the message + + }).then(function() { + return success(); + }); + } + }; + + function success() { + return $q(function(resolve) { + resolve(); + + }).then(function() { + // persist keypair + return publickeyVerifier.persistKeypair(); + + }).then(function() { + // persist credentials locally (needs private key to encrypt imap password) + return auth.storeCredentials(); + + }).then(function() { + $location.path('/account'); // go to main account screen + }); + } + + /** + * schedules next verification attempt in RETRY_INTERVAL ms (scope.timeout) + * and sets up a countdown timer for the ui (scope.countdown) + */ + function scheduleVerification() { + $scope.timeout = setTimeout($scope.verify, RETRY_INTERVAL); + + // shows the countdown timer, decrements each second + $scope.countdown = RETRY_INTERVAL / 1000; + $scope.countdownDecrement = setInterval(function() { + if ($scope.countdown > 0) { + $timeout(function() { + $scope.countdown--; + }, 0); + } + }, 1000); + } + + function disarmTimeouts() { + clearTimeout($scope.timeout); + clearInterval($scope.countdownDecrement); + } + + scheduleVerification(); +}; + +module.exports = PublicKeyVerifierCtrl; \ No newline at end of file diff --git a/src/js/email/email.js b/src/js/email/email.js index ab3f291..8f3a8b7 100644 --- a/src/js/email/email.js +++ b/src/js/email/email.js @@ -122,7 +122,7 @@ Email.prototype.unlock = function(options) { }).then(function() { // persist newly generated keypair - var newKeypair = { + return { publicKey: { _id: generatedKeypair.keyId, userId: self._account.emailAddress, @@ -135,8 +135,6 @@ Email.prototype.unlock = function(options) { } }; - return self._keychain.putUserKeyPair(newKeypair); - }).then(setPrivateKey); function handleExistingKeypair(keypair) { @@ -160,6 +158,7 @@ Email.prototype.unlock = function(options) { if (!matchingPrivUserId || !matchingPubUserId || keypair.privateKey.userId !== self._account.emailAddress || keypair.publicKey.userId !== self._account.emailAddress) { throw new Error('User IDs dont match!'); } + resolve(); }).then(function() { @@ -168,14 +167,17 @@ Email.prototype.unlock = function(options) { passphrase: options.passphrase, privateKeyArmored: keypair.privateKey.encryptedKey, publicKeyArmored: keypair.publicKey.publicKey + }).then(function() { + return keypair; }); }).then(setPrivateKey); } - function setPrivateKey() { + function setPrivateKey(keypair) { // set decrypted privateKey to pgpMailer self._pgpbuilder._privateKey = self._pgp._privateKey; + return keypair; } }; @@ -259,15 +261,11 @@ Email.prototype.refreshFolder = function(options) { /** * Fetches a message's headers from IMAP. * - * NB! If we fetch a message whose subject line correspond's to that of a verification message, - * we try to verify that, and if that worked, we delete the verified message from IMAP. - * * @param {Object} options.folder The folder for which to fetch the message */ Email.prototype.fetchMessages = function(options) { var self = this, - folder = options.folder, - messages; + folder = options.folder; self.busy(); @@ -279,42 +277,18 @@ Email.prototype.fetchMessages = function(options) { // list the messages starting from the lowest new uid to the highest new uid return self._imapListMessages(options); - }).then(function(msgs) { - messages = msgs; - // if there are verification messages in the synced messages, handle it - var verificationMessages = _.filter(messages, function(message) { - return message.subject === str.verificationSubject; - }); - - // if there are verification messages, continue after we've tried to verify - if (verificationMessages.length > 0) { - var jobs = []; - verificationMessages.forEach(function(verificationMessage) { - var promise = handleVerification(verificationMessage).then(function() { - // if verification worked, we remove the mail from the list. - messages.splice(messages.indexOf(verificationMessage), 1); - }).catch(function() { - // if it was NOT a valid verification mail, do nothing - // if an error occurred and the mail was a valid verification mail, - // keep the mail in the list so the user can see it and verify manually - }); - jobs.push(promise); - }); - return Promise.all(jobs); - } - - }).then(function() { + }).then(function(messages) { if (_.isEmpty(messages)) { // nothing to do, we're done here return; } - // persist the encrypted message to the local storage + // persist the messages to the local storage return self._localStoreMessages({ folder: folder, emails: messages }).then(function() { - // this enables us to already show the attachment clip in the message list ui + // show the attachment clip in the message list ui messages.forEach(function(message) { message.attachments = message.bodyParts.filter(function(bodyPart) { return bodyPart.type === MSG_PART_TYPE_ATTACHMENT; @@ -338,40 +312,6 @@ Email.prototype.fetchMessages = function(options) { throw err; } } - - // Handles verification of public keys, deletion of messages with verified keys - function handleVerification(message) { - return self._getBodyParts({ - folder: folder, - uid: message.uid, - bodyParts: message.bodyParts - }).then(function(parsedBodyParts) { - var body = _.pluck(filterBodyParts(parsedBodyParts, MSG_PART_TYPE_TEXT), MSG_PART_ATTR_CONTENT).join('\n'), - verificationUrlPrefix = config.keyServerUrl + config.verificationUrl, - uuid = body.split(verificationUrlPrefix).pop().substr(0, config.verificationUuidLength), - uuidRegex = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/; - - // there's no valid uuid in the message, so forget about it - if (!uuidRegex.test(uuid)) { - throw new Error('No public key verifier found!'); - } - - // there's a valid uuid in the message, so try to verify it - return self._keychain.verifyPublicKey(uuid).catch(function(err) { - throw new Error('Verifying your public key failed: ' + err.message); - }); - - }).then(function() { - // public key has been verified, delete the message - return self._imapDeleteMessage({ - folder: folder, - uid: message.uid - }).catch(function() { - // if we could successfully not delete the message or not doesn't matter. - // just don't show it in whiteout and keep quiet about it - }); - }); - } }; /** @@ -1573,8 +1513,8 @@ Email.prototype._getBodyParts = function(options) { return self._imapClient.getBodyParts(options); }).then(function() { if (options.bodyParts.filter(function(bodyPart) { - return !(bodyPart.raw || bodyPart.content); - }).length) { + return !(bodyPart.raw || bodyPart.content); + }).length) { var error = new Error('Can not get the contents of this message. It has already been deleted!'); error.hide = true; throw error; diff --git a/src/js/service/index.js b/src/js/service/index.js index 18731d1..bf633a6 100644 --- a/src/js/service/index.js +++ b/src/js/service/index.js @@ -14,4 +14,5 @@ require('./admin'); require('./lawnchair'); require('./devicestorage'); require('./auth'); -require('./keychain'); \ No newline at end of file +require('./keychain'); +require('./publickey-verifier'); \ No newline at end of file diff --git a/src/js/service/keychain.js b/src/js/service/keychain.js index 4c9912d..1f1a4c0 100644 --- a/src/js/service/keychain.js +++ b/src/js/service/keychain.js @@ -659,7 +659,7 @@ Keychain.prototype.putUserKeyPair = function(keypair) { // validate input if (!keypair || !keypair.publicKey || !keypair.privateKey || !keypair.publicKey.userId || keypair.publicKey.userId !== keypair.privateKey.userId) { return new Promise(function() { - throw new Error('Incorrect input!'); + throw new Error('Cannot put user key pair: Incorrect input!'); }); } @@ -676,6 +676,24 @@ Keychain.prototype.putUserKeyPair = function(keypair) { }); }; +/** + * Uploads the public key + * @param {Object} publicKey The user's public key + * @return {Promise} + */ +Keychain.prototype.uploadPublicKey = function(publicKey) { + var self = this; + + // validate input + if (!publicKey || !publicKey.userId || !publicKey.publicKey) { + return new Promise(function() { + throw new Error('Cannot upload user key pair: Incorrect input!'); + }); + } + + return self._publicKeyDao.put(publicKey); +}; + // // Helper functions // diff --git a/src/js/service/publickey-verifier.js b/src/js/service/publickey-verifier.js new file mode 100644 index 0000000..3e0ac99 --- /dev/null +++ b/src/js/service/publickey-verifier.js @@ -0,0 +1,233 @@ +'use strict'; + +var MSG_PART_ATTR_CONTENT = 'content'; +var MSG_PART_TYPE_TEXT = 'text'; + +var ngModule = angular.module('woServices'); +ngModule.service('publickeyVerifier', PublickeyVerifier); +module.exports = PublickeyVerifier; + +var ImapClient = require('imap-client'); + +function PublickeyVerifier(auth, appConfig, mailreader, keychain) { + this._appConfig = appConfig; + this._mailreader = mailreader; + this._keychain = keychain; + this._auth = auth; + this._workerPath = appConfig.config.workerPath + '/tcp-socket-tls-worker.min.js'; + this._keyServerUrl = this._appConfig.config.keyServerUrl; +} + +// +// Public API +// + +PublickeyVerifier.prototype.configure = function() { + var self = this; + + return self._auth.getCredentials().then(function(credentials) { + // tls socket worker path for multithreaded tls in non-native tls environments + credentials.imap.tlsWorkerPath = self._appConfig.config.workerPath + '/tcp-socket-tls-worker.min.js'; + self._imap = new ImapClient(credentials.imap); + }); +}; + +PublickeyVerifier.prototype.persistKeypair = function() { + return this._keychain.putUserKeyPair(this.keypair); +}; + +PublickeyVerifier.prototype.verify = function() { + var self = this, + verificationSuccessful = false; + + // have to wrap it in a promise to catch .onError of imap-client + return new Promise(function(resolve, reject) { + self._imap.onError = reject; + + // login + self._imap.login().then(function() { + // list folders + return self._imap.listWellKnownFolders(); + }).then(function(wellKnownFolders) { + var paths = []; // gathers paths + + // extract the paths from the folder arrays + for (var folderType in wellKnownFolders) { + if (wellKnownFolders.hasOwnProperty(folderType) && Array.isArray(wellKnownFolders[folderType])) { + paths = paths.concat(_.pluck(wellKnownFolders[folderType], 'path')); + } + } + return paths; + + }).then(function(paths) { + return self._searchAll(paths); // search + + }).then(function(candidates) { + if (!candidates.length) { + // nothing here to potentially verify + verificationSuccessful = false; + return; + } + + // verify everything that looks like a verification mail + return self._verifyAll(candidates).then(function(success) { + verificationSuccessful = success; + }); + + }).then(function() { + // at this point, we don't care about errors anymore + self._imap.onError = function() {}; + self._imap.logout(); + + if (!verificationSuccessful) { + // nothing unexpected went wrong, but no public key could be verified + throw new Error('Could not verify public key'); + } + + resolve(); // we're done + + }).catch(reject); + }); +}; + +PublickeyVerifier.prototype._searchAll = function(paths) { + var self = this, + candidates = []; // gather matching uids + + // async for-loop inside a then-able + return new Promise(next); + + // search each path for the relevant email + function next(resolve) { + if (!paths.length) { + resolve(candidates); + return; + } + + var path = paths.shift(); + self._imap.search({ + path: path, + header: ['Subject', self._appConfig.string.verificationSubject] + }).then(function(uids) { + uids.forEach(function(uid) { + candidates.push({ + path: path, + uid: uid + }); + }); + next(resolve); // keep on searching + }).catch(function() { + next(resolve); // if there's an error, just search the next inbox + }); + } +}; + +PublickeyVerifier.prototype._verifyAll = function(candidates) { + var self = this; + + // async for-loop inside a then-able + return new Promise(next); + + function next(resolve) { + if (!candidates.length) { + resolve(false); + return; + } + + var candidate = candidates.shift(); + self._verify(candidate.path, candidate.uid).then(function(success) { + if (success) { + resolve(success); // we're done here + } else { + next(resolve); + } + }).catch(function() { + next(resolve); // ignore + }); + } +}; + + +PublickeyVerifier.prototype._verify = function(path, uid) { + var self = this, + message; + + // get the metadata for the message + return self._imap.listMessages({ + path: path, + firstUid: uid, + lastUid: uid + }).then(function(messages) { + if (!messages.length) { + // message has been deleted in the meantime + throw new Error('Message has already been deleted'); + } + + // remember in scope + message = messages[0]; + + }).then(function() { + // get the body for the message + return self._imap.getBodyParts({ + path: path, + uid: uid, + bodyParts: message.bodyParts + }); + + }).then(function() { + // parse the message + return new Promise(function(resolve, reject) { + self._mailreader.parse(message, function(err, root) { + if (err) { + reject(err); + } else { + resolve(root); + } + }); + }); + + }).then(function(root) { + // extract the nonce + var body = _.pluck(filterBodyParts(root, MSG_PART_TYPE_TEXT), MSG_PART_ATTR_CONTENT).join('\n'), + verificationUrlPrefix = self._keyServerUrl + self._appConfig.config.verificationUrl, + uuid = body.split(verificationUrlPrefix).pop().substr(0, self._appConfig.config.verificationUuidLength), + uuidRegex = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/; + + // there's no valid uuid in the message, so forget about it + if (!uuidRegex.test(uuid)) { + throw new Error('No public key verifier found!'); + } + + // there's a valid uuid in the message, so try to verify it + return self._keychain.verifyPublicKey(uuid).catch(function(err) { + throw new Error('Verifying your public key failed: ' + err.message); + }); + + }).then(function() { + return self._imap.deleteMessage({ + path: path, + uid: uid + }).catch(function() {}); // ignore error here + }).then(function() { + return true; + }); +}; + +/** + * Helper function that recursively traverses the body parts tree. Looks for bodyParts that match the provided type and aggregates them + * + * @param {Array} bodyParts The bodyParts array + * @param {String} type The type to look up + * @param {undefined} result Leave undefined, only used for recursion + */ +function filterBodyParts(bodyParts, type, result) { + result = result || []; + bodyParts.forEach(function(part) { + if (part.type === type) { + result.push(part); + } else if (Array.isArray(part.content)) { + filterBodyParts(part.content, type, result); + } + }); + return result; +} \ No newline at end of file diff --git a/src/tpl/login-verify-public-key.html b/src/tpl/login-verify-public-key.html new file mode 100644 index 0000000..5ee4e74 --- /dev/null +++ b/src/tpl/login-verify-public-key.html @@ -0,0 +1,36 @@ +
+
+ +
+

Email address verification

+

+ We will now automatically verify your email address with a confirmation message we've sent you. +

+
+

+ Verifying your email address in {{countdown}} seconds. +

+ +
+

{{errMsg}}

+
+ +
+
+
+ +
+

+ This could take a moment. Please be patient, you'll be forwarded to your inbox when verification is successful. +

+
+ +
+
+ +
+
+
+
\ No newline at end of file diff --git a/test/integration/publickey-verifier-test.js b/test/integration/publickey-verifier-test.js new file mode 100644 index 0000000..206a382 --- /dev/null +++ b/test/integration/publickey-verifier-test.js @@ -0,0 +1,153 @@ +'use strict'; + +var ImapClient = require('imap-client'), + BrowserCrow = require('browsercrow'), + mailreader = require('mailreader'), + config = require('../../src/js/app-config'), + str = config.string; + +describe('Public-Key Verifier integration tests', function() { + this.timeout(10 * 1000); + + var verifier; // SUT + var imapServer, keyId, workingUUID, outdatedUUID; // fixture + var imapClient, auth, keychain; // stubs + + beforeEach(function(done) { + + // + // Test data + // + + keyId = '1234DEADBEEF'; + workingUUID = '8314D2BF-82E5-4862-A614-1EA8CD582485'; + outdatedUUID = 'CA8BD44B-E4C5-4D48-82AB-33DA2E488CF7'; + + // + // Test server setup + // + + var testAccount = { + user: 'safewithme.testuser@gmail.com', + pass: 'passphrase', + xoauth2: 'testtoken' + }; + + var serverUsers = {}; + serverUsers[testAccount.user] = { + password: testAccount.pass, + xoauth2: { + accessToken: testAccount.xoauth2, + sessionTimeout: 3600 * 1000 + } + }; + + imapServer = new BrowserCrow({ + debug: false, + plugins: ['sasl-ir', 'xoauth2', 'special-use', 'id', 'idle', 'unselect', 'enable', 'condstore'], + id: { + name: 'browsercrow', + version: '0.1.0' + }, + storage: { + 'INBOX': { + messages: [{ + raw: 'Message-id: \r\nSubject: ' + str.verificationSubject + '\r\n\r\nhttps://keys.whiteout.io/verify/' + outdatedUUID, + uid: 100 + }, { + raw: 'Message-id: \r\nSubject: ' + str.verificationSubject + '\r\n\r\nhttps://keys.whiteout.io/verify/' + workingUUID, + uid: 200 + }] + }, + '': { + separator: '/', + folders: { + '[Gmail]': { + flags: ['\\Noselect'], + folders: { + 'All Mail': { + 'special-use': '\\All' + }, + Drafts: { + 'special-use': '\\Drafts' + }, + Important: { + 'special-use': '\\Important' + }, + 'Sent Mail': { + 'special-use': '\\Sent' + }, + Spam: { + 'special-use': '\\Junk' + }, + Starred: { + 'special-use': '\\Flagged' + }, + Trash: { + 'special-use': '\\Trash' + } + } + } + } + } + }, + users: serverUsers + }); + + // don't multithread, Function.prototype.bind() is broken in phantomjs in web workers + window.Worker = undefined; + sinon.stub(mailreader, 'startWorker', function() {}); + + // build and inject angular services + angular.module('email-integration-test', ['woEmail']); + angular.mock.module('email-integration-test'); + angular.mock.inject(function($injector) { + verifier = $injector.get('publickeyVerifier'); + setup(); + }); + + function setup() { + auth = verifier._auth; + auth.setCredentials({ + emailAddress: testAccount.user, + password: 'asd', + smtp: {}, // host and port don't matter here since we're using + imap: {} // a preconfigured smtpclient with mocked tcp sockets + }); + + // avoid firing up a whole http + keychain = verifier._keychain; + keychain.verifyPublicKey = function(uuid) { + return new Promise(function(res, rej) { + if (uuid === workingUUID) { + res(); + } else { + rej(); + } + }); + }; + + // create imap/smtp clients with stubbed tcp sockets + imapClient = new ImapClient({ + auth: { + user: testAccount.user, + xoauth2: testAccount.xoauth2 + }, + secure: true + }); + imapClient._client.client._TCPSocket = imapServer.createTCPSocket(); + + auth._initialized = true; + verifier._imap = imapClient; + verifier._keyId = keyId; + + done(); + } + }); + + describe('#verify', function() { + it('should verify a key', function(done) { + verifier.verify(keyId).then(done); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/controller/login/login-initial-ctrl-test.js b/test/unit/controller/login/login-initial-ctrl-test.js index 016aebf..7e0296f 100644 --- a/test/unit/controller/login/login-initial-ctrl-test.js +++ b/test/unit/controller/login/login-initial-ctrl-test.js @@ -1,17 +1,19 @@ 'use strict'; var Auth = require('../../../../src/js/service/auth'), + PublicKeyVerifier = require('../../../../src/js/service/publickey-verifier'), LoginInitialCtrl = require('../../../../src/js/controller/login/login-initial'), Email = require('../../../../src/js/email/email'); describe('Login (initial user) Controller unit test', function() { - var scope, ctrl, location, emailMock, authMock, newsletterStub, + var scope, ctrl, location, emailMock, authMock, newsletterStub, verifierMock, emailAddress = 'fred@foo.com', keyId, expectedKeyId; beforeEach(function() { emailMock = sinon.createStubInstance(Email); authMock = sinon.createStubInstance(Auth); + verifierMock = sinon.createStubInstance(PublicKeyVerifier); keyId = '9FEB47936E712926'; expectedKeyId = '6E712926'; @@ -32,6 +34,7 @@ describe('Login (initial user) Controller unit test', function() { $routeParams: {}, $q: window.qMock, newsletter: newsletter, + publickeyVerifier: verifierMock, email: emailMock, auth: authMock }); @@ -102,14 +105,14 @@ describe('Login (initial user) Controller unit test', function() { emailMock.unlock.withArgs({ passphrase: undefined, realname: authMock.realname - }).returns(resolves()); + }).returns(resolves('foofoo')); authMock.storeCredentials.returns(resolves()); scope.generateKey().then(function() { expect(scope.errMsg).to.not.exist; expect(scope.state.ui).to.equal(2); expect(newsletterStub.called).to.be.true; - expect(location.$$path).to.equal('/account'); + expect(location.$$path).to.equal('/login-verify-public-key'); expect(emailMock.unlock.calledOnce).to.be.true; done(); }); diff --git a/test/unit/controller/login/login-new-device-ctrl-test.js b/test/unit/controller/login/login-new-device-ctrl-test.js index 4151f19..3962730 100644 --- a/test/unit/controller/login/login-new-device-ctrl-test.js +++ b/test/unit/controller/login/login-new-device-ctrl-test.js @@ -3,6 +3,7 @@ var PGP = require('../../../../src/js/crypto/pgp'), LoginNewDeviceCtrl = require('../../../../src/js/controller/login/login-new-device'), KeychainDAO = require('../../../../src/js/service/keychain'), + PublicKeyVerifier = require('../../../../src/js/service/publickey-verifier'), EmailDAO = require('../../../../src/js/email/email'), Auth = require('../../../../src/js/service/auth'); @@ -11,11 +12,14 @@ describe('Login (new device) Controller unit test', function() { emailAddress = 'fred@foo.com', passphrase = 'asd', keyId, - keychainMock; + location, + keychainMock, + verifierMock; beforeEach(function() { emailMock = sinon.createStubInstance(EmailDAO); authMock = sinon.createStubInstance(Auth); + verifierMock = sinon.createStubInstance(PublicKeyVerifier); keyId = '9FEB47936E712926'; keychainMock = sinon.createStubInstance(KeychainDAO); @@ -26,8 +30,10 @@ describe('Login (new device) Controller unit test', function() { angular.module('loginnewdevicetest', ['woServices']); angular.mock.module('loginnewdevicetest'); - angular.mock.inject(function($rootScope, $controller) { + angular.mock.inject(function($rootScope, $location, $controller) { scope = $rootScope.$new(); + location = $location; + scope.state = { ui: {} }; @@ -39,6 +45,7 @@ describe('Login (new device) Controller unit test', function() { email: emailMock, auth: authMock, pgp: pgpMock, + publickeyVerifier: verifierMock, keychain: keychainMock }); }); @@ -69,12 +76,13 @@ describe('Login (new device) Controller unit test', function() { _id: keyId, publicKey: 'a' })); - emailMock.unlock.withArgs(sinon.match.any, passphrase).returns(resolves()); + emailMock.unlock.returns(resolves('asd')); keychainMock.putUserKeyPair.returns(resolves()); scope.confirmPassphrase().then(function() { expect(emailMock.unlock.calledOnce).to.be.true; expect(keychainMock.getUserKeyPair.calledOnce).to.be.true; + expect(location.$$path).to.equal('/account'); done(); }); }); @@ -92,12 +100,14 @@ describe('Login (new device) Controller unit test', function() { }); keychainMock.getUserKeyPair.withArgs(emailAddress).returns(resolves()); - emailMock.unlock.withArgs(sinon.match.any, passphrase).returns(resolves()); + keychainMock.uploadPublicKey.returns(resolves()); + emailMock.unlock.returns(resolves('asd')); keychainMock.putUserKeyPair.returns(resolves()); scope.confirmPassphrase().then(function() { expect(emailMock.unlock.calledOnce).to.be.true; expect(keychainMock.getUserKeyPair.calledOnce).to.be.true; + expect(location.$$path).to.equal('/login-verify-public-key'); done(); }); }); diff --git a/test/unit/controller/login/login-verify-public-key-ctrl-test.js b/test/unit/controller/login/login-verify-public-key-ctrl-test.js new file mode 100644 index 0000000..f3281e6 --- /dev/null +++ b/test/unit/controller/login/login-verify-public-key-ctrl-test.js @@ -0,0 +1,113 @@ +'use strict'; + +var Auth = require('../../../../src/js/service/auth'), + Dialog = require('../../../../src/js/util/dialog'), + PublicKeyVerifier = require('../../../../src/js/service/publickey-verifier'), + KeychainDAO = require('../../../../src/js/service/keychain'), + PublicKeyVerifierCtrl = require('../../../../src/js/controller/login/login-verify-public-key'); + +describe('Public Key Verification Controller unit test', function() { + // Angular parameters + var scope, location; + + // Stubs & Fixture + var auth, verifier, dialogStub, keychain; + var emailAddress = 'foo@foo.com'; + + // SUT + var verificationCtrl; + + beforeEach(function() { + // remeber pre-test state to restore later + auth = sinon.createStubInstance(Auth); + verifier = sinon.createStubInstance(PublicKeyVerifier); + dialogStub = sinon.createStubInstance(Dialog); + keychain = sinon.createStubInstance(KeychainDAO); + + auth.emailAddress = emailAddress; + + // setup the controller + angular.module('publickeyverificationtest', []); + angular.mock.module('publickeyverificationtest'); + angular.mock.inject(function($rootScope, $controller, $location) { + scope = $rootScope.$new(); + location = $location; + + verificationCtrl = $controller(PublicKeyVerifierCtrl, { + $scope: scope, + $q: window.qMock, + auth: auth, + publickeyVerifier: verifier, + dialog: dialogStub, + keychain: keychain, + appConfig: { + string: { + publickeyVerificationSkipTitle: 'foo', + publickeyVerificationSkipMessage: 'bar' + } + } + }); + }); + }); + + afterEach(function() {}); + + describe('#verify', function() { + it('should verify', function(done) { + var credentials = {}; + + keychain.getUserKeyPair.withArgs(emailAddress).returns(resolves({})); + auth.getCredentials.returns(resolves(credentials)); + verifier.configure.withArgs(credentials).returns(resolves()); + verifier.verify.withArgs().returns(resolves()); + verifier.persistKeypair.returns(resolves()); + + scope.verify().then(function() { + expect(keychain.getUserKeyPair.calledOnce).to.be.true; + expect(auth.getCredentials.calledOnce).to.be.true; + expect(verifier.configure.calledOnce).to.be.true; + expect(verifier.verify.calledOnce).to.be.true; + expect(verifier.persistKeypair.calledOnce).to.be.true; + expect(location.$$path).to.equal('/account'); + + done(); + }); + }); + + it('should skip verification when key is already verified', function(done) { + keychain.getUserKeyPair.withArgs(emailAddress).returns(resolves({ + publicKey: {} + })); + + scope.verify().then(function() { + expect(keychain.getUserKeyPair.calledOnce).to.be.true; + expect(auth.getCredentials.called).to.be.false; + expect(verifier.configure.called).to.be.false; + expect(verifier.verify.called).to.be.false; + expect(location.$$path).to.equal('/account'); + + done(); + }); + }); + + it('should not verify', function(done) { + var credentials = {}; + + auth.getCredentials.returns(resolves(credentials)); + verifier.configure.withArgs(credentials).returns(resolves()); + verifier.verify.withArgs().returns(rejects(new Error('foo'))); + + scope.verify().then(function() { + expect(auth.getCredentials.calledOnce).to.be.true; + expect(verifier.configure.calledOnce).to.be.true; + expect(verifier.verify.calledOnce).to.be.true; + expect(scope.errMsg).to.equal('foo'); + + clearTimeout(scope.timeout); + clearInterval(scope.countdownDecrement); + + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/email/email-dao-test.js b/test/unit/email/email-dao-test.js index 819257d..b4d98c7 100644 --- a/test/unit/email/email-dao-test.js +++ b/test/unit/email/email-dao-test.js @@ -216,7 +216,6 @@ describe('Email DAO unit tests', function() { privateKeyArmored: mockKeyPair.privateKey.encryptedKey, publicKeyArmored: mockKeyPair.publicKey.publicKey }).returns(resolves()); - keychainStub.putUserKeyPair.withArgs().returns(resolves()); dao.unlock({ realname: name, @@ -224,30 +223,6 @@ describe('Email DAO unit tests', function() { }).then(function() { expect(pgpStub.generateKeys.calledOnce).to.be.true; expect(pgpStub.importKeys.calledOnce).to.be.true; - expect(keychainStub.putUserKeyPair.calledOnce).to.be.true; - - done(); - }); - }); - - it('should fail when persisting fails', function(done) { - var keypair = { - keyId: 123, - publicKeyArmored: 'qwerty', - privateKeyArmored: 'asdfgh' - }; - pgpStub.generateKeys.returns(resolves(keypair)); - pgpStub.importKeys.withArgs().returns(resolves()); - keychainStub.putUserKeyPair.returns(rejects({})); - - dao.unlock({ - passphrase: passphrase - }).catch(function(err) { - expect(err).to.exist; - - expect(pgpStub.generateKeys.calledOnce).to.be.true; - expect(pgpStub.importKeys.calledOnce).to.be.true; - expect(keychainStub.putUserKeyPair.calledOnce).to.be.true; done(); }); @@ -387,7 +362,7 @@ describe('Email DAO unit tests', function() { describe('#fetchMessages', function() { var imapListStub, imapGetStub, imapDeleteStub, localStoreStub; - var opts, message, validUuid, corruptedUuid, verificationSubject; + var opts, message; var notified; beforeEach(function() { @@ -407,9 +382,6 @@ describe('Email DAO unit tests', function() { unread: true, bodyParts: [] }; - validUuid = '9A858952-17EE-4273-9E74-D309EAFDFAFB'; - corruptedUuid = 'OMFG_FUCKING_BASTARD_UUID_FROM_HELL!'; - verificationSubject = "[whiteout] New public key uploaded"; notified = false; dao.onIncomingMessage = function(newMessages) { @@ -455,103 +427,6 @@ describe('Email DAO unit tests', function() { done(); }); }); - - it('should verify verification mails', function(done) { - message.subject = verificationSubject; - - imapListStub.withArgs(opts).returns(resolves([message])); - - imapGetStub.withArgs({ - folder: inboxFolder, - uid: message.uid, - bodyParts: message.bodyParts - }).returns(resolves([{ - type: 'text', - content: '' + cfg.keyServerUrl + cfg.verificationUrl + validUuid - }])); - - keychainStub.verifyPublicKey.withArgs(validUuid).returns(resolves()); - - imapDeleteStub.withArgs({ - folder: inboxFolder, - uid: message.uid - }).returns(resolves()); - - dao.fetchMessages(opts).then(function() { - expect(inboxFolder.messages).to.not.contain(message); - expect(notified).to.be.false; - expect(imapListStub.calledOnce).to.be.true; - expect(imapGetStub.calledOnce).to.be.true; - expect(keychainStub.verifyPublicKey.calledOnce).to.be.true; - expect(imapDeleteStub.calledOnce).to.be.true; - expect(localStoreStub.called).to.be.false; - done(); - }); - }); - - it('should not verify invalid verification mails', function(done) { - message.subject = verificationSubject; - - imapListStub.withArgs(opts).returns(resolves([message])); - - imapGetStub.withArgs({ - folder: inboxFolder, - uid: message.uid, - bodyParts: message.bodyParts - }).returns(resolves([{ - type: 'text', - content: '' + cfg.keyServerUrl + cfg.verificationUrl + corruptedUuid - }])); - - localStoreStub.withArgs({ - folder: inboxFolder, - emails: [message] - }).returns(resolves()); - - dao.fetchMessages(opts).then(function() { - expect(inboxFolder.messages).to.contain(message); - expect(notified).to.be.true; - expect(imapListStub.calledOnce).to.be.true; - expect(imapGetStub.calledOnce).to.be.true; - expect(keychainStub.verifyPublicKey.called).to.be.false; - expect(imapDeleteStub.called).to.be.false; - expect(localStoreStub.calledOnce).to.be.true; - done(); - }); - }); - - it('should display verification mail when verification failed', function(done) { - message.subject = verificationSubject; - - imapListStub.withArgs(opts).returns(resolves([message])); - - imapGetStub.withArgs({ - folder: inboxFolder, - uid: message.uid, - bodyParts: message.bodyParts - }).returns(resolves([{ - type: 'text', - content: '' + cfg.keyServerUrl + cfg.verificationUrl + validUuid - }])); - - keychainStub.verifyPublicKey.withArgs(validUuid).returns(rejects({})); - - localStoreStub.withArgs({ - folder: inboxFolder, - emails: [message] - }).returns(resolves()); - - dao.fetchMessages(opts).then(function() { - expect(inboxFolder.messages).to.contain(message); - expect(notified).to.be.true; - expect(imapListStub.calledOnce).to.be.true; - expect(imapGetStub.calledOnce).to.be.true; - expect(keychainStub.verifyPublicKey.calledOnce).to.be.true; - expect(imapDeleteStub.called).to.be.false; - expect(localStoreStub.calledOnce).to.be.true; - done(); - }); - }); }); describe('#deleteMessage', function() { diff --git a/test/unit/service/keychain-dao-test.js b/test/unit/service/keychain-dao-test.js index 165b941..1e7bcf8 100644 --- a/test/unit/service/keychain-dao-test.js +++ b/test/unit/service/keychain-dao-test.js @@ -1356,6 +1356,29 @@ describe('Keychain DAO unit tests', function() { }); }); + describe('upload public key', function() { + it('should upload key', function(done) { + var keypair = { + publicKey: { + _id: '12345', + userId: testUser, + publicKey: 'asdf' + }, + privateKey: { + _id: '12345', + encryptedKey: 'qwer' + } + }; + + pubkeyDaoStub.put.withArgs(keypair.publicKey).returns(resolves()); + + keychainDao.uploadPublicKey(keypair.publicKey).then(function() { + expect(pubkeyDaoStub.put.calledOnce).to.be.true; + done(); + }); + }); + }); + describe('put user keypair', function() { it('should fail', function(done) { var keypair = { diff --git a/test/unit/service/publickey-verifier-test.js b/test/unit/service/publickey-verifier-test.js new file mode 100644 index 0000000..2ed6fa9 --- /dev/null +++ b/test/unit/service/publickey-verifier-test.js @@ -0,0 +1,210 @@ +'use strict'; + +var mailreader = require('mailreader'), + KeychainDAO = require('../../../src/js/service/keychain'), + ImapClient = require('imap-client'), + PublickeyVerifier = require('../../../src/js/service/publickey-verifier'), + appConfig = require('../../../src/js/app-config'); + +describe('Public-Key Verifier', function() { + var verifier; + var imapStub, parseStub, keychainStub, credentials, workerPath; + + beforeEach(function() { + // + // Stubs + // + + workerPath = '../lib/tcp-socket-tls-worker.min.js'; + imapStub = sinon.createStubInstance(ImapClient); + parseStub = sinon.stub(mailreader, 'parse'); + keychainStub = sinon.createStubInstance(KeychainDAO); + + // + // Fixture + // + credentials = { + imap: { + host: 'asd', + port: 1234, + secure: true, + auth: { + user: 'user', + pass: 'pass' + } + } + }; + + // + // Setup SUT + // + verifier = new PublickeyVerifier({}, appConfig, mailreader, keychainStub); + verifier._imap = imapStub; + }); + + afterEach(function() { + mailreader.parse.restore(); + }); + + describe('#check', function() { + var FOLDER_TYPE_INBOX = 'Inbox', + FOLDER_TYPE_SENT = 'Sent', + FOLDER_TYPE_DRAFTS = 'Drafts', + FOLDER_TYPE_TRASH = 'Trash', + FOLDER_TYPE_FLAGGED = 'Flagged'; + + var messages, + folders, + searches, + workingUUID, + outdatedUUID; + + beforeEach(function() { + folders = {}; + searches = {}; + + [FOLDER_TYPE_INBOX, FOLDER_TYPE_SENT, FOLDER_TYPE_DRAFTS, FOLDER_TYPE_TRASH, FOLDER_TYPE_FLAGGED].forEach(function(type) { + folders[type] = [{ + path: type + }]; + searches[type] = { + path: type, + header: ['Subject', appConfig.string.verificationSubject] + }; + }); + + workingUUID = '8314D2BF-82E5-4862-A614-1EA8CD582485'; + outdatedUUID = 'CA8BD44B-E4C5-4D48-82AB-33DA2E488CF7'; + messages = [{ + uid: 123, + bodyParts: [{ + type: 'text', + content: 'https://keys.whiteout.io/verify/' + workingUUID + }] + }, { + uid: 456, + bodyParts: [{ + type: 'text', + content: 'https://keys.whiteout.io/verify/' + outdatedUUID + }] + }, { + uid: 789, + bodyParts: [{ + type: 'text', + content: 'foobar' + }] + }]; + }); + + it('should verify a key', function(done) { + // log in + imapStub.login.returns(resolves()); + + // list the folders + imapStub.listWellKnownFolders.returns(resolves(folders)); + + // return matching uids for inbox, flagged, and sent, otherwise no matches + imapStub.search.returns(resolves([])); + imapStub.search.withArgs(searches[FOLDER_TYPE_INBOX]).returns(resolves([messages[1].uid])); + imapStub.search.withArgs(searches[FOLDER_TYPE_FLAGGED]).returns(resolves([messages[0].uid])); + imapStub.search.withArgs(searches[FOLDER_TYPE_SENT]).returns(resolves([messages[2].uid])); + + // fetch message metadata from inbox, flagged, and sent + imapStub.listMessages.withArgs({ + path: FOLDER_TYPE_INBOX, + firstUid: messages[1].uid, + lastUid: messages[1].uid + }).returns(resolves([messages[1]])); + imapStub.listMessages.withArgs({ + path: FOLDER_TYPE_FLAGGED, + firstUid: messages[0].uid, + lastUid: messages[0].uid + }).returns(resolves([messages[0]])); + imapStub.listMessages.withArgs({ + path: FOLDER_TYPE_SENT, + firstUid: messages[2].uid, + lastUid: messages[2].uid + }).returns(resolves([messages[2]])); + + // fetch message metadata from inbox, flagged, and sent + imapStub.getBodyParts.withArgs({ + path: FOLDER_TYPE_INBOX, + uid: messages[1].uid, + bodyParts: messages[1].bodyParts + }).returns(resolves(messages[1].bodyParts)); + imapStub.getBodyParts.withArgs({ + path: FOLDER_TYPE_FLAGGED, + uid: messages[0].uid, + bodyParts: messages[0].bodyParts + }).returns(resolves(messages[0].bodyParts)); + imapStub.getBodyParts.withArgs({ + path: FOLDER_TYPE_SENT, + uid: messages[2].uid, + bodyParts: messages[2].bodyParts + }).returns(resolves(messages[2].bodyParts)); + + // parse messages (already have body parts, so this is essentially a no-op) + parseStub.withArgs(messages[0]).yields(null, messages[0].bodyParts); + parseStub.withArgs(messages[1]).yields(null, messages[1].bodyParts); + parseStub.withArgs(messages[2]).yields(null, messages[2].bodyParts); + + // delete the verification message from the inbox + imapStub.deleteMessage.withArgs({ + path: FOLDER_TYPE_FLAGGED, + uid: messages[0].uid + }).returns(resolves()); + + keychainStub.verifyPublicKey.withArgs(workingUUID).returns(resolves()); + keychainStub.verifyPublicKey.withArgs(outdatedUUID).returns(rejects(new Error('foo'))); + + // logout ... duh + imapStub.logout.returns(resolves()); + + // run the test + verifier.verify().then(function() { + // verification + expect(parseStub.callCount).to.equal(3); + expect(imapStub.login.callCount).to.equal(1); + expect(imapStub.listWellKnownFolders.callCount).to.equal(1); + expect(imapStub.search.callCount).to.be.at.least(5); + expect(imapStub.listMessages.callCount).to.equal(3); + expect(imapStub.getBodyParts.callCount).to.equal(3); + expect(imapStub.deleteMessage.callCount).to.equal(1); + expect(imapStub.logout.callCount).to.equal(1); + + done(); + }); + }); + + it('should not find a verifiable key', function(done) { + // log in + imapStub.login.returns(resolves()); + + // list the folders + imapStub.listWellKnownFolders.returns(resolves(folders)); + + // return matching uids for inbox, flagged, and sent, otherwise no matches + imapStub.search.returns(resolves([])); + + // logout ... duh + imapStub.logout.returns(resolves()); + + // run the test + verifier.verify().catch(function(error) { + expect(error.message).to.equal('Could not verify public key'); + + // verification + expect(imapStub.login.callCount).to.equal(1); + expect(imapStub.listWellKnownFolders.callCount).to.equal(1); + expect(imapStub.search.callCount).to.be.at.least(5); + expect(imapStub.listMessages.callCount).to.equal(0); + expect(imapStub.getBodyParts.callCount).to.equal(0); + expect(imapStub.deleteMessage.callCount).to.equal(0); + expect(imapStub.logout.callCount).to.equal(1); + expect(parseStub.callCount).to.equal(0); + + done(); + }); + }); + }); +}); \ No newline at end of file