[WO-860] Introduce publickey-verifier

This commit is contained in:
Felix Hammerl 2015-02-20 17:55:11 +01:00 committed by Tankred Hase
parent 0304bbf8fe
commit 1d4a9414bb
17 changed files with 952 additions and 227 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,4 +14,5 @@ require('./admin');
require('./lawnchair');
require('./devicestorage');
require('./auth');
require('./keychain');
require('./keychain');
require('./publickey-verifier');

View File

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

View File

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

View File

@ -0,0 +1,36 @@
<section class="page" ng-class="{'u-waiting-cursor': busy}">
<div class="page__canvas">
<header class="page__header">
<img src="img/whiteout_logo.svg" alt="whiteout.io">
</header>
<main class="page__main">
<h2 class="typo-title">Email address verification</h2>
<p class="typo-paragraph">
We will now automatically verify your email address with a confirmation message we've sent you.
</p>
<div ng-show="!busy">
<p class="typo-paragraph">
Verifying your email address in {{countdown}} seconds.
</p>
<form class="form" name="form">
<p class="form__error-message" ng-show="errMsg">{{errMsg}}</p>
<div class="form__row">
<button type="submit" ng-click="verify()" class="btn" tabindex="1">Verify now</button>
</div>
</form>
</div>
<div ng-show="busy">
<p class="typo-paragraph">
This could take a moment. Please be patient, you'll be forwarded to your inbox when verification is successful.
</p>
<div class="spinner-block spinner-block--standalone">
<span class="spinner spinner--big"></span>
</div>
</div>
</main>
<div ng-include="'tpl/page-footer.html'"></div>
</div>
</section>

View File

@ -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: <a>\r\nSubject: ' + str.verificationSubject + '\r\n\r\nhttps://keys.whiteout.io/verify/' + outdatedUUID,
uid: 100
}, {
raw: 'Message-id: <a>\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);
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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