Merge branch 'master' into feature-chroot

feature-chroot
Simon Vareille 2020-06-13 11:30:09 +02:00
commit 9011ac93b7
No known key found for this signature in database
GPG Key ID: 008AE8E706CC19F9
10 changed files with 420 additions and 22 deletions

View File

@ -6,5 +6,7 @@
"verify_success_link": "Your public OpenPGP key is now available at the following link:",
"verify_removal_subject": "Verify key removal",
"verify_removal_text": "Hello {0},\n\nplease verify removal of your email address {1} from our key server ({2}) by clicking on the following link:\n\n{3}\n\nGreetings from the Mailvelope Team",
"removal_success": "Email address {0} removed from the key directory"
"removal_success": "Email address {0} removed from the key directory",
"check_signatures_subject": "Confirm new signatures",
"check_signatures_text": "Hello {0},\n\n{1} new signature(s) have been uploaded to your keys on our keyserver ({3}) ! Please select the ones you want to add by clicking on the following link:\n\n{2}\n\nGreetings from the Mailvelope Team"
}

View File

@ -62,7 +62,7 @@ router.get('/manage.html', ctx => ctx.render('manage'));
// HKP and REST api routes
router.post('/pks/add', ctx => hkp.add(ctx));
router.get('/pks/lookup', ctx => hkp.lookup(ctx));
router.post('/api/v1/key', ctx => rest.create(ctx));
router.post('/api/v1/key', ctx => rest.postHandler(ctx));
router.get('/api/v1/key', ctx => rest.query(ctx));
router.del('/api/v1/key', ctx => rest.remove(ctx));

View File

@ -53,12 +53,13 @@ class Email {
* @param {Object} template the email template function to use
* @param {Object} userId recipient user id object: { name:'Jon Smith', email:'j@smith.com', publicKeyArmored:'...' }
* @param {string} keyId key id of public key
* @param {Object} data data used by template
* @param {Object} origin origin of the server
* @yield {Object} reponse object containing SMTP info
*/
async send({template, userId, keyId, origin, publicKeyArmored}) {
async send({template, userId, keyId, data, origin, publicKeyArmored}) {
const compiled = template({
...userId,
...data,
origin,
keyId
});

View File

@ -18,4 +18,12 @@ function verifyRemove(ctx, {name, email, nonce, origin, keyId}) {
};
}
module.exports = {verifyKey, verifyRemove};
function checkNewSigs(ctx, {name, sigsNb, nonce, origin, keyId}) {
const link = `${util.url(origin)}/api/v1/key?op=checkSignatures&keyId=${keyId}&nonce=${nonce}`;
return {
subject: ctx.__('check_signatures_subject'),
text: ctx.__('check_signatures_text', [name, sigsNb, link, origin.host])
};
}
module.exports = {verifyKey, verifyRemove, checkNewSigs};

View File

@ -32,13 +32,26 @@ class REST {
constructor(publicKey) {
this._publicKey = publicKey;
}
/**
* http POST handler
* @param {Object} ctx The koa request/response context
*/
async postHandler(ctx) {
const json = await parse.json(ctx, {limit: '1mb'});
if(json.op === 'confirmSignatures')
return this[json.op](ctx, json);//delegate operation
await this.create(ctx, json);
}
/**
* Public key / user ID upload via http POST
* @param {Object} ctx The koa request/response context
* @param {Object} json The json content of the request
*/
async create(ctx) {
const {emails, publicKeyArmored} = await parse.json(ctx, {limit: '1mb'});
async create(ctx, json) {
const {emails, publicKeyArmored} = json || await parse.json(ctx, {limit: '1mb'});
if (!publicKeyArmored) {
ctx.throw(400, 'Invalid request!');
}
@ -54,7 +67,8 @@ class REST {
*/
async query(ctx) {
const op = ctx.query.op;
if (op === 'verify' || op === 'verifyRemove') {
if (op === 'verify' || op === 'verifyRemove' || op === 'confirmSignatures' ||
op === 'checkSignatures') {
return this[op](ctx); // delegate operation
}
// do READ if no 'op' provided
@ -79,7 +93,38 @@ class REST {
const link = util.url(util.origin(ctx), `/pks/lookup?op=get&search=${email}`);
await ctx.render('verify-success', {email, link});
}
/**
* Check public key's signatures via http GET
* @param {Object} ctx The koa request/response context
*/
async checkSignatures(ctx) {
const q = {keyId: ctx.query.keyId, nonce: ctx.query.nonce};
if (!util.isKeyId(q.keyId) || !util.isString(q.nonce)) {
ctx.throw(400, 'Invalid request!');
}
const sigs = await this._publicKey.getPendingSignatures(q, ctx);
// create link for confirmation
const link = util.url(util.origin(ctx), `/api/v1/key`);
await ctx.render('verify-certs', {keyId: q.keyId, link, nonce: q.nonce, sigs});
}
/**
* Confirm public key's signatures via http POST
* @param {Object} ctx The koa request/response context
* @param {Object} json The json content of the request
*/
async confirmSignatures(ctx, json) {
const post = json || await parse.json(ctx, {limit: '1mb'});
const q = {keyId: post.keyId, nonce: post.nonce, sigs: post.sig};
const {email} = await this._publicKey.verifySignatures(q, util.origin(ctx), ctx);
// create link for sharing
const link = util.url(util.origin(ctx), `/pks/lookup?op=get&search=${email}`);
ctx.body = `Update successful. You can find your key <a href="${link}" target="_blank">here</a>.`;
ctx.status = 201;
}
/**
* Request public key removal via http DELETE
* @param {Object} ctx The koa request/response context

View File

@ -68,7 +68,7 @@ class PGP {
}
// check for at least one valid user id
const userIds = await this.parseUserIds(key.users, primaryKey, verifyDate);
const {userIds, status} = await this.parseUserIds(key.users, primaryKey, verifyDate);
if (!userIds.length) {
if (status == 1) {
util.throw(400, 'Invalid PGP key: no user ID comes from a valid organisation');
@ -122,10 +122,10 @@ class PGP {
/**
* Parse an array of user ids and verify signatures
* @param {Array} users A list of openpgp.js user objects
* @param {Array} users A list of openpgp.js user objects
* @param {Object} primaryKey The primary key packet of the key
* @param {Date} verifyDate Verify user IDs at this point in time
* @return {Array, integer} An array of user id objects and a satus indicator.
* @return {Array, integer} An array of user id objects and a satus indicator
* Values of status : 0 if no error, 1 if no address comes from a specific organisation.
*/
async parseUserIds(users, primaryKey, verifyDate = new Date()) {
@ -174,12 +174,59 @@ class PGP {
key.users = key.users.filter(({userId}) => !userId || emails.includes(util.normalizeEmail(userId.email)));
return key.armor();
}
/**
* Remove signatures from source armored key which are not in compared armored key
* @param {String} srcArmored armored key block to be filtered
* @param {String} cmpArmored armored key block to be compared with
* @return {String, newSigs} filtered {armored key block, list of new signatures}
*/
async filterKeyBySignatures(srcArmored, cmpArmored) {
const {keys: [srcKey], err: srcErr} = await openpgp.key.readArmored(srcArmored);
if (srcErr) {
log.error('pgp', 'Failed to parse source PGP key:\n%s', srcArmored, srcErr);
util.throw(500, 'Failed to parse PGP key');
}
const {keys: [cmpKey], err: cmpErr} = await openpgp.key.readArmored(cmpArmored);
if (cmpErr) {
log.error('pgp', 'Failed to parse destination PGP key:\n%s', cmpArmored, cmpErr);
util.throw(500, 'Failed to parse PGP key');
}
const newSigs=[];
if(cmpKey.hasSameFingerprintAs(srcKey)) {
await Promise.all(srcKey.users.map(async srcUser => {
await Promise.all(cmpKey.users.map(async dstUser => {
if ((srcUser.userId && dstUser.userId &&
(srcUser.userId.userid === dstUser.userId.userid)) ||
(srcUser.userAttribute && (srcUser.userAttribute.equals(dstUser.userAttribute)))) {
const source = srcUser.otherCertifications;
const dest = dstUser.otherCertifications;
for(let i = source.length-1; i >= 0; i--) {
const sourceSig = source[i];
if (!sourceSig.isExpired() && !dest.some(function(destSig) {
return util.equalsUint8Array(destSig.signature, sourceSig.signature);
})) {
// list new signatures
let userId = (srcUser.userId) ? srcUser.userId.userid : null;
let userAttribute = (srcUser.userAttribute) ? srcUser.userAttribute : null;
newSigs.push({user: {userId: userId, userAttribute: userAttribute}, signature: Buffer.from(sourceSig.write()).toString('base64')});
// do not add new signatures
source.splice(i, 1);
}
}
}
}));
}));
}
return {armored: srcKey.armor(), newSigs: newSigs};
}
/**
* Merge (update) armored key blocks
* Merge (update) armored key blocks without adding new signatures
* @param {String} srcArmored source amored key block
* @param {String} dstArmored destination armored key block
* @return {String} merged armored key block
* @return {String} merged amored key block
*/
async updateKey(srcArmored, dstArmored) {
const {keys: [srcKey], err: srcErr} = await openpgp.key.readArmored(srcArmored);
@ -191,11 +238,63 @@ class PGP {
if (dstErr) {
log.error('pgp', 'Failed to parse destination PGP key for update:\n%s', dstArmored, dstErr);
util.throw(500, 'Failed to parse PGP key');
}
}
await dstKey.update(srcKey);
return dstKey.armor();
}
/**
* Add new signature to key
* @param {String} publicKeyArmored source amored key block
* @param {Object} signature signature to add
* @return {String} updated armored key block
*/
async addSignature(publicKeyArmored, {user, signature}) {
const {keys: [key], err: srcErr} = await openpgp.key.readArmored(publicKeyArmored);
const signaturePacket = await this.getSignatureFromBase64(signature);
if (srcErr) {
log.error('pgp', 'Failed to parse source PGP key for update:\n%s', publicKeyArmored, srcErr);
util.throw(500, 'Failed to parse PGP key');
}
for(const srcUser of key.users) {
if((srcUser.userId && user.userId === srcUser.userId.userid) ||
(user.userAttribute && user.userAttribute === srcUser.userAttribute)) {
if(!srcUser.otherCertifications.some(certSig => util.equalsUint8Array(certSig.signature, signaturePacket.signature))) {
srcUser.otherCertifications.push(signaturePacket);
}
}
}
return key.armor();
}
/**
* Get openpgp.packet.Signature object from base64 encoded signature
* @param {String} signature base64 encoded signature
* @return {openpgp.packet.Signature} Signature object
*/
async getSignatureFromBase64(signature) {
const signaturePacket = new openpgp.packet.Signature();
signaturePacket.read(new Uint8Array(Buffer.from(signature, 'base64')));
return signaturePacket;
}
/**
* Returns primary user and most significant (latest valid) self signature
* - if multiple primary users exist, returns the one with the latest self signature
* - otherwise, returns the user with the latest self signature
* @return {Object} The primary userId
*/
async getPrimaryUser(publicKeyArmored) {
const {keys: [key], err: srcErr} = await openpgp.key.readArmored(publicKeyArmored);
if (srcErr) {
log.error('pgp', 'Failed to parse PGP key for getPrimaryUser:\n%s', publicKeyArmored, srcErr);
util.throw(500, 'Failed to parse PGP key');
}
const primaryUser = await key.getPrimaryUser();
return primaryUser;
}
/**
* Remove user ID from armored key block
* @param {String} email email of user ID to be removed

View File

@ -20,6 +20,7 @@
const config = require('config');
const util = require('./util');
const tpl = require('../email/templates');
const crypto = require('crypto');
/**
* Database documents have the format:
@ -88,11 +89,28 @@ class PublicKey {
if (verified) {
key.userIds = await this._mergeUsers(verified.userIds, key.userIds, key.publicKeyArmored);
// reduce new key to verified user IDs
const filteredPublicKeyArmored = await this._pgp.filterKeyByUserIds(key.userIds.filter(({verified}) => verified), key.publicKeyArmored);
let filteredPublicKeyArmored = await this._pgp.filterKeyByUserIds(key.userIds.filter(({verified}) => verified), key.publicKeyArmored);
// reduce new key to verified signatures and get new signatures
const {armored, newSigs} = await this._pgp.filterKeyBySignatures(filteredPublicKeyArmored, verified.publicKeyArmored);
filteredPublicKeyArmored = armored;
// update verified key with new key
key.publicKeyArmored = await this._pgp.updateKey(verified.publicKeyArmored, filteredPublicKeyArmored);
// store pending signatures in key and generate nounce for confirmation
if(newSigs.length) {
if(!verified.pendingSignatures)
key.pendingSignatures = {sigs: newSigs, nonce: util.random()};
else {
key.pendingSignatures = verified.pendingSignatures;
key.pendingSignatures.sigs = verified.pendingSignatures.sigs.concat(newSigs.filter(sourceSig => !verified.pendingSignatures.sigs.some(function(pendingSig) {
return pendingSig.signature === sourceSig.signature;
})));
}
}
// send mails to verify all user ids
await this._sendVerifyEmail(key, origin, ctx);
// send mail to confirm all new signatures
await this._sendNewSigsEmail(key, origin, ctx);
// store key in database
await this._persistKey(key);
} else {
@ -169,7 +187,7 @@ class PublicKey {
_includeEmail(users, user) {
return users.find(({email}) => email === user.email);
}
/**
* Send verification emails to the public keys user ids for verification.
* If a primary email address is provided only one email will be sent.
@ -183,7 +201,7 @@ class PublicKey {
if (userId.notify && userId.notify === true) {
// generate nonce for verification
userId.nonce = util.random();
await this._email.send({template: tpl.verifyKey.bind(null, ctx), userId, keyId, origin, publicKeyArmored: userId.publicKeyArmored});
await this._email.send({template: tpl.verifyKey.bind(null, ctx), userId, keyId, data: userId, origin, publicKeyArmored: userId.publicKeyArmored});
}
}
}
@ -201,14 +219,30 @@ class PublicKey {
if (userId.notify && userId.notify === true && util.isFromOrganisation(userId.email)) {
// generate nonce for verification
userId.nonce = util.random();
await this._email.send({template: tpl.verifyKey.bind(null, ctx), userId, keyId, origin, publicKeyArmored: userId.publicKeyArmored});
await this._email.send({template: tpl.verifyKey.bind(null, ctx), userId, keyId, data: userId, origin, publicKeyArmored: userId.publicKeyArmored});
}
}
}
/**
* Send email to the public key's primary user ids for confirmation
* of new signatures addition.
* @param {Object} key key documents containg all the needed data
* @param {Object} origin the server's origin (required for email links)
* @param {Object} ctx Context
* @return {Promise}
*/
async _sendNewSigsEmail(key, origin, ctx) {
if(key.pendingSignatures && key.pendingSignatures.sigs.length){
let primaryUser = await this._pgp.getPrimaryUser(key.publicKeyArmored);
const userId = primaryUser.user.userId;
await this._email.send({template: tpl.checkNewSigs.bind(null, ctx), userId, keyId: key.keyId, data: {name: userId.name, sigsNb: key.pendingSignatures.sigs.length, nonce: key.pendingSignatures.nonce}, origin, publicKeyArmored: key.publicKeyArmored});
}
}
/**
* Persist the public key and its user ids in the database.
* @param {Object} key public key parameters
* @param {Object} key public key parameters
* @return {Promise}
*/
async _persistKey(key) {
@ -291,6 +325,46 @@ class PublicKey {
}, DB_TYPE);
return {email};
}
/**
* Verify signatures by proving knowledge of the nonce.
* @param {string} keyId Correspronding public key id
* @param {string} nonce The verification nonce proving email address ownership
* @param {Array} sigs The list of signatures to verify
* @param {Object} origin The server's origin (required for email links)
* @param {Object} ctx Context
* @return {Promise} The email that has been verified
*/
async verifySignatures({keyId, nonce, sigs}, origin, ctx) {
// look for verification nonce in database
const query = {keyId, 'pendingSignatures.nonce': nonce};
const key = await this._mongo.get(query, DB_TYPE);
if (!key) {
util.throw(404, 'Signatures not found on key');
}
let publicKeyArmored = key.publicKeyArmored;
for(const {user, signature} of key.pendingSignatures.sigs) {
// update armored key
let hash = crypto.createHash('md5');
hash.update(signature, 'base64');
hash = hash.digest('hex');
if(sigs.includes(hash)) {
publicKeyArmored = await this._pgp.addSignature(key.publicKeyArmored, {user, signature});
publicKeyArmored = await this._pgp.updateKey(key.publicKeyArmored, publicKeyArmored);
}
}
key.pendingSignatures = null;
await this._mongo.update(query, {
publicKeyArmored,
'pendingSignatures': null
}, DB_TYPE);
const email = (await this._pgp.getPrimaryUser(publicKeyArmored)).user.userId.email;
return {email};
}
/**
* Removes keys with the same email address
@ -368,9 +442,62 @@ class PublicKey {
email: uid.email,
verified: uid.verified
}));
if(key.pendingSignatures)
delete key.pendingSignatures.nonce
return key;
}
/**
* Fetch all pending signatures of a public key from the database. Either the
* key fingerprint, id or the email address muss be provided.
* @param {string} keyId Correspronding public key id
* @param {string} nonce The verification nonce proving legitimity of the request
* @param {Object} ctx Context
* @return {Map} The list of userId and associated signatures
*/
async getPendingSignatures({fingerprint, keyId, email, nonce}, ctx) {
// look for verified key
const userIds = email ? [{email}] : undefined;
const key = await this.getVerified({keyId, fingerprint, userIds});
if (!key) {
util.throw(404, ctx.__('key_not_found'));
}
if(!key.pendingSignatures)
util.throw(404, "No pending signatures");
if(key.pendingSignatures.nonce != nonce)
util.throw(403, "Invalid nonce");
const signatures = new Map();
for(const {user, signature} of key.pendingSignatures.sigs) {
const signedUserID = user.userId;
let hash = crypto.createHash('md5');
hash.update(signature, 'base64');
hash = hash.digest('hex')
const signaturePacket = await this._pgp.getSignatureFromBase64(signature);
const fingerprint = Buffer.from(signaturePacket.issuerFingerprint).toString('HEX');
const verified = await this.getVerified({fingerprint: fingerprint});
const issuerUID = (verified)? await this._pgp.getPrimaryUser(verified.publicKeyArmored): "[unknown identity]";
const sig = {issuerFingerprint: fingerprint,
created: signaturePacket.created.toDateString(),
userId: issuerUID,
hash: hash
};
if(!signatures.has(signedUserID)) {
signatures.set(signedUserID, []);
}
signatures.get(signedUserID).push(sig);
}
return signatures;
}
/**
* Request removal of the public key by flagging all user ids and sending
* a verification email to the primary email address. Only one email
@ -391,7 +518,7 @@ class PublicKey {
// send verification mails
keyId = key.keyId; // get keyId in case request was by email
for (const userId of key.userIds) {
await this._email.send({template: tpl.verifyRemove.bind(null, ctx), userId, keyId, origin});
await this._email.send({template: tpl.verifyRemove.bind(null, ctx), userId, keyId, data: userId, origin});
}
}

View File

@ -104,6 +104,27 @@ exports.normalizeEmail = function(email) {
return email;
};
/**
* Check Uint8Array equality
* @param {Uint8Array} first array
* @param {Uint8Array} second array
* @returns {Boolean} equality
*/
exports.equalsUint8Array = function (array1, array2) {
try {
if (array1.length !== array2.length) {
return false;
}
for (let i = 0; i < array1.length; i++) {
if (array1[i] !== array2[i]) {
return false;
}
}
} catch (e) {return false;}
return true;
};
/**
* Create an error with a custom status attribute e.g. for http codes.
* @param {number} status The error's http status code

View File

@ -0,0 +1,47 @@
/* eslint-disable */
;(function($) {
'use strict';
// POST signatures form
$('#signatures form').submit(function(e) {
e.preventDefault();
$('#signatures .alert').addClass('hidden');
var elements = $('#signatures form')[0];
var obj = {sig: []};
for(var elem of elements){
switch(elem.name) {
case "op":
case "keyId":
case "nonce":
obj[elem.name] = elem.value;
break;
case "sig":
if(elem.checked)
obj["sig"].push(elem.value);
break;
}
}
$.ajax({
method: 'POST',
url: '/api/v1/key',
data: JSON.stringify(obj),
contentType: 'application/json',
}).done(function(data, textStatus, xhr) {
if (xhr.status === 304) {
alert('signatures', 'danger', 'Key already exists!');
} else {
alert('signatures', 'success', xhr.responseText);
}
})
.fail(function(xhr) {
alert('signatures', 'danger', xhr.responseText);
});
});
function alert(region, outcome, text) {
$('#' + region + ' .alert-' + outcome + ' span').html(text);
$('#' + region + ' .alert-' + outcome).removeClass('hidden');
}
}(jQuery));

48
src/view/verify-certs.html Executable file
View File

@ -0,0 +1,48 @@
<style type="text/css">
.uid { color: green; text-decoration: underline; }
.warn { color: red; font-weight: bold; }
</style>
<div class="container">
<div class="header clearfix">
<nav>
<ul class="nav nav-pills pull-right">
<li role="presentation"><a href="/">Home</a></li>
<li role="presentation"><a href="/manage.html">Manage Keys</a></li>
<li role="presentation"><a href="https://github.com/mailvelope/keyserver" target="_blank">GitHub</a></li>
</ul>
</nav>
<h3 class="text-muted">Mailvelope Key Server</h3>
</div>
<div class="row marketing">
<div id="signatures" class="col-lg-12">
<h2>Select the signatures you want to add to your key</h2>
<div class="alert alert-success hidden" role="alert">
<strong>Success!</strong> <span></span>
</div>
<div class="alert alert-danger hidden" role="alert">
<strong>Error!</strong> <span></span>
</div>
<form id="sigForm" action="/api/v1/key" method="post"> <!-- <%= link %> -->
<hr /><pre><strong>pub</strong> <a href="/pks/lookup?op=get&search=0x<%= keyId %>">0x<%= keyId %></a><input type="hidden" id="op" name="op" value="confirmSignatures"><input type="hidden" id="keyId" name="keyId" value="<%= keyId %>"><input type="hidden" id="nonce" name="nonce" value="<%= nonce %>">
<% for(const [userid, signatures] of sigs) { %>
<strong>uid</strong> <span class="uid"><%= userid %></span>
<% for(sig of signatures) { %>
&emsp;<input type="checkbox" id="<%= sig.hash %>" name="sig" value="<%= sig.hash %>"> <label for="<%= sig.hash %>"> <a href="/pks/lookup?op=get&search=0x<%= sig.issuerFingerprint %>">0x<%= sig.issuerFingerprint %></a> <%= sig.created %> <%= sig.userId %></label><br>
<% } %>
<% } %>
</pre>
<input class="btn btn-primary btn-lg" type="submit" value="Confirm selected signatures (and delete unselected ones)">
</form>
</div> <!-- /col-lg-12 -->
</div> <!-- /row marketing -->
<%- include('footer') %>
</div> <!-- /container -->
<script src="/js/jquery.min.js"></script>
<script src="/js/verify-certs.js"></script>