diff --git a/locales/en.json b/locales/en.json index a5599c4..4e88879 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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" } diff --git a/src/app/index.js b/src/app/index.js index 7ce71c8..b0c59f6 100644 --- a/src/app/index.js +++ b/src/app/index.js @@ -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)); diff --git a/src/email/email.js b/src/email/email.js index 8b8d4d7..4115c3c 100644 --- a/src/email/email.js +++ b/src/email/email.js @@ -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 }); diff --git a/src/email/templates.js b/src/email/templates.js index 0accfa0..3462f41 100644 --- a/src/email/templates.js +++ b/src/email/templates.js @@ -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}; diff --git a/src/route/rest.js b/src/route/rest.js index 04f51ed..4bf1fba 100644 --- a/src/route/rest.js +++ b/src/route/rest.js @@ -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 here.`; + ctx.status = 201; + } + /** * Request public key removal via http DELETE * @param {Object} ctx The koa request/response context diff --git a/src/service/pgp.js b/src/service/pgp.js index 7036eb3..0ebbf3b 100644 --- a/src/service/pgp.js +++ b/src/service/pgp.js @@ -125,7 +125,7 @@ class PGP { * @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 diff --git a/src/service/public-key.js b/src/service/public-key.js index 865febc..2895e8a 100644 --- a/src/service/public-key.js +++ b/src/service/public-key.js @@ -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}); } } diff --git a/src/service/util.js b/src/service/util.js index a79179a..3f38a52 100644 --- a/src/service/util.js +++ b/src/service/util.js @@ -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 diff --git a/src/static/js/verify-cert.js b/src/static/js/verify-cert.js new file mode 100644 index 0000000..a191c0a --- /dev/null +++ b/src/static/js/verify-cert.js @@ -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)); diff --git a/src/view/verify-certs.html b/src/view/verify-certs.html new file mode 100755 index 0000000..6f81939 --- /dev/null +++ b/src/view/verify-certs.html @@ -0,0 +1,48 @@ + +