diff --git a/locales/en.json b/locales/en.json index a5599c4..6f30ed6 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", + "confirm_signatures_subject": "Confirm new signatures", + "confirm_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/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..87f5217 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 confirmNewSigs(ctx, {name, sigsNb, nonce, origin, keyId}) { + const link = `${util.url(origin)}/api/v1/key?op=confirmSignatures&keyId=${keyId}&nonce=${nonce}`; + return { + subject: ctx.__('confirm_signatures_subject'), + text: ctx.__('confirm_signatures_text', [name, sigsNb, link, origin.host]) + }; +} + +module.exports = {verifyKey, verifyRemove, confirmNewSigs}; diff --git a/src/service/pgp.js b/src/service/pgp.js index 7368b76..8bb15d7 100644 --- a/src/service/pgp.js +++ b/src/service/pgp.js @@ -124,7 +124,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()) { @@ -175,10 +175,10 @@ class PGP { } /** - * 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, newSigs} merged armored key block, list of new signatures */ async updateKey(srcArmored, dstArmored) { const {keys: [srcKey], err: srcErr} = await openpgp.key.readArmored(srcArmored); @@ -191,10 +191,47 @@ class PGP { log.error('pgp', 'Failed to parse destination PGP key for update:\n%s', dstArmored, dstErr); util.throw(500, 'Failed to parse PGP key'); } + + // list new signatures + const newSigs=[]; + if(dstKey.hasSameFingerprintAs(srcKey)) { + const source = srcKey.directSignatures; + const dest = dstKey.directSignatures; + if(source) { + for(const sourceSig of source) { + if (!sourceSig.isExpired() && !dest.some(function(destSig) { + return util.equalsUint8Array(destSig.signature, sourceSig.signature); + })) { + newSigs.push(sourceSig); + } + } + } + // do not add new signatures + source = source.filter(sourceSig => !newSigs.some(function(sig) { + return util.equalsUint8Array(sig.signature, sourceSig.signature); + })); + } + await dstKey.update(srcKey); - return dstKey.armor(); + return {armored: dstKey.armor(), newSigs: newSigs}; } - + + /** + * 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 object: { name, email} + */ + 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 = key.getPrimaryUser(); + return {name: primaryUser.name, email: primaryUser.email}; + } + /** * 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 de39e10..c272d44 100644 --- a/src/service/public-key.js +++ b/src/service/public-key.js @@ -89,10 +89,21 @@ class PublicKey { 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); - // update verified key with new key - key.publicKeyArmored = await this._pgp.updateKey(verified.publicKeyArmored, filteredPublicKeyArmored); + // update verified key with new key and get new signatures + let newSigs = []; + {armored: key.publicKeyArmored, newSigs: newSigs} = await this._pgp.updateKey(verified.publicKeyArmored, filteredPublicKeyArmored); + // store pending signatures in key and generate nounce for confirmation + if(!key.pendingSignatures) + key.pendingSignatures = {sigs: newSigs, nonce: util.random()}; + else { + key.pendingSignatures = key.pendingSignatures.concat(newSigs.filter(sourceSig => !key.pendingSignatures.some(function(sig) { + return util.equalsUint8Array(sig.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 { @@ -175,7 +186,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, userId, origin, publicKeyArmored: userId.publicKeyArmored}); } } } @@ -193,14 +204,29 @@ 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, userId, origin, publicKeyArmored: userId.publicKeyArmored}); } } } + + /** + * Send confirmation email to the public keys 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.sigs.length){ + const primaryUser = this._pgp.getPrimaryUser(key.publicKeyArmored); + await this._email.send({template: tpl.confirmNewSigs.bind(null, ctx), primaryUser, key.keyId, {name: primaryUser.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) { @@ -271,7 +297,7 @@ class PublicKey { let {publicKeyArmored, email} = key.userIds.find(userId => userId.nonce === nonce); // update armored key if (key.publicKeyArmored) { - publicKeyArmored = await this._pgp.updateKey(key.publicKeyArmored, publicKeyArmored); + publicKeyArmored = await this._pgp.updateKey(key.publicKeyArmored, publicKeyArmored).armored; } // flag the user id as verified @@ -383,7 +409,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, userId, origin}); } } diff --git a/src/service/util.js b/src/service/util.js index 8ac7076..2304699 100644 --- a/src/service/util.js +++ b/src/service/util.js @@ -36,7 +36,29 @@ exports.isString = function(data) { exports.isTrue = function(data) { if (this.isString(data)) { return data === 'true'; - } else { + } else /** + * Check Uint8Array equality + * @param {Uint8Array} first array + * @param {Uint8Array} second array + * @returns {Boolean} equality + */ + equalsUint8Array: function (array1, array2) { + if (!util.isUint8Array(array1) || !util.isUint8Array(array2)) { + throw new Error('Data must be in the form of a Uint8Array'); + } + + if (array1.length !== array2.length) { + return false; + } + + for (let i = 0; i < array1.length; i++) { + if (array1[i] !== array2[i]) { + return false; + } + } + return true; + }, +{ return Boolean(data); } }; @@ -103,6 +125,28 @@ 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