[WO-296] implement change passphrase ui

This commit is contained in:
Tankred Hase 2014-04-11 18:39:13 +02:00
parent bfc354146a
commit aee532da44
19 changed files with 410 additions and 89 deletions

View File

@ -7,6 +7,7 @@ requirejs([
'js/controller/popover',
'js/controller/add-account',
'js/controller/account',
'js/controller/set-passphrase',
'js/controller/contacts',
'js/controller/login',
'js/controller/login-initial',
@ -26,6 +27,7 @@ requirejs([
PopoverCtrl,
AddAccountCtrl,
AccountCtrl,
SetPassphraseCtrl,
ContactsCtrl,
LoginCtrl,
LoginInitialCtrl,
@ -92,6 +94,7 @@ requirejs([
app.controller('WriteCtrl', WriteCtrl);
app.controller('MailListCtrl', MailListCtrl);
app.controller('AccountCtrl', AccountCtrl);
app.controller('SetPassphraseCtrl', SetPassphraseCtrl);
app.controller('ContactsCtrl', ContactsCtrl);
app.controller('DialogCtrl', DialogCtrl);
app.controller('PopoverCtrl', PopoverCtrl);

View File

@ -3,14 +3,16 @@ define(function(require) {
var appController = require('js/app-controller'),
dl = require('js/util/download'),
emailDao;
pgp, keychain, userId;
//
// Controller
//
var AccountCtrl = function($scope) {
emailDao = appController._emailDao;
userId = appController._emailDao._account.emailAddress;
keychain = appController._keychain;
pgp = appController._crypto;
$scope.state.account = {
open: false,
@ -23,32 +25,49 @@ define(function(require) {
// scope variables
//
var fpr = emailDao._crypto.getFingerprint(),
keyId = emailDao._crypto.getKeyId();
$scope.eMail = emailDao._account.emailAddress;
$scope.keyId = keyId.slice(8);
var keyParams = pgp.getKeyParams();
$scope.eMail = userId;
$scope.keyId = keyParams._id.slice(8);
var fpr = keyParams.fingerprint;
$scope.fingerprint = fpr.slice(0, 4) + ' ' + fpr.slice(4, 8) + ' ' + fpr.slice(8, 12) + ' ' + fpr.slice(12, 16) + ' ' + fpr.slice(16, 20) + ' ' + fpr.slice(20, 24) + ' ' + fpr.slice(24, 28) + ' ' + fpr.slice(28, 32) + ' ' + fpr.slice(32, 36) + ' ' + fpr.slice(36);
$scope.keysize = emailDao._account.asymKeySize;
$scope.keysize = keyParams.bitSize;
//
// scope functions
//
$scope.exportKeyFile = function() {
emailDao._crypto.exportKeys(function(err, keys) {
keychain.getUserKeyPair(userId, function(err, keys) {
if (err) {
$scope.onError(err);
return;
}
var id = 'whiteout_mail_' + emailDao._account.emailAddress + '_' + keys.keyId.substring(8, keys.keyId.length);
var keyId = keys.publicKey._id;
var file = 'whiteout_mail_' + userId + '_' + keyId.substring(8, keyId.length);
dl.createDownload({
content: keys.publicKeyArmored + keys.privateKeyArmored,
filename: id + '.asc',
content: keys.publicKey.publicKey + keys.privateKey.encryptedKey,
filename: file + '.asc',
contentType: 'text/plain'
}, $scope.onError);
}, onExport);
});
};
function onExport(err) {
if (err) {
$scope.onError(err);
return;
}
$scope.state.account.toggle(false);
$scope.$apply();
$scope.onError({
title: 'Success',
message: 'Exported keypair to file.'
});
}
};
return AccountCtrl;

View File

@ -0,0 +1,83 @@
define(function(require) {
'use strict';
var appController = require('js/app-controller'),
pgp, keychain;
//
// Controller
//
var SetPassphraseCtrl = function($scope) {
keychain = appController._keychain;
pgp = appController._crypto;
$scope.state.setPassphrase = {
open: false,
toggle: function(to) {
this.open = to;
$scope.newPassphrase = undefined;
$scope.oldPassphrase = undefined;
$scope.confirmation = undefined;
}
};
//
// scope variables
//
//
// scope functions
//
$scope.setPassphrase = function() {
var keyId = pgp.getKeyParams()._id;
keychain.lookupPrivateKey(keyId, function(err, savedKey) {
if (err) {
$scope.onError(err);
return;
}
pgp.changePassphrase({
privateKeyArmored: savedKey.encryptedKey,
oldPassphrase: $scope.oldPassphrase,
newPassphrase: $scope.newPassphrase
}, onPassphraseChanged);
});
};
function onPassphraseChanged(err, newPrivateKeyArmored) {
if (err) {
$scope.onError(err);
return;
}
// persist new armored key
var keyParams = pgp.getKeyParams(newPrivateKeyArmored);
var privateKey = {
_id: keyParams._id,
userId: keyParams.userId,
encryptedKey: newPrivateKeyArmored
};
keychain.saveLocalPrivateKey(privateKey, onKeyPersisted);
}
function onKeyPersisted(err) {
if (err) {
$scope.onError(err);
return;
}
$scope.state.setPassphrase.toggle(false);
$scope.$apply();
$scope.onError({
title: 'Success',
message: 'Passphrase change complete.'
});
}
};
return SetPassphraseCtrl;
});

View File

@ -17,7 +17,7 @@ define(function(require) {
* Generate a key pair for the user
*/
PGP.prototype.generateKeys = function(options, callback) {
var userId;
var userId, passphrase;
if (!util.emailRegEx.test(options.emailAddress) || !options.keySize) {
callback({
@ -28,7 +28,8 @@ define(function(require) {
// generate keypair (keytype 1=RSA)
userId = 'Whiteout User <' + options.emailAddress + '>';
openpgp.generateKeyPair(1, options.keySize, userId, options.passphrase, onGenerated);
passphrase = (options.passphrase) ? options.passphrase : undefined;
openpgp.generateKeyPair(1, options.keySize, userId, passphrase, onGenerated);
function onGenerated(err, keys) {
if (err) {
@ -99,8 +100,18 @@ define(function(require) {
* Read all relevant params of an armored key.
*/
PGP.prototype.getKeyParams = function(keyArmored) {
var key = openpgp.key.readArmored(keyArmored).keys[0],
packet = key.getKeyPacket();
var key, packet;
// process armored key input
if (keyArmored) {
key = openpgp.key.readArmored(keyArmored).keys[0];
} else if (this._publicKey) {
key = this._publicKey;
} else {
throw new Error('Cannot read key params... keys not set!');
}
packet = key.getKeyPacket();
return {
_id: packet.getKeyId().toHex().toUpperCase(),
@ -188,17 +199,24 @@ define(function(require) {
* Change the passphrase of an ascii armored private key.
*/
PGP.prototype.changePassphrase = function(options, callback) {
var privKey, packets;
var privKey, packets, newPassphrase, newKeyArmored;
if (!options.privateKeyArmored ||
typeof options.oldPassphrase !== 'string' ||
typeof options.newPassphrase !== 'string') {
// set undefined instead of empty string as passphrase
newPassphrase = (options.newPassphrase) ? options.newPassphrase : undefined;
if (!options.privateKeyArmored) {
callback({
errMsg: 'Could not export keys!'
errMsg: 'Private key must be specified to change passphrase!'
});
return;
}
if (options.oldPassphrase === newPassphrase ||
(!options.oldPassphrase && !newPassphrase)) {
callback(new Error('New and old passphrase are the same!'));
return;
}
// read armored key
try {
privKey = openpgp.key.readArmored(options.privateKeyArmored).keys[0];
@ -221,8 +239,9 @@ define(function(require) {
try {
packets = privKey.getAllKeyPackets();
for (var i = 0; i < packets.length; i++) {
packets[i].encrypt(options.newPassphrase);
packets[i].encrypt(newPassphrase);
}
newKeyArmored = privKey.armor();
} catch (e) {
callback({
errMsg: 'Setting new passphrase failed!'
@ -230,7 +249,15 @@ define(function(require) {
return;
}
callback(null, privKey.armor());
// check if new passphrase really works
if (!privKey.decrypt(newPassphrase)) {
callback({
errMsg: 'Decrypting key with new passphrase failed!'
});
return;
}
callback(null, newKeyArmored);
};
/**

View File

@ -10,7 +10,11 @@ define(function() {
return;
}
console.error(options);
if (options.stack) {
console.error(options.stack);
} else {
console.error(options);
}
scope.state.dialog = {
open: true,

View File

@ -15,6 +15,7 @@
@import "components/icons";
@import "components/lightbox";
@import "components/nav";
@import "components/dialog";
@import "components/mail-list";
@import "components/layout";
@import "components/popover";
@ -24,6 +25,7 @@
@import "views/shared";
@import "views/add-account";
@import "views/account";
@import "views/set-passphrase";
@import "views/contacts";
@import "views/dialog";
@import "views/navigation";

View File

@ -0,0 +1,17 @@
.dialog {
padding: 0px;
color: $color-grey-dark;
@include respond-to(mobile) {
top: 0;
max-width: 100%;
}
.control {
float: right;
button {
border: 0!important;
}
}
}

View File

@ -1,13 +1,7 @@
.view-account {
padding: 0px;
color: $color-grey-dark;
@include respond-to(mobile) {
height: 100%;
}
table {
margin: 50px auto 100px auto;
margin: 50px auto 60px auto;
td {
padding-top: 15px;
@ -20,13 +14,4 @@
}
}
button {
border: 0!important;
}
.export-control {
position: absolute;
bottom: 15px;
right: 15px;
}
}

View File

@ -1,29 +1,17 @@
.view-dialog {
padding: 0px;
color: $color-grey-dark;
max-width: 350px;
height: auto;
top: 30%;
@include respond-to(mobile) {
top: 0;
max-width: 100%;
}
p {
text-align: center;
max-width: 80%;
margin: 30px auto 70px auto;
margin: 30px auto;
}
.control {
position: absolute;
bottom: $lightbox-padding;
right: $lightbox-padding;
button {
width: 100px;
border: 0!important;
}
}
}

View File

@ -0,0 +1,25 @@
.view-set-passphrase {
.inputs {
margin: 40px 60px 30px;
div {
margin: 5px 0;
}
}
table {
margin: 50px auto 60px auto;
td {
padding-top: 15px;
&:first-child {
text-align: right;
padding-right: 15px;
font-weight: bold;
}
}
}
}

View File

@ -5,7 +5,8 @@
</header>
<div class="content">
<div class="view-account">
<div class="dialog view-account">
<table>
<tbody>
<tr>
@ -26,9 +27,12 @@
</tr>
</tbody>
</table>
<div class="export-control">
<div class="control">
<button ng-click="state.account.toggle(false); state.setPassphrase.toggle(true)" class="btn btn-alt">Set passphrase</button>
<button ng-click="exportKeyFile()" class="btn">Export keypair</button>
</div>
</div><!-- /.view-account -->
</div><!-- /.content -->
</div><!-- /.lightbox-body -->

View File

@ -25,9 +25,12 @@
<div class="lightbox-overlay" ng-class="{'show': state.account.open}">
<div class="lightbox lightbox-effect" ng-include="'tpl/account.html'"></div>
</div>
<div class="lightbox-overlay" ng-class="{'show': state.setPassphrase.open}">
<div class="lightbox lightbox-effect" ng-include="'tpl/set-passphrase.html'"></div>
</div>
<div class="lightbox-overlay" ng-class="{'show': state.contacts.open}">
<div class="lightbox lightbox-effect" ng-include="'tpl/contacts.html'"></div>
</div>
<div class="lightbox-overlay" ng-class="{'show': state.dialog.open}">
<div class="lightbox lightbox-effect view-dialog" ng-include="'tpl/dialog.html'"></div>
<div class="lightbox lightbox-effect dialog view-dialog" ng-include="'tpl/dialog.html'"></div>
</div>

View File

@ -39,6 +39,5 @@
<div class="popover-content">
<p>A passphrase is like a password that protects your PGP key.</p>
<p>If your device is lost or stolen the passphrase protects the contents of your mailbox.</p>
<p>You cannot change your passphrase at a later time.</p>
</div>
</div><!--/.popover-->

View File

@ -40,7 +40,7 @@
<div class="arrow"></div>
<div class="popover-title"><b>What is this?</b></div>
<div class="popover-content">
<p>The passphrase protects your encrypted mailbox.</p>
<p>A passphrase is like a password that protects your PGP key.</p>
<p>There is no way to access your messages without your passphrase.</p>
<p>If you have forgotten your passphrase, please request an account reset by sending an email to <b>support@whiteout.io</b>. You will not be able to read previous messages after a reset.</p>
</div>

View File

@ -0,0 +1,33 @@
<div class="lightbox-body" ng-controller="SetPassphraseCtrl">
<header>
<h2>Set passphrase</h2>
<button class="close" ng-click="state.setPassphrase.toggle(false)" data-action="lightbox-close">&#xe007;</button>
</header>
<div class="content">
<div class="dialog view-set-passphrase">
<table>
<tbody>
<tr>
<td><label>Current passphrase</label></td>
<td><input class="input-text" type="password" ng-model="oldPassphrase" ng-change="checkPassphraseQuality()" tabindex="1" focus-me="true"></td>
</tr>
<tr>
<td><label>New passphrase</label></td>
<td><input class="input-text" type="password" ng-model="newPassphrase" ng-change="checkPassphraseQuality()" tabindex="2"></td>
</tr>
<tr>
<td><label>Confirm passphrase</label></td>
<td><input class="input-text" type="password" ng-model="confirmation" ng-class="{'input-text-error': (confirmation || newPassphrase) && confirmation !== newPassphrase}" tabindex="3"></td>
</tr>
</tbody>
</table>
<div class="control">
<button ng-click="setPassphrase()" class="btn" ng-disabled="(confirmation || newPassphrase) && confirmation !== newPassphrase" tabindex="4">Set passphrase</button>
</div>
</div><!-- /.view-set-passphrase -->
</div><!-- /.content -->
</div><!-- /.lightbox-body -->

View File

@ -5,23 +5,20 @@ define(function(require) {
angular = require('angular'),
mocks = require('angularMocks'),
AccountCtrl = require('js/controller/account'),
EmailDAO = require('js/dao/email-dao'),
PGP = require('js/crypto/pgp'),
dl = require('js/util/download'),
appController = require('js/app-controller');
appController = require('js/app-controller'),
KeychainDAO = require('js/dao/keychain-dao');
describe('Account Controller unit test', function() {
var scope, accountCtrl, origEmailDao, emailDaoMock,
var scope, accountCtrl,
dummyFingerprint, expectedFingerprint,
dummyKeyId, expectedKeyId,
emailAddress,
keySize,
cryptoMock;
emailAddress, keySize, cryptoMock, keychainMock;
beforeEach(function() {
origEmailDao = appController._emailDao;
appController._emailDao = emailDaoMock = sinon.createStubInstance(EmailDAO);
emailDaoMock._crypto = cryptoMock = sinon.createStubInstance(PGP);
appController._crypto = cryptoMock = sinon.createStubInstance(PGP);
appController._keychain = keychainMock = sinon.createStubInstance(KeychainDAO);
dummyFingerprint = '3A2D39B4E1404190B8B949DE7D7E99036E712926';
expectedFingerprint = '3A2D 39B4 E140 4190 B8B9 49DE 7D7E 9903 6E71 2926';
@ -31,10 +28,18 @@ define(function(require) {
cryptoMock.getKeyId.returns(dummyKeyId);
emailAddress = 'fred@foo.com';
keySize = 1234;
emailDaoMock._account = {
emailAddress: emailAddress,
asymKeySize: keySize
appController._emailDao = {
_account: {
emailAddress: emailAddress,
asymKeySize: keySize
}
};
cryptoMock.getKeyParams.returns({
_id: dummyKeyId,
fingerprint: dummyFingerprint,
userId: emailAddress,
bitSize: keySize
});
angular.module('accounttest', []);
mocks.module('accounttest');
@ -47,10 +52,7 @@ define(function(require) {
});
});
afterEach(function() {
// restore the module
appController._emailDao = origEmailDao;
});
afterEach(function() {});
describe('scope variables', function() {
it('should be set correctly', function() {
@ -63,16 +65,22 @@ define(function(require) {
describe('export to key file', function() {
it('should work', function(done) {
var createDownloadMock = sinon.stub(dl, 'createDownload');
cryptoMock.exportKeys.yields(null, {
publicKeyArmored: 'a',
privateKeyArmored: 'b',
keyId: dummyKeyId
keychainMock.getUserKeyPair.withArgs(emailAddress).yields(null, {
publicKey: {
_id: dummyKeyId,
publicKey: 'a'
},
privateKey: {
encryptedKey: 'b'
}
});
createDownloadMock.withArgs(sinon.match(function(arg) {
return arg.content === 'ab' && arg.filename === 'whiteout_mail_' + emailAddress + '_' + expectedKeyId + '.asc' && arg.contentType === 'text/plain';
})).yields();
scope.onError = function() {
expect(cryptoMock.exportKeys.calledOnce).to.be.true;
scope.onError = function(err) {
expect(err.title).to.equal('Success');
expect(scope.state.account.open).to.be.false;
expect(keychainMock.getUserKeyPair.calledOnce).to.be.true;
expect(dl.createDownload.calledOnce).to.be.true;
dl.createDownload.restore();
done();
@ -82,9 +90,10 @@ define(function(require) {
});
it('should not work when key export failed', function(done) {
cryptoMock.exportKeys.yields(new Error('asdasd'));
scope.onError = function() {
expect(cryptoMock.exportKeys.calledOnce).to.be.true;
keychainMock.getUserKeyPair.yields(new Error('Boom!'));
scope.onError = function(err) {
expect(err.message).to.equal('Boom!');
expect(keychainMock.getUserKeyPair.calledOnce).to.be.true;
done();
};
@ -93,14 +102,19 @@ define(function(require) {
it('should not work when create download failed', function(done) {
var createDownloadMock = sinon.stub(dl, 'createDownload');
cryptoMock.exportKeys.yields(null, {
publicKeyArmored: 'a',
privateKeyArmored: 'b',
keyId: dummyKeyId
keychainMock.getUserKeyPair.withArgs(emailAddress).yields(null, {
publicKey: {
_id: dummyKeyId,
publicKey: 'a'
},
privateKey: {
encryptedKey: 'b'
}
});
createDownloadMock.withArgs().yields(new Error('asdasd'));
scope.onError = function() {
expect(cryptoMock.exportKeys.calledOnce).to.be.true;
scope.onError = function(err) {
expect(err.message).to.equal('asdasd');
expect(keychainMock.getUserKeyPair.calledOnce).to.be.true;
expect(dl.createDownload.calledOnce).to.be.true;
dl.createDownload.restore();
done();

View File

@ -42,6 +42,7 @@ function startTests() {
'test/new-unit/dialog-ctrl-test',
'test/new-unit/add-account-ctrl-test',
'test/new-unit/account-ctrl-test',
'test/new-unit/set-passphrase-ctrl-test',
'test/new-unit/contacts-ctrl-test',
'test/new-unit/login-existing-ctrl-test',
'test/new-unit/login-initial-ctrl-test',

View File

@ -140,6 +140,30 @@ define(function(require) {
});
});
});
it('should fail when passphrases are equal', function(done) {
pgp.changePassphrase({
privateKeyArmored: privkey,
oldPassphrase: passphrase,
newPassphrase: passphrase
}, function(err, reEncryptedKey) {
expect(err).to.exist;
expect(reEncryptedKey).to.not.exist;
done();
});
});
it('should fail when old passphrase is incorrect', function(done) {
pgp.changePassphrase({
privateKeyArmored: privkey,
oldPassphrase: 'asd',
newPassphrase: 'yxcv'
}, function(err, reEncryptedKey) {
expect(err).to.exist;
expect(reEncryptedKey).to.not.exist;
done();
});
});
});
describe('Encrypt/Sign/Decrypt/Verify', function() {
@ -193,6 +217,15 @@ define(function(require) {
expect(params.userId).to.equal("whiteout.test@t-online.de");
expect(params.algorithm).to.equal("rsa_encrypt_sign");
});
it('should work without param', function() {
var params = pgp.getKeyParams();
expect(params.fingerprint).to.equal('5856CEF789C3A307E8A1B976F6F60E9B42CDFF4C');
expect(params._id).to.equal("F6F60E9B42CDFF4C");
expect(params.bitSize).to.equal(keySize);
expect(params.userId).to.equal("whiteout.test@t-online.de");
expect(params.algorithm).to.equal("rsa_encrypt_sign");
});
});
describe('Encrypt and sign', function() {

View File

@ -0,0 +1,81 @@
define(function(require) {
'use strict';
var expect = chai.expect,
angular = require('angular'),
mocks = require('angularMocks'),
SetPassphraseCtrl = require('js/controller/set-passphrase'),
PGP = require('js/crypto/pgp'),
appController = require('js/app-controller'),
KeychainDAO = require('js/dao/keychain-dao');
describe('Set Passphrase Controller unit test', function() {
var scope, setPassphraseCtrl,
dummyFingerprint, expectedFingerprint,
dummyKeyId, expectedKeyId,
emailAddress, keySize, cryptoMock, keychainMock;
beforeEach(function() {
appController._crypto = cryptoMock = sinon.createStubInstance(PGP);
appController._keychain = keychainMock = sinon.createStubInstance(KeychainDAO);
dummyFingerprint = '3A2D39B4E1404190B8B949DE7D7E99036E712926';
expectedFingerprint = '3A2D 39B4 E140 4190 B8B9 49DE 7D7E 9903 6E71 2926';
dummyKeyId = '9FEB47936E712926';
expectedKeyId = '6E712926';
cryptoMock.getFingerprint.returns(dummyFingerprint);
cryptoMock.getKeyId.returns(dummyKeyId);
emailAddress = 'fred@foo.com';
keySize = 1234;
cryptoMock.getKeyParams.returns({
_id: dummyKeyId,
fingerprint: dummyFingerprint,
userId: emailAddress,
bitSize: keySize
});
angular.module('setpassphrasetest', []);
mocks.module('setpassphrasetest');
mocks.inject(function($rootScope, $controller) {
scope = $rootScope.$new();
scope.state = {};
setPassphraseCtrl = $controller(SetPassphraseCtrl, {
$scope: scope
});
});
});
afterEach(function() {});
describe('setPassphrase', function() {
it('should work', function(done) {
scope.oldPassphrase = 'old';
scope.newPassphrase = 'new';
keychainMock.lookupPrivateKey.withArgs(dummyKeyId).yields(null, {
encryptedKey: 'encrypted'
});
cryptoMock.changePassphrase.withArgs({
privateKeyArmored: 'encrypted',
oldPassphrase: 'old',
newPassphrase: 'new'
}).yields(null, 'newArmoredKey');
keychainMock.saveLocalPrivateKey.withArgs({
_id: dummyKeyId,
userId: emailAddress,
encryptedKey: 'newArmoredKey'
}).yields();
scope.onError = function(err) {
expect(err.title).to.equal('Success');
done();
};
scope.setPassphrase();
});
});
});
});