[WO-185] implement contacts ui to import external public keys

This commit is contained in:
Tankred Hase 2014-03-06 18:19:51 +01:00
parent 6c8773827a
commit a8c9984524
21 changed files with 501 additions and 43 deletions

View File

@ -7,6 +7,7 @@ requirejs([
'js/controller/popover',
'js/controller/add-account',
'js/controller/account',
'js/controller/contacts',
'js/controller/login',
'js/controller/login-initial',
'js/controller/login-new-device',
@ -24,6 +25,7 @@ requirejs([
PopoverCtrl,
AddAccountCtrl,
AccountCtrl,
ContactsCtrl,
LoginCtrl,
LoginInitialCtrl,
LoginNewDeviceCtrl,
@ -32,14 +34,25 @@ requirejs([
ReadCtrl,
WriteCtrl,
NavigationCtrl,
util) {
util
) {
'use strict';
// reset window.name
window.name = util.UUID();
// init main angular module including dependencies
var app = angular.module('mail', ['ngRoute', 'ngTouch', 'navigation', 'mail-list', 'write', 'read', 'login-new-device', 'popover']);
var app = angular.module('mail', [
'ngRoute',
'ngTouch',
'navigation',
'mail-list',
'write',
'read',
'contacts',
'login-new-device',
'popover'
]);
// set router paths
app.config(function($routeProvider) {
@ -77,6 +90,7 @@ requirejs([
app.controller('WriteCtrl', WriteCtrl);
app.controller('MailListCtrl', MailListCtrl);
app.controller('AccountCtrl', AccountCtrl);
app.controller('ContactsCtrl', ContactsCtrl);
app.controller('DialogCtrl', DialogCtrl);
app.controller('PopoverCtrl', PopoverCtrl);

View File

@ -0,0 +1,124 @@
define(function(require) {
'use strict';
var angular = require('angular'),
_ = require('underscore'),
appController = require('js/app-controller'),
keychain, pgp;
//
// Controller
//
var ContactsCtrl = function($scope) {
keychain = appController._keychain,
pgp = appController._crypto;
$scope.state.contacts = {
open: false,
toggle: function(to) {
this.open = to;
$scope.listKeys();
}
};
// set default value so that the popover height is correct on init
$scope.fingerprint = 'XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX';
//
// scope functions
//
$scope.listKeys = function() {
keychain.listLocalPublicKeys(function(err, keys) {
if (err) {
$scope.onError(err);
return;
}
keys.forEach(addParams);
$scope.keys = keys;
$scope.$apply();
function addParams(key) {
var params = pgp.getKeyParams(key.publicKey);
_.extend(key, params);
}
});
};
$scope.getFingerprint = function(key) {
var fpr = key.fingerprint;
var formatted = 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.fingerprint = formatted;
};
$scope.importKey = function(publicKeyArmored) {
var keyParams = pgp.getKeyParams(publicKeyArmored);
var pubkey = {
_id: keyParams._id,
userId: keyParams.userId,
publicKey: publicKeyArmored
};
keychain.saveLocalPublicKey(pubkey, function(err) {
if (err) {
$scope.onError(err);
return;
}
// update displayed keys
$scope.listKeys();
});
};
$scope.removeKey = function(key) {
keychain.removeLocalPublicKey(key._id, function(err) {
if (err) {
$scope.onError(err);
return;
}
// update displayed keys
$scope.listKeys();
});
};
};
//
// Directives
//
var ngModule = angular.module('contacts', []);
ngModule.directive('keyfileInput', function() {
return function(scope, elm) {
elm.on('change', function(e) {
for (var i = 0; i < e.target.files.length; i++) {
importKey(e.target.files.item(i));
}
});
function importKey(file) {
var reader = new FileReader();
reader.onload = function(e) {
scope.importKey(e.target.result);
};
reader.readAsText(file);
}
};
});
ngModule.directive('keyfileBtn', function() {
return function(scope, elm) {
elm.on('click touchstart', function(e) {
e.preventDefault();
document.querySelector('#keyfile-input').click();
});
};
});
return ContactsCtrl;
});

View File

@ -172,6 +172,11 @@ define(function(require) {
e.preventDefault();
scope.state.account.toggle(false);
} else if (e.keyCode === 27 && scope.state.contacts.open) {
// escape -> close contacts view
e.preventDefault();
scope.state.contacts.toggle(false);
} else if (e.keyCode === 27 && scope.state.nav.open) {
// escape -> close nav view
e.preventDefault();

View File

@ -68,13 +68,8 @@ define(function(require) {
return fingerprint(this._publicKey);
};
PGP.prototype.getUserId = function(keyArmored) {
var key = openpgp.key.readArmored(keyArmored).keys[0];
return key.getUserIds()[0];
};
/**
* Show a user's key id
* Show a user's key id.
*/
PGP.prototype.getKeyId = function(keyArmored) {
var key, pubKeyId, privKeyId;
@ -100,6 +95,23 @@ define(function(require) {
return pubKeyId;
};
/**
* Read all relevant params of an armored key.
*/
PGP.prototype.getKeyParams = function(keyArmored) {
var key = openpgp.key.readArmored(keyArmored).keys[0],
packet = key.getKeyPacket();
return {
_id: packet.getKeyId().toHex().toUpperCase(),
userId: key.getUserIds()[0].split('<')[1].split('>')[0],
fingerprint: util.hexstrdump(packet.getFingerprint()).toUpperCase(),
algorithm: packet.algorithm,
bitSize: packet.getBitSize(),
created: packet.created,
};
};
/**
* Import the user's key pair
*/

View File

@ -156,8 +156,8 @@ define(function(require) {
}
// check if the key's user ID matches the current account
pubUserID = self._crypto.getUserId(keypair.publicKey.publicKey);
privUserID = self._crypto.getUserId(keypair.privateKey.encryptedKey);
pubUserID = self._crypto.getKeyParams(keypair.publicKey.publicKey).userId;
privUserID = self._crypto.getKeyParams(keypair.privateKey.encryptedKey).userId;
if (pubUserID.indexOf(self._account.emailAddress) === -1 || privUserID.indexOf(self._account.emailAddress) === -1) {
callback({
errMsg: 'User IDs dont match!'
@ -1342,4 +1342,4 @@ define(function(require) {
};
return EmailDAO;
});
});

View File

@ -256,31 +256,43 @@ define(function(require) {
return;
}
if (!pubkey) {
// fetch from cloud storage
self._publicKeyDao.get(id, function(err, cloudPubkey) {
if (pubkey) {
callback(null, pubkey);
return;
}
// fetch from cloud storage
self._publicKeyDao.get(id, function(err, cloudPubkey) {
if (err) {
callback(err);
return;
}
// cache public key in cache
self.saveLocalPublicKey(cloudPubkey, function(err) {
if (err) {
callback(err);
return;
}
// cache public key in cache
self.saveLocalPublicKey(cloudPubkey, function(err) {
if (err) {
callback(err);
return;
}
callback(null, cloudPubkey);
});
callback(null, cloudPubkey);
});
} else {
callback(null, pubkey);
}
});
});
};
/**
* List all the locally stored public keys
*/
KeychainDAO.prototype.listLocalPublicKeys = function(callback) {
// search local keyring for public key
this._localDbDao.list('publickey', 0, null, callback);
};
KeychainDAO.prototype.removeLocalPublicKey = function(id, callback) {
this._localDbDao.remove('publickey_' + id, callback);
};
KeychainDAO.prototype.lookupPrivateKey = function(id, callback) {
// lookup in local storage
this._localDbDao.read('privatekey_' + id, callback);

View File

@ -24,6 +24,7 @@
@import "views/shared";
@import "views/add-account";
@import "views/account";
@import "views/contacts";
@import "views/dialog";
@import "views/navigation";
@import "views/mail-list";

View File

@ -20,6 +20,7 @@
transition: background-color 0.3s;
text-decoration: none;
font-weight: normal;
outline: 0;
&:hover,
&:focus {
@ -28,7 +29,6 @@
&:active,
&.active {
outline: 0;
background-image: none;
box-shadow: none;
top: 1px;

View File

@ -0,0 +1,42 @@
.view-contacts {
.key-controls {
margin: 30px;
input[type=text] {
line-height: 23px;
}
input[type=file] {
visibility: hidden;
width: 0;
height: 0;
}
}
.key-list {
max-height: 400px;
margin: 20px;
overflow-y: scroll;
table {
th, td {
padding: 5px 10px;
}
.hover {
cursor: pointer;
}
button.remove {
font-family: $font-family-icons;
font-size: 0.75em;
color: $color-grey-input;
border: none;
background: none;
outline: none;
}
}
}
}

View File

@ -79,6 +79,8 @@
margin-top: em(50, 16);
padding: 0 $nav-padding;
li {
margin-bottom: 0.25em;
a {
font-size: $font-size-big;
}

View File

@ -6,7 +6,7 @@
<div class="content">
<div class="view-account">
<table summary="Kontakt">
<table>
<tbody>
<tr>
<td>Email</td>

45
src/tpl/contacts.html Normal file
View File

@ -0,0 +1,45 @@
<div class="lightbox-body" ng-controller="ContactsCtrl">
<header>
<h2>Contacts</h2>
<button class="close" ng-click="state.contacts.toggle(false)" data-action="lightbox-close">&#xe007;</button>
</header>
<div class="content">
<div class="view-contacts">
<div class="key-controls">
<input class="input-text" type="text" placeholder="Search..." ng-model="searchText">
<span>
<input id="keyfile-input" type="file" multiple accept=".asc" keyfile-input>
<button class="btn" ng-class="{'btn-primary': sendBtnSecure === false}" keyfile-btn>Import public keys</button>
</span>
</div>
<div class="key-list">
<table>
<tr>
<th>Key ID</th>
<th>Email</th>
<th>Created</th>
<th>Size</th>
</tr>
<tr ng-repeat="key in keys | orderBy:'userId' | filter:searchText">
<td class="hover" ng-mouseover="getFingerprint(key)" popover="#fingerprint-contact">{{key._id.slice(8)}}</td>
<td>{{key.userId}}</td>
<td>{{key.created | date:'mediumDate'}}</td>
<td>{{key.bitSize}} bit</td>
<td><button class="remove" ng-click="removeKey(key)">&#xe007;</button></td>
</tr>
</table>
</div>
</div><!-- /.view-contacts -->
</div><!-- /.content -->
<!-- popovers -->
<div id="fingerprint-contact" class="popover right" ng-controller="PopoverCtrl">
<div class="popover-title"><b>Fingerprint</b></div>
<div class="popover-content">{{fingerprint}}</div>
</div><!--/.popover-->
</div><!-- /.lightbox-body -->

View File

@ -25,6 +25,9 @@
<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.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>

View File

@ -8,7 +8,7 @@
<form>
<div>
<input type="file" file-reader tabindex="1">
<input type="file" accept=".asc" file-reader tabindex="1">
<span class="popover-info" data-icon-append="&#xe010;" popover="#keyfile-info"></span>
</div>
<div>

View File

@ -16,7 +16,8 @@
<ul class="nav-secondary">
<li><a href="#" ng-click="state.account.toggle(true); $event.preventDefault()">Account</a></li>
<li><a href="http://whiteout.io" target="_blank">About whiteout.io</a></li>
<li><a href="#" ng-click="state.contacts.toggle(true); $event.preventDefault()">Contacts</a></li>
<li><a href="http://whiteout.io" target="_blank">About</a></li>
</ul>
<footer>

View File

@ -20,10 +20,8 @@ define(function(require) {
beforeEach(function() {
origEmailDao = appController._emailDao;
cryptoMock = sinon.createStubInstance(PGP);
emailDaoMock = sinon.createStubInstance(EmailDAO);
emailDaoMock._crypto = cryptoMock;
appController._emailDao = emailDaoMock;
appController._emailDao = emailDaoMock = sinon.createStubInstance(EmailDAO);
emailDaoMock._crypto = cryptoMock = sinon.createStubInstance(PGP);
dummyFingerprint = '3A2D39B4E1404190B8B949DE7D7E99036E712926';
expectedFingerprint = '3A2D 39B4 E140 4190 B8B9 49DE 7D7E 9903 6E71 2926';

View File

@ -0,0 +1,168 @@
define(function(require) {
'use strict';
var expect = chai.expect,
angular = require('angular'),
mocks = require('angularMocks'),
ContactsCtrl = require('js/controller/contacts'),
appController = require('js/app-controller'),
KeychainDAO = require('js/dao/keychain-dao'),
PGP = require('js/crypto/pgp');
describe('Contacts Controller unit test', function() {
var scope, contactsCtrl,
origKeychain, keychainMock,
origCrypto, cryptoMock;
beforeEach(function() {
origCrypto = appController._crypto;
appController._crypto = cryptoMock = sinon.createStubInstance(PGP);
origKeychain = appController._keychain;
appController._keychain = keychainMock = sinon.createStubInstance(KeychainDAO);
angular.module('contactstest', []);
mocks.module('contactstest');
mocks.inject(function($rootScope, $controller) {
scope = $rootScope.$new();
scope.state = {};
contactsCtrl = $controller(ContactsCtrl, {
$scope: scope
});
});
});
afterEach(function() {
// restore the module
appController._crypto = origCrypto;
appController._keychain = origKeychain;
});
describe('scope variables', function() {
it('should be set correctly', function() {
expect(scope.fingerprint).to.equal('XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX');
expect(scope.state.contacts.open).to.be.false;
expect(scope.state.contacts.toggle).to.exist;
});
});
describe('listKeys', function() {
it('should fail due to error in keychain.listLocalPublicKeys', function(done) {
keychainMock.listLocalPublicKeys.yields(42);
scope.onError = function(err) {
expect(err).to.equal(42);
done();
};
scope.listKeys();
});
it('should work', function(done) {
keychainMock.listLocalPublicKeys.yields(null, [{
_id: '12345'
}]);
cryptoMock.getKeyParams.returns({
fingerprint: 'asdf'
});
scope.$apply = function() {
expect(scope.keys.length).to.equal(1);
expect(scope.keys[0]._id).to.equal('12345');
expect(scope.keys[0].fingerprint).to.equal('asdf');
done();
};
expect(scope.keys).to.not.exist;
scope.listKeys();
});
});
describe('getFingerprint', function() {
it('should work', function() {
var key = {
fingerprint: 'YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY'
};
scope.getFingerprint(key);
expect(scope.fingerprint).to.equal('YYYY YYYY YYYY YYYY YYYY ... YYYY YYYY YYYY YYYY YYYY');
});
});
describe('importKey', function() {
it('should work', function(done) {
var keyArmored = 'ARMORED PUBLICKEY';
cryptoMock.getKeyParams.returns({
_id: '12345',
userId: 'max@example.com'
});
keychainMock.saveLocalPublicKey.withArgs({
_id: '12345',
userId: 'max@example.com',
publicKey: 'ARMORED PUBLICKEY'
}).yields();
scope.listKeys = function() {
done();
};
scope.importKey(keyArmored);
});
it('should fail due to error in keychain.saveLocalPublicKey', function(done) {
var keyArmored = 'ARMORED PUBLICKEY';
cryptoMock.getKeyParams.returns({
_id: '12345',
userId: 'max@example.com'
});
keychainMock.saveLocalPublicKey.withArgs({
_id: '12345',
userId: 'max@example.com',
publicKey: 'ARMORED PUBLICKEY'
}).yields(42);
scope.onError = function(err) {
expect(err).to.equal(42);
done();
};
scope.importKey(keyArmored);
});
});
describe('removeKey', function() {
it('should work', function(done) {
var key = {
_id: '12345'
};
keychainMock.removeLocalPublicKey.withArgs('12345').yields();
scope.listKeys = function() {
done();
};
scope.removeKey(key);
});
it('should fail due to error in keychain.removeLocalPublicKey', function(done) {
var key = {
_id: '12345'
};
keychainMock.removeLocalPublicKey.withArgs('12345').yields(42);
scope.onError = function(err) {
expect(err).to.equal(42);
done();
};
scope.removeKey(key);
});
});
});
});

View File

@ -354,7 +354,9 @@ define(function(require) {
_pgpbuilder: {}
};
pgpStub.getUserId.returns('Whiteout User <' + emailAddress + '>');
pgpStub.getKeyParams.returns({
userId: emailAddress
});
pgpStub.importKeys.withArgs({
passphrase: passphrase,

View File

@ -32,6 +32,30 @@ define(function(require) {
});
});
describe('listLocalPublicKeys', function() {
it('should work', function(done) {
lawnchairDaoStub.list.withArgs('publickey', 0, null).yields();
keychainDao.listLocalPublicKeys(function() {
expect(lawnchairDaoStub.list.callCount).to.equal(1);
done();
});
});
});
describe('removeLocalPublicKey', function() {
it('should work', function(done) {
var id = 'asdf';
lawnchairDaoStub.remove.withArgs('publickey_' + id).yields();
keychainDao.removeLocalPublicKey(id, function() {
expect(lawnchairDaoStub.remove.callCount).to.equal(1);
done();
});
});
});
describe('lookup public key', function() {
it('should fail', function(done) {
keychainDao.lookupPublicKey(undefined, function(err, key) {

View File

@ -39,6 +39,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/contacts-ctrl-test',
'test/new-unit/login-existing-ctrl-test',
'test/new-unit/login-initial-ctrl-test',
'test/new-unit/login-new-device-ctrl-test',

View File

@ -150,13 +150,6 @@ define(function(require) {
});
});
describe('Get UserId', function() {
it('should work with param', function() {
var userId = pgp.getUserId(pubkey);
expect(userId).to.contain(user);
});
});
describe('Get Fingerprint', function() {
it('should work without param', function() {
var fingerprint = pgp.getFingerprint();
@ -169,6 +162,17 @@ define(function(require) {
});
});
describe('getKeyParams', function() {
it('should work with param', function() {
var params = pgp.getKeyParams(pubkey);
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() {
it('should fail', function(done) {
var input = null;