diff --git a/Core/source/assets/compat/mime-email-encrypted-inline-text-signed.txt b/Core/source/assets/compat/mime-email-encrypted-inline-text-signed.txt new file mode 100644 index 000000000..739a49e1c --- /dev/null +++ b/Core/source/assets/compat/mime-email-encrypted-inline-text-signed.txt @@ -0,0 +1,33 @@ +Delivered-To: flowcrypt.compatibility@gmail.com +Return-Path: +Openpgp: id=E76853E128A0D376CAE47C143A30F4CC0A9A8F10 +From: flowcrypt.compatibility@gmail.com +MIME-Version: 1.0 +Date: Thu, 2 Nov 2017 17:54:14 -0700 +Message-ID: +Subject: mime email encrypted inline text signed +To: flowcrypt.compatibility@gmail.com +Content-Type: text/plain; charset="UTF-8" + +-----BEGIN PGP MESSAGE----- +Version: FlowCrypt [BUILD_REPLACEABLE_VERSION] Gmail Encryption +Comment: Seamlessly send and receive encrypted email + +wcBMAwurnAGLJl0iAQf/Unn+k8dkJ35DM+jJPCqFYMyP9WY8J/2g4Kf5yvlR +7jarIe89D+Uu6nGJwKZXzQ/aLn1FQT6Y3ty+RxCB2hSNzkcrydE9vO0hGJde +0uK3cljgtiBTW1qDAKJAAD8y3i6CRrs4+mJacJCJ7XoQsZqvHzN/ywJ0/XDW +FEWINXtqrt7IyfIy9LbP4LsqifUZOX2SMQX5i8JMTO/85xJrgFpH429XvreG +BAD2NdxAA0zTuUKuJtdJGmRBN+aU/rRZTdu5sF8PfDtVBGmPTYuhIiCSCoGe +KwkxejyfkAPmeUqjiWXSUX0Tlulmfuogo0ofgtizhF5kww3IbE3tiH6fzIxU +RtLAsgFhC00k2WTu1jzunu6BCoHkhAghVAN8M3mF112e6wmeiS4y3cGGTAPi +g+C4cd223NzpiRc/BvauONUaL6LLJIcIC65eErU5Ii25X8AEXio7Juc9JT0Z +pwvAnVqOuxxIV+WCAi0QHerEPDNz5pl9uyiGwPSd8ecnr50zgiqLoT9RjzFs +PJJjijjkBV1fH72NBlJD5XwRNbRJZKGFonV1swZbd01Ki2D2ENQvgM+NN+r0 +Tdw/oauD+YfIxyc/mde3mF7ZDbBGmmATwvH1JyVURJKpXxT0SBKjkdeUYVL+ +wIxo5zvz8RNMbfz3AB9gH/VnlNdwLirFGgKe3feoBIuOH60KxrIOOO/zJhMs +eHHCiaIT9+73ML1murgnMJi0rhzopQp4q2CdkzrUZce8+NubyhMWyur6cA4p +adZoSa5urtIYvoDnFreNYpUPu6VGV2RBTTj6NPJjoGoCw3rSRNpzbHY1KnBf +O2iCZQZx7IYfqBeoJlI= +=ADUd +-----END PGP MESSAGE----- + diff --git a/Core/source/assets/compat/mime-email-plain-signed-detached.txt b/Core/source/assets/compat/mime-email-plain-signed-detached.txt new file mode 100644 index 000000000..e856127d0 --- /dev/null +++ b/Core/source/assets/compat/mime-email-plain-signed-detached.txt @@ -0,0 +1,53 @@ +Return-Path: +X-Original-To: t@est.com +Delivered-To: t@est.com +Received: from [192.168.56.1] (unknown [192.168.56.1]) + by est.com (Postfix) with ESMTPS id E08F2103ED0 + for ; Tue, 12 Oct 2021 23:06:02 +0000 (UTC) +To: t@est.com +From: Test User +Subject: mime email plain signed detached +Message-ID: <920dc165-7ba2-3f36-3177-a8e76bae4ddd@est.com> +Date: Wed, 13 Oct 2021 02:06:02 +0300 +User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 + Thunderbird/78.13.0 +MIME-Version: 1.0 +Content-Type: multipart/signed; micalg=pgp-sha256; + protocol="application/pgp-signature"; + boundary="1C2MFssN1e2UeK7Q7Fok4Ko9VNn7xQryG" + +This is an OpenPGP/MIME signed message (RFC 4880 and 3156) +--1C2MFssN1e2UeK7Q7Fok4Ko9VNn7xQryG +Content-Type: multipart/mixed; boundary="zB40xvQ1bEP0WaDoW1b6PiXYa0sf6aLHw"; + protected-headers="v1" +From: Test User +To: t@est.com +Message-ID: <920dc165-7ba2-3f36-3177-a8e76bae4ddd@est.com> +Subject: mime email plain signed detached + +--zB40xvQ1bEP0WaDoW1b6PiXYa0sf6aLHw +Content-Type: text/plain; charset=utf-8; format=flowed +Content-Transfer-Encoding: base64 +Content-Language: en-US + +c29tZQrmsYkKdHh0Cgo= + +--zB40xvQ1bEP0WaDoW1b6PiXYa0sf6aLHw-- + +--1C2MFssN1e2UeK7Q7Fok4Ko9VNn7xQryG +Content-Type: application/pgp-signature; name="OpenPGP_signature.asc" +Content-Description: OpenPGP digital signature +Content-Disposition: attachment; filename="OpenPGP_signature" + +-----BEGIN PGP SIGNATURE----- + +wsB5BAABCAAjFiEE52hT4Sig03bK5HwUOjD0zAqajxAFAmFmFNoFAwAAAAAACgkQOjD0zAqajxBM +dgf7BDgUTDQ4AvhUdyJ8xMtWpzRKCWz1tp2ErqciQCamXJxOkoBMlcY8mrohYgbNtOvabKguknWT +NUpzgcnU1DbtL/wB43tJNfAmquLhl3dw5TBPieg7lImZSNAyS5H0Uxtuxze0nVrdgCgVoyaPuC/7 +PBoMMUM1LLrnP5LJM9d10Hku6BqsgYgI2GDx7MFiWkUIHWFcSLaZk+Ttc7Dpau40bpuJDxW5Cd1p +OrBkuuuSSlYVQkZB8ABZ2T/OT+H06kGuvN5SV9UDTuSHi+Uk355bzWJau5hEQHXoT+O4TskJlJAG ++VkotOZYUtGyPWdwKsun1/w8w2xc9GCMjnb2owFXdQ== +=bMPl +-----END PGP SIGNATURE----- + +--1C2MFssN1e2UeK7Q7Fok4Ko9VNn7xQryG-- diff --git a/Core/source/assets/compat/mime-email-plain-signed-edited.txt b/Core/source/assets/compat/mime-email-plain-signed-edited.txt new file mode 100644 index 000000000..a1a58d969 --- /dev/null +++ b/Core/source/assets/compat/mime-email-plain-signed-edited.txt @@ -0,0 +1,32 @@ +Delivered-To: flowcrypt.compatibility@gmail.com +Return-Path: +Openpgp: id=E76853E128A0D376CAE47C143A30F4CC0A9A8F10 +From: flowcrypt.compatibility@gmail.com +MIME-Version: 1.0 +Date: Thu, 2 Nov 2017 17:54:14 -0700 +Message-ID: +Subject: mime email plain signed +To: flowcrypt.compatibility@gmail.com +Content-Type: text/plain; charset="UTF-8" + + +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +some +汉 +TXT +-----BEGIN PGP SIGNATURE----- +Version: FlowCrypt [BUILD_REPLACEABLE_VERSION] Gmail Encryption +Comment: Seamlessly send and receive encrypted email + +wsBcBAEBCAAGBQJhY1x0AAoJEDow9MwKmo8QMR0H/iHkHFr1doJhqIVsSXyQ +bBwYpoaIRHEZlF3RQa3sbkLqXSTzHf81ySb7/oXgd+z5RYYw2vD9bebaInb3 +Ca9FuBrM4ZlnmVVnNpv/nntZZdln+AVq+rEOaLDcHC4nFTC3Z7QwulZn31Hm +b0JcsGAuIDfUZ1mFfFpyCq7KB+XGbV3bc+bUBO0pjKfbsEISBqJxuCzJIr+0 +pIwMSdluQXxtyyqT3aXnoH9jl75dBDDvMEPR3c/I2+k9SWWoYslwhn2TqB94 +RFgwWpw/SH8+1b3lI1AWnkVpkkm8JOnteViHCz85ALd9xkACjq8UavPGLy0v +J6XRgsV1E2RTmydFB6vv/9w= +=quFc +-----END PGP SIGNATURE----- + diff --git a/Core/source/assets/compat/mime-email-plain-signed.txt b/Core/source/assets/compat/mime-email-plain-signed.txt new file mode 100644 index 000000000..0adfa0371 --- /dev/null +++ b/Core/source/assets/compat/mime-email-plain-signed.txt @@ -0,0 +1,32 @@ +Delivered-To: flowcrypt.compatibility@gmail.com +Return-Path: +Openpgp: id=E76853E128A0D376CAE47C143A30F4CC0A9A8F10 +From: flowcrypt.compatibility@gmail.com +MIME-Version: 1.0 +Date: Thu, 2 Nov 2017 17:54:14 -0700 +Message-ID: +Subject: mime email plain signed +To: flowcrypt.compatibility@gmail.com +Content-Type: text/plain; charset="UTF-8" + + +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +some +汉 +txt +-----BEGIN PGP SIGNATURE----- +Version: FlowCrypt [BUILD_REPLACEABLE_VERSION] Gmail Encryption +Comment: Seamlessly send and receive encrypted email + +wsBcBAEBCAAGBQJhY1x0AAoJEDow9MwKmo8QMR0H/iHkHFr1doJhqIVsSXyQ +bBwYpoaIRHEZlF3RQa3sbkLqXSTzHf81ySb7/oXgd+z5RYYw2vD9bebaInb3 +Ca9FuBrM4ZlnmVVnNpv/nntZZdln+AVq+rEOaLDcHC4nFTC3Z7QwulZn31Hm +b0JcsGAuIDfUZ1mFfFpyCq7KB+XGbV3bc+bUBO0pjKfbsEISBqJxuCzJIr+0 +pIwMSdluQXxtyyqT3aXnoH9jl75dBDDvMEPR3c/I2+k9SWWoYslwhn2TqB94 +RFgwWpw/SH8+1b3lI1AWnkVpkkm8JOnteViHCz85ALd9xkACjq8UavPGLy0v +J6XRgsV1E2RTmydFB6vv/9w= +=quFc +-----END PGP SIGNATURE----- + diff --git a/Core/source/core/msg-block-parser.ts b/Core/source/core/msg-block-parser.ts index aeef335d9..8249a9ef2 100644 --- a/Core/source/core/msg-block-parser.ts +++ b/Core/source/core/msg-block-parser.ts @@ -10,7 +10,7 @@ import { Catch } from '../platform/catch.js'; import { Mime } from './mime.js'; import { PgpArmor } from './pgp-armor.js'; import { PgpKey } from './pgp-key.js'; -import { PgpMsg } from './pgp-msg.js'; +import { PgpMsg, VerifyRes } from './pgp-msg.js'; import { Str } from './common.js'; type SanitizedBlocks = { blocks: MsgBlock[], subject: string | undefined, isRichText: boolean }; @@ -40,7 +40,7 @@ export class MsgBlockParser { } } - public static fmtDecryptedAsSanitizedHtmlBlocks = async (decryptedContent: Uint8Array, imgHandling: SanitizeImgHandling = 'IMG-TO-LINK'): Promise => { + public static fmtDecryptedAsSanitizedHtmlBlocks = async (decryptedContent: Uint8Array, signature?: VerifyRes, imgHandling: SanitizeImgHandling = 'IMG-TO-LINK'): Promise => { const blocks: MsgBlock[] = []; let isRichText = false; if (!Mime.resemblesMsg(decryptedContent)) { @@ -49,24 +49,34 @@ export class MsgBlockParser { utf = PgpMsg.stripFcTeplyToken(utf); const armoredPubKeys: string[] = []; utf = PgpMsg.stripPublicKeys(utf, armoredPubKeys); - blocks.push(MsgBlock.fromContent('decryptedHtml', Str.asEscapedHtml(utf))); // escaped text as html + const block = MsgBlock.fromContent('decryptedHtml', Str.asEscapedHtml(utf)); + block.verifyRes = signature; + blocks.push(block); // escaped text as html await MsgBlockParser.pushArmoredPubkeysToBlocks(armoredPubKeys, blocks); return { blocks, subject: undefined, isRichText }; } const decoded = await Mime.decode(decryptedContent); if (typeof decoded.html !== 'undefined') { - blocks.push(MsgBlock.fromContent('decryptedHtml', Xss.htmlSanitizeKeepBasicTags(decoded.html, imgHandling))); // sanitized html + const block = MsgBlock.fromContent('decryptedHtml', Xss.htmlSanitizeKeepBasicTags(decoded.html, imgHandling)); + block.verifyRes = signature; + blocks.push(block); // sanitized html isRichText = true; } else if (typeof decoded.text !== 'undefined') { - blocks.push(MsgBlock.fromContent('decryptedHtml', Str.asEscapedHtml(decoded.text))); // escaped text as html + const block = MsgBlock.fromContent('decryptedHtml', Str.asEscapedHtml(decoded.text)); + block.verifyRes = signature; + blocks.push(block); // escaped text as html } else { - blocks.push(MsgBlock.fromContent('decryptedHtml', Str.asEscapedHtml(Buf.with(decryptedContent).toUtfStr()))); // escaped mime text as html + const block = MsgBlock.fromContent('decryptedHtml', Str.asEscapedHtml(Buf.with(decryptedContent).toUtfStr())); + block.verifyRes = signature; + blocks.push(); // escaped mime text as html } for (const att of decoded.atts) { if (att.treatAs() === 'publicKey') { await MsgBlockParser.pushArmoredPubkeysToBlocks([att.getData().toUtfStr()], blocks); } else { - blocks.push(MsgBlock.fromAtt('decryptedAtt', '', { name: att.name, data: att.getData(), length: att.length, type: att.type })); + const block = MsgBlock.fromAtt('decryptedAtt', '', { name: att.name, data: att.getData(), length: att.length, type: att.type }); + block.verifyRes = signature; + blocks.push(block); } } return { blocks, subject: decoded.subject, isRichText }; diff --git a/Core/source/core/pgp-msg.ts b/Core/source/core/pgp-msg.ts index 40c67b6d7..abf0ee86d 100644 --- a/Core/source/core/pgp-msg.ts +++ b/Core/source/core/pgp-msg.ts @@ -2,7 +2,7 @@ 'use strict'; -import { Contact, KeyInfo, PgpKey, PrvKeyInfo } from './pgp-key.js'; +import { KeyInfo, PgpKey, PrvKeyInfo } from './pgp-key.js'; import { MsgBlock, MsgBlockType } from './msg-block.js'; import { Str, Value } from './common.js'; @@ -19,9 +19,9 @@ export namespace PgpMsgMethod { export namespace Arg { export type Encrypt = { pubkeys: string[], signingPrv?: OpenPGP.key.Key, pwd?: string, data: Uint8Array, filename?: string, armor: boolean, date?: Date }; export type Type = { data: Uint8Array }; - export type Decrypt = { kisWithPp: PrvKeyInfo[], encryptedData: Uint8Array, msgPwd?: string }; + export type Decrypt = { kisWithPp: PrvKeyInfo[], encryptedData: Uint8Array, msgPwd?: string, verificationPubkeys?: string[] }; export type DiagnosePubkeys = { privateKis: KeyInfo[], message: Uint8Array }; - export type VerifyDetached = { plaintext: Uint8Array, sigText: Uint8Array }; + export type VerifyDetached = { plaintext: Uint8Array, sigText: Uint8Array, verificationPubkeys?: string[] }; } export type DiagnosePubkeys = (arg: Arg.DiagnosePubkeys) => Promise; export type VerifyDetached = (arg: Arg.VerifyDetached) => Promise; @@ -31,7 +31,6 @@ export namespace PgpMsgMethod { } type SortedKeysForDecrypt = { - verificationContacts: Contact[]; forVerification: OpenPGP.key.Key[]; encryptedFor: string[]; signedBy: string[]; @@ -53,7 +52,13 @@ type PreparedForDecrypt = { isArmored: boolean, isCleartext: true, message: Open type OpenpgpMsgOrCleartext = OpenPGP.message.Message | OpenPGP.cleartext.CleartextMessage; -export type VerifyRes = { signer?: string; contact?: Contact; match: boolean | null; error?: string; }; +export type VerifyRes = { + signer?: string; + match: boolean | null; + error?: string; + mixed?: boolean; + partial?: boolean; +}; export type PgpMsgTypeResult = { armored: boolean, type: MsgBlockType } | undefined; export type DecryptResult = DecryptSuccess | DecryptError; export type DiagnoseMsgPubkeysResult = { found_match: boolean, receivers: number, }; @@ -123,8 +128,8 @@ export class PgpMsg { return await openpgp.stream.readToEnd((signRes as OpenPGP.SignArmorResult).data); } - public static verify = async (msgOrVerResults: OpenpgpMsgOrCleartext | OpenPGP.message.Verification[], pubs: OpenPGP.key.Key[], contact?: Contact): Promise => { - const sig: VerifyRes = { contact, match: null }; // tslint:disable-line:no-null-keyword + public static verify = async (msgOrVerResults: OpenpgpMsgOrCleartext | OpenPGP.message.Verification[], pubs: OpenPGP.key.Key[]): Promise => { + const sig: VerifyRes = { match: null }; // tslint:disable-line:no-null-keyword try { // While this looks like bad method API design, it's here to ensure execution order when 1) reading data, 2) verifying, 3) processing signatures // Else it will hang trying to read a stream: https://github.com/openpgpjs/openpgpjs/issues/916#issuecomment-510620625 @@ -152,14 +157,19 @@ export class PgpMsg { return sig; } - public static verifyDetached: PgpMsgMethod.VerifyDetached = async ({ plaintext, sigText }) => { + public static verifyDetached: PgpMsgMethod.VerifyDetached = async ({ plaintext, sigText, verificationPubkeys }) => { const message = openpgp.message.fromText(Buf.fromUint8(plaintext).toUtfStr()); await message.appendSignature(Buf.fromUint8(sigText).toUtfStr()); const keys = await PgpMsg.getSortedKeys([], message); - return await PgpMsg.verify(message, keys.forVerification, keys.verificationContacts[0]); + if (verificationPubkeys) { + for (const verificationPubkey of verificationPubkeys) { + keys.forVerification.push(...(await openpgp.key.readArmored(verificationPubkey)).keys); + } + } + return await PgpMsg.verify(message, keys.forVerification); } - public static decrypt: PgpMsgMethod.Decrypt = async ({ kisWithPp, encryptedData, msgPwd }) => { + public static decrypt: PgpMsgMethod.Decrypt = async ({ kisWithPp, encryptedData, msgPwd, verificationPubkeys }) => { let prepared: PreparedForDecrypt; const longids: DecryptError$longids = { message: [], matching: [], chosen: [], needPassphrase: [] }; try { @@ -167,14 +177,14 @@ export class PgpMsg { } catch (formatErr) { return { success: false, error: { type: DecryptErrTypes.format, message: String(formatErr) }, longids }; } - const keys = await PgpMsg.getSortedKeys(kisWithPp, prepared.message); + const keys = await PgpMsg.getSortedKeys(kisWithPp, prepared.message, verificationPubkeys); longids.message = keys.encryptedFor; longids.matching = keys.prvForDecrypt.map(ki => ki.longid); longids.chosen = keys.prvForDecryptDecrypted.map(ki => ki.longid); longids.needPassphrase = keys.prvForDecryptWithoutPassphrases.map(ki => ki.longid); const isEncrypted = !prepared.isCleartext; if (!isEncrypted) { - const signature = await PgpMsg.verify(prepared.message, keys.forVerification, keys.verificationContacts[0]); + const signature = await PgpMsg.verify(prepared.message, keys.forVerification); const text = await openpgp.stream.readToEnd(prepared.message.getText()!); return { success: true, content: Buf.fromUtfStr(text), isEncrypted, signature }; } @@ -194,10 +204,12 @@ export class PgpMsg { const passwords = msgPwd ? [msgPwd] : undefined; const privateKeys = keys.prvForDecryptDecrypted.map(ki => ki.decrypted!); const decrypted = await (prepared.message as OpenPGP.message.Message).decrypt(privateKeys, passwords, undefined, false); - await PgpMsg.cryptoMsgGetSignedBy(decrypted, keys); // we can only figure out who signed the msg once it's decrypted + // we can only figure out who signed the msg once it's decrypted + await PgpMsg.cryptoMsgGetSignedBy(decrypted, keys); + await PgpMsg.populateKeysForVerification(keys, verificationPubkeys); const verifyResults = keys.signedBy.length ? await decrypted.verify(keys.forVerification) : undefined; // verify first to prevent stream hang const content = new Buf(await openpgp.stream.readToEnd(decrypted.getLiteralData()!)); // read content second to prevent stream hang - const signature = verifyResults ? await PgpMsg.verify(verifyResults, [], keys.verificationContacts[0]) : undefined; // evaluate verify results third to prevent stream hang + const signature = verifyResults ? await PgpMsg.verify(verifyResults, []) : undefined; // evaluate verify results third to prevent stream hang if (!prepared.isCleartext && (prepared.message as OpenPGP.message.Message).packets.filterByTag(openpgp.enums.packet.symmetricallyEncrypted).length) { const noMdc = 'Security threat!\n\nMessage is missing integrity checks (MDC). The sender should update their outdated software.\n\nDisplay the message at your own risk.'; return { success: false, content, error: { type: DecryptErrTypes.noMdc, message: noMdc }, message: prepared.message, longids, isEncrypted }; @@ -299,21 +311,23 @@ export class PgpMsg { } private static cryptoMsgGetSignedBy = async (msg: OpenpgpMsgOrCleartext, keys: SortedKeysForDecrypt) => { - keys.signedBy = Value.arr.unique(await PgpKey.longids(msg.getSigningKeyIds ? msg.getSigningKeyIds() : [])); - if (keys.signedBy.length && typeof Store.dbContactGet === 'function') { - const verificationContacts = await Store.dbContactGet(undefined, keys.signedBy); - keys.verificationContacts = verificationContacts.filter(contact => contact && contact.pubkey) as Contact[]; + keys.signedBy = Value.arr.unique(await PgpKey.longids( + msg.getSigningKeyIds ? msg.getSigningKeyIds() : [])); + } + + private static populateKeysForVerification = async (keys: SortedKeysForDecrypt, + verificationPubkeys?: string[]) => { + if (typeof verificationPubkeys !== 'undefined') { keys.forVerification = []; - for (const contact of keys.verificationContacts) { - const { keys: keysForVerification } = await openpgp.key.readArmored(contact.pubkey!); + for (const verificationPubkey of verificationPubkeys) { + const { keys: keysForVerification } = await openpgp.key.readArmored(verificationPubkey); keys.forVerification.push(...keysForVerification); } } } - private static getSortedKeys = async (kiWithPp: PrvKeyInfo[], msg: OpenpgpMsgOrCleartext): Promise => { + private static getSortedKeys = async (kiWithPp: PrvKeyInfo[], msg: OpenpgpMsgOrCleartext, verificationPubkeys?: string[]): Promise => { const keys: SortedKeysForDecrypt = { - verificationContacts: [], forVerification: [], encryptedFor: [], signedBy: [], @@ -322,16 +336,21 @@ export class PgpMsg { prvForDecryptDecrypted: [], prvForDecryptWithoutPassphrases: [], }; - const encryptedForKeyids = msg instanceof openpgp.message.Message ? (msg as OpenPGP.message.Message).getEncryptionKeyIds() : []; + const encryptedForKeyids = msg instanceof openpgp.message.Message + ? (msg as OpenPGP.message.Message).getEncryptionKeyIds() + : []; keys.encryptedFor = await PgpKey.longids(encryptedForKeyids); await PgpMsg.cryptoMsgGetSignedBy(msg, keys); + await PgpMsg.populateKeysForVerification(keys, verificationPubkeys); if (keys.encryptedFor.length) { for (const ki of kiWithPp) { ki.parsed = await PgpKey.read(ki.private); // todo // this is inefficient because we are doing unnecessary parsing of all keys here - // better would be to compare to already stored KeyInfo, however KeyInfo currently only holds primary longid, not longids of subkeys - // while messages are typically encrypted for subkeys, thus we have to parse the key to get the info - // we are filtering here to avoid a significant performance issue of having to attempt decrypting with all keys simultaneously + // better would be to compare to already stored KeyInfo, however KeyInfo currently + // only holds primary longid, not longids of subkeys, while messages are typically + // encrypted for subkeys, thus we have to parse the key to get the info + // we are filtering here to avoid a significant performance issue of having + // to attempt decrypting with all keys simultaneously for (const longid of await Promise.all(ki.parsed.getKeyIds().map(({ bytes }) => PgpKey.longid(bytes)))) { if (keys.encryptedFor.includes(longid!)) { keys.prvMatching.push(ki); diff --git a/Core/source/gen-compat-assets.ts b/Core/source/gen-compat-assets.ts index 354472c88..4f5dba5e8 100644 --- a/Core/source/gen-compat-assets.ts +++ b/Core/source/gen-compat-assets.ts @@ -6,13 +6,14 @@ // @ts-ignore - it cannot figure out the types, because we don't want to install them from npm // nodejs-mobile expects it as global, but this test runs as standard Nodejs -global.openpgp = require('openpgp'); // remove it and you'll see what I mean +//global.openpgp = require('openpgp'); // remove it and you'll see what I mean import * as ava from 'ava'; -import { AvaContext, writeFile } from './test/test-utils'; +import { AvaContext, getKeypairs, writeFile } from './test/test-utils'; import { PgpMsg } from './core/pgp-msg'; import { Xss } from './platform/xss'; +import { openpgp } from './core/pgp'; const text = Buffer.from('some\n汉\ntxt'); const textSpecialChars = Buffer.from('> special & other\n> second line'); @@ -86,6 +87,25 @@ Content-Type: text/plain; charset="UTF-8" ${text.toString()} `.replace(/^\n/, '')); +// Used to generate encrypted+signed and plaintext signed emails +const mimeEmail2 = (t: AvaContext, text: Buffer | string, original?: Buffer | string) => { + const orig = original ? original.toString() + '\n\n' : ''; + return Buffer.from(` +Delivered-To: flowcrypt.compatibility@gmail.com +Return-Path: +Openpgp: id=E76853E128A0D376CAE47C143A30F4CC0A9A8F10 +From: flowcrypt.compatibility@gmail.com +MIME-Version: 1.0 +Date: Thu, 2 Nov 2017 17:54:14 -0700 +Message-ID: +Subject: ${subject(t)} +To: flowcrypt.compatibility@gmail.com +Content-Type: text/plain; charset="UTF-8" + +${orig}${text.toString()} +`.replace(/^\n/, '')); +} + const mimePgp = (t: AvaContext, text: string | Buffer) => Buffer.from(` Content-Type: multipart/mixed; boundary="PpujspXwR9sayhr0t4sBaTxoXX6dlYhLU"; protected-headers="v1" @@ -181,3 +201,23 @@ ava.default('mime-email-plain-html.txt', async t => { await write(t, plainHtmlMimeEmail(t)); t.pass(); }); + +ava.default('mime-email-encrypted-inline-text-signed.txt', async t => { + const { keys } = getKeypairs('rsa1'); + const signingPrv = (await openpgp.key.readArmored(keys[0].private)).keys[0]; + // console.log("rsa1 key fingerprint:" + signingPrv.getFingerprint().toUpperCase()); + if (!(await signingPrv.decrypt(keys[0].passphrase))) throw Error('Can\'t decrypt private key'); + const { data } = await PgpMsg.encrypt({ data: text, signingPrv: signingPrv, pubkeys, armor: true }) as OpenPGP.EncryptArmorResult; + await write(t, mimeEmail2(t, data)); + t.pass(); +}); + +ava.default('mime-email-plain-signed.txt', async t => { + const { keys } = getKeypairs('rsa1'); + const signingPrv = (await openpgp.key.readArmored(keys[0].private)).keys[0]; + if (!(await signingPrv.decrypt(keys[0].passphrase))) throw Error('Can\'t decrypt private key'); + const data = text.toString(); + const signed = await PgpMsg.sign(signingPrv, data); + await write(t, mimeEmail2(t, signed)); + t.pass(); +}); diff --git a/Core/source/mobile-interface/endpoints.ts b/Core/source/mobile-interface/endpoints.ts index d10f51587..e911307b1 100644 --- a/Core/source/mobile-interface/endpoints.ts +++ b/Core/source/mobile-interface/endpoints.ts @@ -78,7 +78,7 @@ export class Endpoints { } public parseDecryptMsg = async (uncheckedReq: any, data: Buffers): Promise => { - const { keys: kisWithPp, msgPwd, isEmail } = ValidateInput.parseDecryptMsg(uncheckedReq); + const { keys: kisWithPp, msgPwd, isEmail, verificationPubkeys } = ValidateInput.parseDecryptMsg(uncheckedReq); const rawBlocks: MsgBlock[] = []; // contains parsed, unprocessed / possibly encrypted data let rawSigned: string | undefined = undefined; let subject: string | undefined = undefined; @@ -93,17 +93,17 @@ export class Endpoints { const sequentialProcessedBlocks: MsgBlock[] = []; // contains decrypted or otherwise formatted data for (const rawBlock of rawBlocks) { if ((rawBlock.type === 'signedMsg' || rawBlock.type === 'signedHtml') && rawBlock.signature) { - const verify = await PgpMsg.verifyDetached({ sigText: Buf.fromUtfStr(rawBlock.signature), plaintext: Buf.with(rawSigned || rawBlock.content) }); + const verify = await PgpMsg.verifyDetached({ sigText: Buf.fromUtfStr(rawBlock.signature), plaintext: Buf.with(rawSigned || rawBlock.content), verificationPubkeys: verificationPubkeys }); if (rawBlock.type === 'signedHtml') { sequentialProcessedBlocks.push({ type: 'verifiedMsg', content: Xss.htmlSanitizeKeepBasicTags(rawBlock.content.toString()), verifyRes: verify, complete: true }); } else { // text sequentialProcessedBlocks.push({ type: 'verifiedMsg', content: Str.asEscapedHtml(rawBlock.content.toString()), verifyRes: verify, complete: true }); } } else if (rawBlock.type === 'encryptedMsg' || rawBlock.type === 'signedMsg') { - const decryptRes = await PgpMsg.decrypt({ kisWithPp, msgPwd, encryptedData: Buf.with(rawBlock.content) }); + const decryptRes = await PgpMsg.decrypt({ kisWithPp, msgPwd, encryptedData: Buf.with(rawBlock.content), verificationPubkeys }); if (decryptRes.success) { if (decryptRes.isEncrypted) { - const formatted = await MsgBlockParser.fmtDecryptedAsSanitizedHtmlBlocks(decryptRes.content); + const formatted = await MsgBlockParser.fmtDecryptedAsSanitizedHtmlBlocks(decryptRes.content, decryptRes.signature); sequentialProcessedBlocks.push(...formatted.blocks); subject = formatted.subject || subject; } else { @@ -124,7 +124,7 @@ export class Endpoints { } } else if (rawBlock.type === 'encryptedAtt' && rawBlock.attMeta && /^(0x)?[A-Fa-f0-9]{16,40}\.asc\.pgp$/.test(rawBlock.attMeta.name || '')) { // encrypted pubkey attached - const decryptRes = await PgpMsg.decrypt({ kisWithPp, msgPwd, encryptedData: Buf.with(rawBlock.attMeta.data || '') }); + const decryptRes = await PgpMsg.decrypt({ kisWithPp, msgPwd, encryptedData: Buf.with(rawBlock.attMeta.data || ''), verificationPubkeys }); if (decryptRes.content) { sequentialProcessedBlocks.push({ type: 'publicKey', content: decryptRes.content.toString(), complete: true }); } else { @@ -134,12 +134,15 @@ export class Endpoints { sequentialProcessedBlocks.push(rawBlock); } } + // At this point we have sequentialProcessedBlocks filled const msgContentBlocks: MsgBlock[] = []; const blocks: MsgBlock[] = []; let replyType = 'plain'; for (const block of sequentialProcessedBlocks) { // fix/adjust/format blocks before returning it over JSON if (block.content instanceof Buf) { // cannot JSON-serialize Buf - block.content = isContentBlock(block.type) ? block.content.toUtfStr() : block.content.toRawBytesStr(); + block.content = isContentBlock(block.type) + ? block.content.toUtfStr() + : block.content.toRawBytesStr(); } else if (block.attMeta && block.attMeta.data instanceof Uint8Array) { // converting to base64-encoded string instead of uint8 for JSON serilization // value actually replaced to a string, but type remains Uint8Array type set to satisfy TS @@ -185,9 +188,9 @@ export class Endpoints { return fmtRes({ text, replyType, subject }, Buf.fromUtfStr(blocks.map(b => JSON.stringify(b)).join('\n'))); } - public decryptFile = async (uncheckedReq: any, data: Buffers): Promise => { + public decryptFile = async (uncheckedReq: any, data: Buffers, verificationPubkeys?: string[]): Promise => { const { keys: kisWithPp, msgPwd } = ValidateInput.decryptFile(uncheckedReq); - const decryptedMeta = await PgpMsg.decrypt({ kisWithPp, encryptedData: Buf.concat(data), msgPwd }); + const decryptedMeta = await PgpMsg.decrypt({ kisWithPp, encryptedData: Buf.concat(data), msgPwd, verificationPubkeys }); if (!decryptedMeta.success) { decryptedMeta.message = undefined; return fmtRes(decryptedMeta); diff --git a/Core/source/mobile-interface/format-output.ts b/Core/source/mobile-interface/format-output.ts index 461c8204d..b687abfc7 100644 --- a/Core/source/mobile-interface/format-output.ts +++ b/Core/source/mobile-interface/format-output.ts @@ -8,6 +8,7 @@ import { Buf } from '../core/buf'; import { Mime } from '../core/mime'; import { Str } from '../core/common'; import { Xss } from '../platform/xss'; +import { VerifyRes } from '../core/pgp-msg'; export type Buffers = (Buf | Uint8Array)[]; export type EndpointRes = {json: string, data: Buf | Uint8Array}; @@ -71,7 +72,23 @@ export const fmtContentBlock = (allContentBlocks: MsgBlock[]): { contentBlock: M imgsAtTheBottom.push(plainImgBlock); } } + + var verifyRes: (VerifyRes | undefined) = undefined; + var mixedSignatures = false; + var signedBlockCount = 0; for (const block of contentBlocks) { + if (block.verifyRes) { + ++signedBlockCount; + if (!verifyRes) { + verifyRes = block.verifyRes; + } else if (!block.verifyRes.match) { + if (verifyRes.match) { + verifyRes = block.verifyRes; + } + } else if (verifyRes.match && block.verifyRes.signer !== verifyRes.signer) { + mixedSignatures = true; + } + } if (block.type === 'decryptedText') { msgContentAsHtml += fmtMsgContentBlockAsHtml(Str.asEscapedHtml(block.content.toString()), 'green'); msgContentAsText += block.content.toString() + '\n'; @@ -94,6 +111,16 @@ export const fmtContentBlock = (allContentBlocks: MsgBlock[]): { contentBlock: M msgContentAsText += block.content.toString() + '\n'; } } + + if (verifyRes && verifyRes.match) { + if (mixedSignatures) { + verifyRes.mixed = true; + } + if (signedBlockCount > 0 && signedBlockCount != contentBlocks.length) { + verifyRes.partial = true; + } + } + for (const inlineImg of imgsAtTheBottom.concat(Object.values(inlineImgsByCid))) { // render any images we did not insert into content, at the bottom let alt = `${inlineImg.attMeta!.name || '(unnamed image)'} - ${inlineImg.attMeta!.length! / 1024}kb`; // in current usage, as used by `endpoints.ts`: `block.attMeta!.data` actually contains base64 encoded data, not Uint8Array as the type claims @@ -101,6 +128,7 @@ export const fmtContentBlock = (allContentBlocks: MsgBlock[]): { contentBlock: M msgContentAsHtml += fmtMsgContentBlockAsHtml(inlineImgTag, 'plain'); msgContentAsText += `[image: ${alt}]\n`; } + msgContentAsHtml = ` @@ -114,7 +142,9 @@ export const fmtContentBlock = (allContentBlocks: MsgBlock[]): { contentBlock: M ${msgContentAsHtml} `; - return { contentBlock: MsgBlock.fromContent('plainHtml', msgContentAsHtml), text: msgContentAsText.trim() }; + const contentBlock = MsgBlock.fromContent('plainHtml', msgContentAsHtml); + contentBlock.verifyRes = verifyRes; + return { contentBlock: contentBlock, text: msgContentAsText.trim() }; } export const fmtRes = (response: {}, data?: Buf | Uint8Array): EndpointRes => { diff --git a/Core/source/mobile-interface/validate-input.ts b/Core/source/mobile-interface/validate-input.ts index ee0e6e8ad..83b4704b1 100644 --- a/Core/source/mobile-interface/validate-input.ts +++ b/Core/source/mobile-interface/validate-input.ts @@ -17,7 +17,7 @@ export namespace NodeRequest { export type composeEmail = composeEmailPlain | composeEmailEncrypted; export type encryptMsg = { pubKeys: string[] }; export type encryptFile = { pubKeys: string[], name: string }; - export type parseDecryptMsg = { keys: PrvKeyInfo[], msgPwd?: string, isEmail?: boolean }; + export type parseDecryptMsg = { keys: PrvKeyInfo[], msgPwd?: string, isEmail?: boolean, verificationPubkeys?: string[] }; export type decryptFile = { keys: PrvKeyInfo[], msgPwd?: string }; export type parseDateStr = { dateStr: string }; export type zxcvbnStrengthBar = { guesses: number, purpose: 'passphrase', value: undefined } | { value: string, purpose: 'passphrase', guesses: undefined }; @@ -60,7 +60,7 @@ export class ValidateInput { } public static parseDecryptMsg = (v: any): NodeRequest.parseDecryptMsg => { - if (isObj(v) && hasProp(v, 'keys', 'PrvKeyInfo[]') && hasProp(v, 'msgPwd', 'string?') && hasProp(v, 'isEmail', 'boolean?')) { + if (isObj(v) && hasProp(v, 'keys', 'PrvKeyInfo[]') && hasProp(v, 'msgPwd', 'string?') && hasProp(v, 'isEmail', 'boolean?') && hasProp(v, 'verificationPubkeys', 'string[]?')) { return v as NodeRequest.parseDecryptMsg; } throw new Error('Wrong request structure for NodeRequest.parseDecryptMsg'); @@ -131,7 +131,7 @@ const isObj = (v: any): v is Obj => { return v && typeof v === 'object'; } -const hasProp = (v: Obj, name: string, type: 'string[]' | 'object' | 'string' | 'number' | 'string?' | 'boolean?' | 'PrvKeyInfo[]' | 'Userid[]' | 'Attachment[]?'): boolean => { +const hasProp = (v: Obj, name: string, type: 'string[]' | 'string[]?' | 'object' | 'string' | 'number' | 'string?' | 'boolean?' | 'PrvKeyInfo[]' | 'Userid[]' | 'Attachment[]?' ): boolean => { if (!isObj(v)) { return false; } @@ -155,6 +155,9 @@ const hasProp = (v: Obj, name: string, type: 'string[]' | 'object' | 'string' | if (type === 'string[]') { return Array.isArray(value) && value.filter((x: any) => typeof x === 'string').length === value.length; } + if (type === 'string[]?') { + return typeof value === 'undefined' || Array.isArray(value) && value.filter((x: any) => typeof x === 'string').length === value.length; + } if (type === 'PrvKeyInfo[]') { return Array.isArray(value) && value.filter((ki: any) => hasProp(ki, 'private', 'string') && hasProp(ki, 'longid', 'string') && hasProp(ki, 'passphrase', 'string?')).length === value.length; } diff --git a/Core/source/platform/store.ts b/Core/source/platform/store.ts index 97c8cd2d8..cdf7a48a4 100644 --- a/Core/source/platform/store.ts +++ b/Core/source/platform/store.ts @@ -2,7 +2,6 @@ 'use strict'; -import { Contact } from '../core/pgp-key.js'; import { openpgp } from '../core/pgp.js'; let KEY_CACHE: { [longidOrArmoredKey: string]: OpenPGP.key.Key } = {}; @@ -12,10 +11,6 @@ const keyLongid = (k: OpenPGP.key.Key) => openpgp.util.str_to_hex(k.getKeyId().b export class Store { - static dbContactGet = async (db: void, emailOrLongid: string[]): Promise<(Contact | undefined)[]> => { - return []; - } - static decryptedKeyCacheSet = (k: OpenPGP.key.Key) => { Store.keyCacheRenewExpiry(); KEY_CACHE[keyLongid(k)] = k; diff --git a/Core/source/test.ts b/Core/source/test.ts index ff979c8b5..e5633249a 100644 --- a/Core/source/test.ts +++ b/Core/source/test.ts @@ -558,3 +558,101 @@ ava.default('can process dirty html without throwing', async t => { expect(clean).to.not.contain('src="http'); t.pass(); }); + +ava.default('verify encrypted+signed message by providing it correct public key', async t => { + const { keys, pubKeys } = getKeypairs('rsa1'); + const { json: decryptJson, data: decryptData } = parseResponse(await endpoints.parseDecryptMsg({ keys, isEmail: true, verificationPubkeys: pubKeys }, [await getCompatAsset('mime-email-encrypted-inline-text-signed')])); + expect(decryptJson.replyType).equals('encrypted'); + expect(decryptJson.subject).equals('mime email encrypted inline text signed'); + const parsedDecryptData = JSON.parse(decryptData.toString()); + expect(!!parsedDecryptData.verifyRes).equals(true); + expect(parsedDecryptData.verifyRes.match).equals(true); + t.pass(); +}); + +ava.default('verify encrypted+signed message by providing it one wrong and one correct', async t => { + const { keys, pubKeys } = getKeypairs('rsa1'); + const { pubKeys: pubKeys2 } = getKeypairs('rsa2'); + const allPubKeys = []; + for (const pubkey of pubKeys2) allPubKeys.push(pubkey); + for (const pubkey of pubKeys) allPubKeys.push(pubkey); + const { json: decryptJson, data: decryptData } = parseResponse(await endpoints.parseDecryptMsg({ keys, isEmail: true, verificationPubkeys: pubKeys }, [await getCompatAsset('mime-email-encrypted-inline-text-signed')])); + expect(decryptJson.replyType).equals('encrypted'); + expect(decryptJson.subject).equals('mime email encrypted inline text signed'); + const parsedDecryptData = JSON.parse(decryptData.toString()); + expect(!!parsedDecryptData.verifyRes).equals(true); + expect(parsedDecryptData.verifyRes.match).equals(true); + t.pass(); +}); + +ava.default('verify encrypted+signed message by providing it only a wrong public key (fail: cannot verify)', async t => { + const { keys } = getKeypairs('rsa1'); + const { pubKeys: pubKeys2 } = getKeypairs('rsa2'); + const { json: decryptJson, data: decryptData } = parseResponse(await endpoints.parseDecryptMsg({ keys, isEmail: true, verificationPubkeys: pubKeys2 }, [await getCompatAsset('mime-email-encrypted-inline-text-signed')])); + expect(decryptJson.replyType).equals('encrypted'); + expect(decryptJson.subject).equals('mime email encrypted inline text signed'); + const parsedDecryptData = JSON.parse(decryptData.toString()); + expect(!!parsedDecryptData.verifyRes).equals(true); + expect(parsedDecryptData.verifyRes.match).equals(null); + t.pass(); +}); + +ava.default('verify plain-text signed message by providing it correct key', async t => { + const { keys, pubKeys } = getKeypairs('rsa1'); + const { json: decryptJson, data: decryptData } = parseResponse(await endpoints.parseDecryptMsg({ keys, isEmail: true, verificationPubkeys: pubKeys }, [await getCompatAsset('mime-email-plain-signed')])); + expect(decryptJson.replyType).equals('plain'); + expect(decryptJson.subject).equals('mime email plain signed'); + const parsedDecryptData = JSON.parse(decryptData.toString()); + expect(!!parsedDecryptData.verifyRes).equals(true); + expect(parsedDecryptData.verifyRes.match).equals(true); + t.pass(); +}); + +ava.default('verify plain-text signed message by providing it both correct and incorrect keys', async t => { + const { keys, pubKeys } = getKeypairs('rsa1'); + const { pubKeys: pubKeys2 } = getKeypairs('rsa2'); + const allPubKeys = []; + for (const pubkey of pubKeys2) allPubKeys.push(pubkey); + for (const pubkey of pubKeys) allPubKeys.push(pubkey); + const { json: decryptJson, data: decryptData } = parseResponse(await endpoints.parseDecryptMsg({ keys, isEmail: true, verificationPubkeys: pubKeys }, [await getCompatAsset('mime-email-plain-signed')])); + expect(decryptJson.replyType).equals('plain'); + expect(decryptJson.subject).equals('mime email plain signed'); + const parsedDecryptData = JSON.parse(decryptData.toString()); + expect(!!parsedDecryptData.verifyRes).equals(true); + expect(parsedDecryptData.verifyRes.match).equals(true); + t.pass(); +}); + +ava.default('verify plain-text signed message by providing it wrong key (fail: cannot verify)', async t => { + const { keys } = getKeypairs('rsa1'); + const { pubKeys: pubKeys2 } = getKeypairs('rsa2'); + const { json: decryptJson, data: decryptData } = parseResponse(await endpoints.parseDecryptMsg({ keys, isEmail: true, verificationPubkeys: pubKeys2 }, [await getCompatAsset('mime-email-plain-signed')])); + expect(decryptJson.replyType).equals('plain'); + expect(decryptJson.subject).equals('mime email plain signed'); + const parsedDecryptData = JSON.parse(decryptData.toString()); + expect(!!parsedDecryptData.verifyRes).equals(true); + expect(parsedDecryptData.verifyRes.match).equals(null); + t.pass(); +}); + +ava.default('verify plain-text signed message that you edited after signing. This invalidates the signature. With correct key. (fail: signature mismatch)', async t => { + const { keys, pubKeys } = getKeypairs('rsa1'); + const { json: decryptJson, data: decryptData } = parseResponse(await endpoints.parseDecryptMsg({ keys, isEmail: true, verificationPubkeys: pubKeys }, [await getCompatAsset('mime-email-plain-signed-edited')])); + expect(decryptJson.replyType).equals('plain'); + expect(decryptJson.subject).equals('mime email plain signed'); + const parsedDecryptData = JSON.parse(decryptData.toString()); + expect(!!parsedDecryptData.verifyRes).equals(true); + expect(parsedDecryptData.verifyRes.match).equals(false); + t.pass(); +}); + +ava.default('verify signed message with detached signature by providing it correct key', async t => { + const { keys, pubKeys } = getKeypairs('rsa1'); + const { json: decryptJson, data: decryptData } = parseResponse(await endpoints.parseDecryptMsg({ keys, isEmail: true, verificationPubkeys: pubKeys }, [await getCompatAsset('mime-email-plain-signed-detached')])); + expect(decryptJson.replyType).equals('plain'); + expect(decryptJson.subject).equals('mime email plain signed detached'); + const parsedDecryptData = JSON.parse(decryptData.toString()); + expect(!!parsedDecryptData.verifyRes).equals(true); + expect(parsedDecryptData.verifyRes.match).equals(true); + t.pass(); +});