diff --git a/extension/chrome/elements/compose-modules/compose-draft-module.ts b/extension/chrome/elements/compose-modules/compose-draft-module.ts index 31f8bcd8131..022bafeb605 100644 --- a/extension/chrome/elements/compose-modules/compose-draft-module.ts +++ b/extension/chrome/elements/compose-modules/compose-draft-module.ts @@ -208,9 +208,6 @@ export class ComposeDraftModule extends ViewModule { } else { prefix = `(saving of this draft was interrupted - to decrypt it, send it to yourself)\n\n`; } - if (sendable.body['encrypted/buf']) { - sendable.body['encrypted/buf'] = Buf.concat([Buf.fromUtfStr(prefix), sendable.body['encrypted/buf']]); - } if (sendable.body['text/plain']) { sendable.body['text/plain'] = `${prefix}${sendable.body['text/plain'] || ''}`; } @@ -287,7 +284,7 @@ export class ComposeDraftModule extends ViewModule { } private decryptAndRenderDraft = async (encrypted: MimeProccesedMsg): Promise => { - const rawBlock = encrypted.blocks.find(b => b.type === 'encryptedMsg' || b.type === 'signedMsg'); + const rawBlock = encrypted.blocks.find(b => ['encryptedMsg', 'signedMsg', 'pkcs7'].includes(b.type)); if (!rawBlock) { return await this.abortAndRenderReplyMsgComposeTableIfIsReplyBox('!rawBlock'); } diff --git a/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts b/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts index 36c65695431..5b02d02ca5f 100644 --- a/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts +++ b/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts @@ -35,8 +35,10 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { newMsg.pwd = undefined; return await this.sendablePwdMsg(newMsg, pubkeys, msgUrl, signingPrv); // encrypted for pubkeys only, pwd ignored } else if (this.richtext) { // rich text: PGP/MIME - https://tools.ietf.org/html/rfc3156#section-4 + // or S/MIME return await this.sendableRichTextMsg(newMsg, pubkeys, signingPrv); - } else { // simple text: PGP/Inline with attachments in separate files + } else { // simple text: PGP or S/MIME Inline with attachments in separate files + // todo: #4046 check attachments for S/MIME return await this.sendableSimpleTextMsg(newMsg, pubkeys, signingPrv); } } @@ -100,29 +102,35 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { private sendableSimpleTextMsg = async (newMsg: NewMsgData, pubs: PubkeyResult[], signingPrv?: Key): Promise => { // todo - choosePubsBasedOnKeyTypeCombinationForPartialSmimeSupport is called later inside encryptDataArmor, could be refactored const pubsForEncryption = KeyUtil.choosePubsBasedOnKeyTypeCombinationForPartialSmimeSupport(pubs); + if (this.isDraft) { + const { data: encrypted } = await this.encryptDataArmor(Buf.fromUtfStr(newMsg.plaintext), undefined, pubs, signingPrv); + return await SendableMsg.createInlineArmored(this.acctEmail, this.headers(newMsg), Buf.fromUint8(encrypted).toUtfStr(), [], { isDraft: this.isDraft }); + } const x509certs = pubsForEncryption.filter(pub => pub.type === 'x509'); if (x509certs.length) { // s/mime const attachments: Attachment[] = this.isDraft ? [] : await this.view.attachmentsModule.attachment.collectAttachments(); // collects attachments - const msgBody = this.richtext ? { 'text/plain': newMsg.plaintext, 'text/html': newMsg.plainhtml } : { 'text/plain': newMsg.plaintext }; + const msgBody = { 'text/plain': newMsg.plaintext }; const mimeEncodedPlainMessage = await Mime.encode(msgBody, { Subject: newMsg.subject }, attachments); - const encryptedMessage = await SmimeKey.encryptMessage({ pubkeys: x509certs, data: Buf.fromUtfStr(mimeEncodedPlainMessage) }); + const encryptedMessage = await SmimeKey.encryptMessage({ pubkeys: x509certs, data: Buf.fromUtfStr(mimeEncodedPlainMessage), armor: false }); const data = encryptedMessage.data; - return await SendableMsg.createSMime(this.acctEmail, this.headers(newMsg), data, { isDraft: this.isDraft }); + return await SendableMsg.createSMimeEncrypted(this.acctEmail, this.headers(newMsg), data, { isDraft: this.isDraft }); } else { // openpgp const attachments: Attachment[] = this.isDraft ? [] : await this.view.attachmentsModule.attachment.collectEncryptAttachments(pubs); const encrypted = await this.encryptDataArmor(Buf.fromUtfStr(newMsg.plaintext), undefined, pubs, signingPrv); - return await SendableMsg.createPgpInline(this.acctEmail, this.headers(newMsg), Buf.fromUint8(encrypted.data).toUtfStr(), attachments, { isDraft: this.isDraft }); + return await SendableMsg.createInlineArmored(this.acctEmail, this.headers(newMsg), Buf.fromUint8(encrypted.data).toUtfStr(), attachments, { isDraft: this.isDraft }); } } private sendableRichTextMsg = async (newMsg: NewMsgData, pubs: PubkeyResult[], signingPrv?: Key) => { + // todo: pubs.type === 'x509' #4047 const plainAttachments = this.isDraft ? [] : await this.view.attachmentsModule.attachment.collectAttachments(); if (this.isDraft) { // this patch is needed as gmail makes it hard (or impossible) to render messages saved as https://tools.ietf.org/html/rfc3156 const pgpMimeToEncrypt = await Mime.encode({ 'text/plain': newMsg.plaintext, 'text/html': newMsg.plainhtml }, { Subject: newMsg.subject }, plainAttachments); const { data: encrypted } = await this.encryptDataArmor(Buf.fromUtfStr(pgpMimeToEncrypt), undefined, pubs, signingPrv); - return await SendableMsg.createPgpInline(this.acctEmail, this.headers(newMsg), Buf.fromUint8(encrypted).toUtfStr(), plainAttachments, { isDraft: this.isDraft }); + return await SendableMsg.createInlineArmored(this.acctEmail, this.headers(newMsg), Buf.fromUint8(encrypted).toUtfStr(), plainAttachments, { isDraft: this.isDraft }); } const pgpMimeToEncrypt = await Mime.encode({ 'text/plain': newMsg.plaintext, 'text/html': newMsg.plainhtml }, { Subject: newMsg.subject }, plainAttachments); + // todo: don't armor S/MIME and decide what to do with attachments #4046 and #4047 const { data: encrypted } = await this.encryptDataArmor(Buf.fromUtfStr(pgpMimeToEncrypt), undefined, pubs, signingPrv); const attachments = this.createPgpMimeAttachments(encrypted); return await SendableMsg.createPgpMime(this.acctEmail, this.headers(newMsg), attachments, { isDraft: this.isDraft }); diff --git a/extension/chrome/elements/compose-modules/formatters/signed-msg-mail-formatter.ts b/extension/chrome/elements/compose-modules/formatters/signed-msg-mail-formatter.ts index f3aff6103c4..186a0a44f81 100644 --- a/extension/chrome/elements/compose-modules/formatters/signed-msg-mail-formatter.ts +++ b/extension/chrome/elements/compose-modules/formatters/signed-msg-mail-formatter.ts @@ -9,14 +9,26 @@ import { NewMsgData } from '../compose-types.js'; import { Key } from '../../../../js/common/core/crypto/key.js'; import { MsgUtil } from '../../../../js/common/core/crypto/pgp/msg-util.js'; import { SendableMsg } from '../../../../js/common/api/email-provider/sendable-msg.js'; -import { SendableMsgBody } from '../../../../js/common/core/mime.js'; +import { Mime, SendableMsgBody } from '../../../../js/common/core/mime.js'; import { ContactStore } from '../../../../js/common/platform/store/contact-store.js'; +import { SmimeKey } from '../../../../js/common/core/crypto/smime/smime-key.js'; +import { Buf } from '../../../../js/common/core/buf.js'; export class SignedMsgMailFormatter extends BaseMailFormatter { public sendableMsg = async (newMsg: NewMsgData, signingPrv: Key): Promise => { this.view.errModule.debug(`SignedMsgMailFormatter.sendableMsg signing with key: ${signingPrv.id}`); const attachments = this.isDraft ? [] : await this.view.attachmentsModule.attachment.collectAttachments(); + if (signingPrv.type === 'x509') { + // todo: attachments, richtext #4046, #4047 + if (this.isDraft) { + throw new Error('signed-only PKCS#7 drafts are not supported'); + } + const msgBody = this.richtext ? { 'text/plain': newMsg.plaintext, 'text/html': newMsg.plainhtml } : { 'text/plain': newMsg.plaintext }; + const mimeEncodedPlainMessage = await Mime.encode(msgBody, { Subject: newMsg.subject }, attachments); + const data = await SmimeKey.sign(signingPrv, Buf.fromUtfStr(mimeEncodedPlainMessage)); + return await SendableMsg.createSMimeSigned(this.acctEmail, this.headers(newMsg), data); + } if (!this.richtext) { // Folding the lines or GMAIL WILL RAPE THE TEXT, regardless of what encoding is used // https://mathiasbynens.be/notes/gmail-plain-text applies to API as well @@ -33,7 +45,7 @@ export class SignedMsgMailFormatter extends BaseMailFormatter { const signedData = await MsgUtil.sign(signingPrv, newMsg.plaintext); const allContacts = [...newMsg.recipients.to || [], ...newMsg.recipients.cc || [], ...newMsg.recipients.bcc || []]; ContactStore.update(undefined, allContacts, { lastUse: Date.now() }).catch(Catch.reportErr); - return await SendableMsg.createPgpInline(this.acctEmail, this.headers(newMsg), signedData, attachments); + return await SendableMsg.createInlineArmored(this.acctEmail, this.headers(newMsg), signedData, attachments); } // pgp/mime detached signature - it must be signed later, while being mime-encoded // prepare a sign function first, which will be used by Mime.encodePgpMimeSigned later diff --git a/extension/js/common/api/email-provider/sendable-msg.ts b/extension/js/common/api/email-provider/sendable-msg.ts index 9d2e2a5acd0..7dfc263d681 100644 --- a/extension/js/common/api/email-provider/sendable-msg.ts +++ b/extension/js/common/api/email-provider/sendable-msg.ts @@ -8,6 +8,7 @@ import { Attachment } from '../../core/attachment.js'; import { Buf } from '../../core/buf.js'; import { RecipientType } from '../shared/api.js'; import { KeyStore } from '../../platform/store/key-store.js'; +import { KeyUtil } from '../../core/crypto/key.js'; type Recipients = { to?: string[], cc?: string[], bcc?: string[] }; @@ -39,15 +40,19 @@ export class SendableMsg { public sign?: (signable: string) => Promise; - public static createSMime = async (acctEmail: string, headers: SendableMsgHeaders, data: Uint8Array, options: SendableMsgOptions): Promise => { - return await SendableMsg.createSendableMsg(acctEmail, headers, { "encrypted/buf": Buf.fromUint8(data) }, [], { type: 'smimeEncrypted', isDraft: options.isDraft }); + public static createSMimeEncrypted = async (acctEmail: string, headers: SendableMsgHeaders, data: Uint8Array, options: SendableMsgOptions): Promise => { + return await SendableMsg.createSendableMsg(acctEmail, headers, { "pkcs7/buf": Buf.fromUint8(data) }, [], { type: 'smimeEncrypted', isDraft: options.isDraft }); + } + + public static createSMimeSigned = async (acctEmail: string, headers: SendableMsgHeaders, data: Uint8Array): Promise => { + return await SendableMsg.createSendableMsg(acctEmail, headers, { "pkcs7/buf": Buf.fromUint8(data) }, [], { type: 'smimeSigned' }); } public static createPlain = async (acctEmail: string, headers: SendableMsgHeaders, body: SendableMsgBody, attachments: Attachment[]): Promise => { return await SendableMsg.createSendableMsg(acctEmail, headers, body, attachments, { type: undefined, isDraft: undefined }); } - public static createPgpInline = async (acctEmail: string, headers: SendableMsgHeaders, body: string, attachments: Attachment[], options?: SendableMsgOptions): Promise => { + public static createInlineArmored = async (acctEmail: string, headers: SendableMsgHeaders, body: string, attachments: Attachment[], options?: SendableMsgOptions): Promise => { return await SendableMsg.createSendableMsg(acctEmail, headers, { "text/plain": body }, attachments, options ? options : { type: undefined, isDraft: undefined }); } @@ -91,7 +96,10 @@ export class SendableMsg { private static create = async (acctEmail: string, { from, recipients, subject, thread, body, attachments, type, isDraft }: SendableMsgDefinition): Promise => { const primaryKi = await KeyStore.getFirstRequired(acctEmail); - const headers: Dict = primaryKi ? { OpenPGP: `id=${primaryKi.longid}` } : {}; // todo - use autocrypt format + const headers: Dict = {}; + if (primaryKi && KeyUtil.getKeyType(primaryKi.private) === 'openpgp') { + headers.Openpgp = `id=${primaryKi.longid}`; // todo - use autocrypt format + } return new SendableMsg( acctEmail, headers, @@ -138,14 +146,11 @@ export class SendableMsg { } } this.headers.Subject = this.subject; - if (this.type === 'smimeEncrypted' && this.body['encrypted/buf']) { - return await Mime.encodeSmime(this.body['encrypted/buf'], this.headers); + if (this.body['pkcs7/buf']) { + return await Mime.encodeSmime(this.body['pkcs7/buf'], this.headers, this.type === 'smimeSigned' ? 'signed-data' : 'enveloped-data'); } else if (this.type === 'pgpMimeSigned' && this.sign) { return await Mime.encodePgpMimeSigned(this.body, this.headers, this.attachments, this.sign); - } else { // encrypted/buf is a Buf instance that is converted to single-part plain/text message - if (this.body['encrypted/buf']) { - this.body = { 'text/plain': this.body['encrypted/buf'].toString() }; - } + } else { return await Mime.encode(this.body, this.headers, this.attachments, this.type); } } diff --git a/extension/js/common/core/crypto/key.ts b/extension/js/common/core/crypto/key.ts index b36d31e5f61..dee5596e9fe 100644 --- a/extension/js/common/core/crypto/key.ts +++ b/extension/js/common/core/crypto/key.ts @@ -291,21 +291,23 @@ export class KeyUtil { } public static choosePubsBasedOnKeyTypeCombinationForPartialSmimeSupport = (pubs: PubkeyResult[]): Key[] => { - const myPubs = pubs.filter(pub => pub.isMine); // currently this must be openpgp pub - const otherPgpPubs = pubs.filter(pub => !pub.isMine && pub.pubkey.type === 'openpgp'); - const otherSmimePubs = pubs.filter(pub => !pub.isMine && pub.pubkey.type === 'x509'); - if (otherPgpPubs.length && otherSmimePubs.length) { - let err = `Cannot use mixed OpenPGP (${otherPgpPubs.map(p => p.email).join(', ')}) and S/MIME (${otherSmimePubs.map(p => p.email).join(', ')}) public keys yet.`; - err += 'If you need to email S/MIME recipient, do not add any OpenPGP recipient at the same time.'; - throw new UnreportableError(err); - } - if (otherPgpPubs.length) { - return myPubs.concat(...otherPgpPubs).map(p => p.pubkey); - } - if (otherSmimePubs.length) { // todo - currently skipping my own pgp keys when encrypting message for S/MIME - return otherSmimePubs.map(pub => pub.pubkey); + let pgpPubs = pubs.filter(pub => pub.pubkey.type === 'openpgp'); + let smimePubs = pubs.filter(pub => pub.pubkey.type === 'x509'); + if (pgpPubs.length && smimePubs.length) { + // get rid of some of my keys to resolve the conflict + // todo: how would it work with drafts? + if (smimePubs.every(pub => pub.isMine)) { + smimePubs = []; + } else if (pgpPubs.every(pub => pub.isMine)) { + pgpPubs = []; + } else { + let err = `Cannot use mixed OpenPGP (${pgpPubs.filter(p => !p.isMine).map(p => p.email).join(', ')}) and ` + + `S/MIME (${smimePubs.filter(p => !p.isMine).map(p => p.email).join(', ')}) public keys yet.`; + err += 'If you need to email S/MIME recipient, do not add any OpenPGP recipient at the same time.'; + throw new UnreportableError(err); + } } - return myPubs.map(p => p.pubkey); + return pgpPubs.concat(smimePubs).map(p => p.pubkey); } public static decrypt = async (key: Key, passphrase: string, optionalKeyid?: OpenPGP.Keyid, optionalBehaviorFlag?: 'OK-IF-ALREADY-DECRYPTED'): Promise => { @@ -369,11 +371,7 @@ export class KeyUtil { if (pubkey.type !== 'x509') { return OpenPGPKey.fingerprintToLongid(pubkey.id); } - const encodedIssuerAndSerialNumber = 'X509-' + Buf.fromRawBytesStr(pubkey.issuerAndSerialNumber!).toBase64Str(); - if (!encodedIssuerAndSerialNumber) { - throw new Error(`Cannot extract IssuerAndSerialNumber from the certificate for: ${pubkey.id}`); - } - return encodedIssuerAndSerialNumber; + return SmimeKey.getKeyLongid(pubkey); } public static getKeyInfoLongids = (ki: ExtendedKeyInfo): string[] => { diff --git a/extension/js/common/core/crypto/pgp/msg-util.ts b/extension/js/common/core/crypto/pgp/msg-util.ts index a66da63d8e9..b99803a930b 100644 --- a/extension/js/common/core/crypto/pgp/msg-util.ts +++ b/extension/js/common/core/crypto/pgp/msg-util.ts @@ -10,7 +10,7 @@ import { PgpArmor, PreparedForDecrypt } from './pgp-armor.js'; import { opgp } from './openpgpjs-custom.js'; import { KeyCache } from '../../../platform/key-cache.js'; import { ContactStore } from '../../../platform/store/contact-store.js'; -import { SmimeKey } from '../smime/smime-key.js'; +import { SmimeKey, SmimeMsg } from '../smime/smime-key.js'; import { OpenPGPKey } from './openpgp-key.js'; export class DecryptionError extends Error { @@ -210,22 +210,26 @@ export class MsgUtil { } catch (formatErr) { return { success: false, error: { type: DecryptErrTypes.format, message: String(formatErr) }, longids }; } - const keys = await MsgUtil.getSortedKeys(kisWithPp, prepared.message); + const keys = prepared.isPkcs7 ? await MsgUtil.getSmimeKeys(kisWithPp, prepared.message) : await MsgUtil.getSortedKeys(kisWithPp, prepared.message); longids.message = keys.encryptedFor; longids.matching = keys.prvForDecrypt.map(ki => ki.longid); longids.chosen = keys.prvForDecryptDecrypted.map(decrypted => decrypted.ki.longid); longids.needPassphrase = keys.prvForDecryptWithoutPassphrases.map(ki => ki.longid); const isEncrypted = !prepared.isCleartext; - if (!isEncrypted) { + if (!isEncrypted && !prepared.isPkcs7) { const signature = await MsgUtil.verify(prepared.message, keys.forVerification, keys.verificationContacts[0]); const content = signature.content || Buf.fromUtfStr('no content'); signature.content = undefined; // no need to duplicate data return { success: true, content, isEncrypted, signature }; } - if (!keys.prvForDecryptDecrypted.length && !msgPwd) { + if (!keys.prvForDecryptDecrypted.length && (!msgPwd || prepared.isPkcs7)) { return { success: false, error: { type: DecryptErrTypes.needPassphrase, message: 'Missing pass phrase' }, longids, isEncrypted }; } try { + if (prepared.isPkcs7) { + const decrypted = SmimeKey.decryptMessage(prepared.message, keys.prvForDecryptDecrypted[0].decrypted); + return { success: true, content: new Buf(decrypted), isEncrypted }; + } const packets = (prepared.message as OpenPGP.message.Message).packets; const isSymEncrypted = packets.filter(p => p.tag === opgp.enums.packet.symEncryptedSessionKey).length > 0; const isPubEncrypted = packets.filter(p => p.tag === opgp.enums.packet.publicKeyEncryptedSessionKey).length > 0; @@ -338,6 +342,42 @@ export class MsgUtil { return keys; } + private static getSmimeKeys = async (kiWithPp: ExtendedKeyInfo[], msg: SmimeMsg): Promise => { + const keys: SortedKeysForDecrypt = { + verificationContacts: [], + forVerification: [], + encryptedFor: [], + signedBy: [], + prvMatching: [], + prvForDecrypt: [], + prvForDecryptDecrypted: [], + prvForDecryptWithoutPassphrases: [], + }; + keys.encryptedFor = SmimeKey.getMessageLongids(msg); + if (keys.encryptedFor.length) { + keys.prvMatching = kiWithPp.filter(ki => KeyUtil.getKeyInfoLongids(ki).some( + longid => keys.encryptedFor.includes(longid))); + keys.prvForDecrypt = keys.prvMatching.length ? keys.prvMatching : kiWithPp; + } else { // prvs not needed for signed msgs + keys.prvForDecrypt = []; + } + for (const ki of keys.prvForDecrypt) { + const cachedKey = KeyCache.getDecrypted(ki.longid); + if (cachedKey) { + keys.prvForDecryptDecrypted.push({ ki, decrypted: cachedKey }); + continue; + } + const parsed = await KeyUtil.parse(ki.private); + if (parsed.fullyDecrypted || ki.passphrase && await SmimeKey.decryptKey(parsed, ki.passphrase) === true) { + KeyCache.setDecrypted(parsed); + keys.prvForDecryptDecrypted.push({ ki, decrypted: parsed }); + } else { + keys.prvForDecryptWithoutPassphrases.push(ki); + } + } + return keys; + } + private static matchingKeyids = (longids: string[], encryptedForKeyids: OpenPGP.Keyid[]): OpenPGP.Keyid[] => { return encryptedForKeyids.filter(kid => longids.includes(OpenPGPKey.bytesToLongid(kid.bytes))); } diff --git a/extension/js/common/core/crypto/pgp/pgp-armor.ts b/extension/js/common/core/crypto/pgp/pgp-armor.ts index 575f114c2b3..d1c3e11a26c 100644 --- a/extension/js/common/core/crypto/pgp/pgp-armor.ts +++ b/extension/js/common/core/crypto/pgp/pgp-armor.ts @@ -2,14 +2,19 @@ 'use strict'; +// todo: move this file level up as it handles both S/MIME and OpenPGP? +import * as forge from 'node-forge'; import { Buf } from '../../buf.js'; import { ReplaceableMsgBlockType } from '../../msg-block.js'; import { Str } from '../../common.js'; import { opgp } from './openpgpjs-custom.js'; import { Stream } from '../../stream.js'; +import { SmimeKey } from '../smime/smime-key.js'; -export type PreparedForDecrypt = { isArmored: boolean, isCleartext: true, message: OpenPGP.cleartext.CleartextMessage | OpenPGP.message.Message } - | { isArmored: boolean, isCleartext: false, message: OpenPGP.message.Message }; +export type PreparedForDecrypt = { isArmored: boolean, isCleartext: true, isPkcs7: false, message: OpenPGP.cleartext.CleartextMessage | OpenPGP.message.Message } + | { isArmored: boolean, isCleartext: false, isPkcs7: false, message: OpenPGP.message.Message } + | { isArmored: boolean, isCleartext: false, isPkcs7: true, message: forge.pkcs7.PkcsEnvelopedData } + ; type CryptoArmorHeaderDefinitions = { readonly [type in ReplaceableMsgBlockType | 'null' | 'signature']: CryptoArmorHeaderDefinition; }; type CryptoArmorHeaderDefinition = { begin: string, middle?: string, end: string | RegExp, replace: boolean }; @@ -20,7 +25,8 @@ export class PgpArmor { null: { begin: '-----BEGIN', end: '-----END', replace: false }, publicKey: { begin: '-----BEGIN PGP PUBLIC KEY BLOCK-----', end: '-----END PGP PUBLIC KEY BLOCK-----', replace: true }, privateKey: { begin: '-----BEGIN PGP PRIVATE KEY BLOCK-----', end: '-----END PGP PRIVATE KEY BLOCK-----', replace: true }, - pkcs12: { begin: '-----BEGIN PKCS12 FILE-----', end: '-----BEGIN PKCS12 FILE-----', replace: true }, // custom format - Base64 dump of pkcs12 file bytes + pkcs12: { begin: '-----BEGIN PKCS12 FILE-----', end: '-----END PKCS12 FILE-----', replace: true }, // custom format - Base64 dump of pkcs12 file bytes + pkcs7: { begin: '-----BEGIN PKCS7-----', end: '-----END PKCS7-----', replace: true }, // PEM-formatted pkcs7 message pkcs8EncryptedPrivateKey: { begin: '-----BEGIN ENCRYPTED PRIVATE KEY-----', end: '-----END ENCRYPTED PRIVATE KEY-----', replace: true }, pkcs8PrivateKey: { begin: '-----BEGIN PRIVATE KEY-----', end: '-----END PRIVATE KEY-----', replace: true }, pkcs8RsaPrivateKey: { begin: '-----BEGIN RSA PRIVATE KEY-----', end: '-----END RSA PRIVATE KEY-----', replace: true }, @@ -84,17 +90,24 @@ export class PgpArmor { throw new Error('Encrypted message could not be parsed because no data was provided'); } const utfChunk = new Buf(encrypted.slice(0, 100)).toUtfStr('ignore'); // ignore errors - this may not be utf string, just testing + if (utfChunk.includes(PgpArmor.headers('pkcs7').begin)) { + const p7 = SmimeKey.readArmoredPkcs7Message(encrypted); + if (p7.type !== '1.2.840.113549.1.7.3') { + throw new Error('Not implemented'); + } + return { isArmored: true, isCleartext: false, isPkcs7: true, message: p7 }; + } const isArmoredEncrypted = utfChunk.includes(PgpArmor.headers('encryptedMsg').begin); const isArmoredSignedOnly = utfChunk.includes(PgpArmor.headers('signedMsg').begin); const isArmored = isArmoredEncrypted || isArmoredSignedOnly; if (isArmoredSignedOnly) { - return { isArmored, isCleartext: true, message: await opgp.cleartext.readArmored(new Buf(encrypted).toUtfStr()) }; + return { isArmored, isCleartext: true, isPkcs7: false, message: await opgp.cleartext.readArmored(new Buf(encrypted).toUtfStr()) }; } else if (isArmoredEncrypted) { const message = await opgp.message.readArmored(new Buf(encrypted).toUtfStr()); const isCleartext = !!message.getLiteralData() && !!message.getSigningKeyIds().length && !message.getEncryptionKeyIds().length; - return { isArmored: true, isCleartext, message }; + return { isArmored: true, isCleartext, isPkcs7: false, message }; } else if (encrypted instanceof Uint8Array) { - return { isArmored, isCleartext: false, message: await opgp.message.read(encrypted) }; + return { isArmored, isCleartext: false, isPkcs7: false, message: await opgp.message.read(encrypted) }; } throw new Error('Message does not have armor headers'); } diff --git a/extension/js/common/core/crypto/smime/smime-key.ts b/extension/js/common/core/crypto/smime/smime-key.ts index f2db1a96880..f67b504bc47 100644 --- a/extension/js/common/core/crypto/smime/smime-key.ts +++ b/extension/js/common/core/crypto/smime/smime-key.ts @@ -8,6 +8,7 @@ import { Buf } from '../../buf.js'; import { MsgBlockParser } from '../../msg-block-parser.js'; import { MsgBlock } from '../../msg-block.js'; +export type SmimeMsg = forge.pkcs7.PkcsEnvelopedData; export class SmimeKey { public static parse = (text: string): Key => { @@ -70,7 +71,7 @@ export class SmimeKey { /** * @param data: an already encoded plain mime message */ - public static encryptMessage = async ({ pubkeys, data }: { pubkeys: Key[], data: Uint8Array }): Promise<{ data: Uint8Array, type: 'smime' }> => { + public static encryptMessage = async ({ pubkeys, data, armor }: { pubkeys: Key[], data: Uint8Array, armor: boolean }): Promise<{ data: Uint8Array, type: 'smime' }> => { const p7 = forge.pkcs7.createEnvelopedData(); for (const pubkey of pubkeys) { const certificate = SmimeKey.getCertificate(pubkey); @@ -81,12 +82,51 @@ export class SmimeKey { } p7.content = forge.util.createBuffer(data); p7.encrypt(); - const derBuffer = forge.asn1.toDer(p7.toAsn1()).getBytes(); - const arr = []; - for (let i = 0, j = derBuffer.length; i < j; ++i) { - arr.push(derBuffer.charCodeAt(i)); + let rawString: string; + if (armor) { + rawString = forge.pkcs7.messageToPem(p7); + } else { + rawString = forge.asn1.toDer(p7.toAsn1()).getBytes(); } - return { data: new Uint8Array(arr), type: 'smime' }; + return { data: Buf.fromRawBytesStr(rawString), type: 'smime' }; + } + + public static readArmoredPkcs7Message = (encrypted: Uint8Array): + forge.pkcs7.PkcsEnvelopedData | forge.pkcs7.PkcsEncryptedData | forge.pkcs7.PkcsSignedData => { + return forge.pkcs7.messageFromPem(new Buf(encrypted).toUtfStr()); + } + + public static decryptMessage = (p7: forge.pkcs7.PkcsEnvelopedData, key: Key): Uint8Array => { + // todo: make sure private, decrypted ? and x509 + const armoredPrivateKey = SmimeKey.getArmoredPrivateKey(key); + const decryptedPrivateKey = forge.pki.privateKeyFromPem(armoredPrivateKey); + // find a recipient by the issuer of a certificate + const recipient = p7.findRecipient(SmimeKey.getCertificate(key)); + // decrypt + p7.decrypt(recipient, decryptedPrivateKey); // todo: exception handling + return p7.content ? Buf.fromRawBytesStr(p7.content.getBytes()) : new Buf(); + } + + // signs binary data and returns DER-encoded PKCS#7 message + public static sign = async (signingPrivate: Key, data: Uint8Array): Promise => { + // todo: !isFullyDecrypted + const p7 = forge.pkcs7.createSignedData(); + p7.addSigner({ + certificate: SmimeKey.getCertificate(signingPrivate), + key: SmimeKey.getArmoredPrivateKey(signingPrivate), + // digestAlgorithm: forge.pki.oids.sha1, + /* + authenticatedAttributes: [{ + type: forge.pki.oids.contentType, + value: forge.pki.oids.data + }, { + type: forge.pki.oids.messageDigest + }] */ + }); + p7.content = forge.util.createBuffer(data); + p7.sign(); + const derBuffer = forge.asn1.toDer(p7.toAsn1()).getBytes(); + return Buf.fromRawBytesStr(derBuffer); } public static decryptKey = async (key: Key, passphrase: string, optionalBehaviorFlag?: 'OK-IF-ALREADY-DECRYPTED'): Promise => { @@ -139,6 +179,31 @@ export class SmimeKey { return key; } + public static getKeyLongid = (key: Key): string => { + if (key.issuerAndSerialNumber !== undefined) { + const encodedIssuerAndSerialNumber = SmimeKey.getLongIdFromDer(key.issuerAndSerialNumber); + if (encodedIssuerAndSerialNumber) { + return encodedIssuerAndSerialNumber; + } + } + throw new Error(`Cannot extract IssuerAndSerialNumber from the certificate for: ${key.id}`); + } + + public static getMessageLongids = (msg: SmimeMsg): string[] => { + return msg.recipients.map(recipient => { + const asn1 = SmimeKey.createIssuerAndSerialNumberAsn1( + SmimeKey.attributesToDistinguishedNameAsn1(recipient.issuer), + recipient.serialNumber); + const der = forge.asn1.toDer(asn1).getBytes(); + return SmimeKey.getLongIdFromDer(der); + }); + } + + // convert from binary string as provided by Forge to a 'X509-' prefixed base64 longid + private static getLongIdFromDer = (der: string): string => { + return 'X509-' + Buf.fromRawBytesStr(der).toBase64Str(); + } + private static getLeafCertificates = (msgBlocks: MsgBlock[]): { pem: string, certificate: forge.pki.Certificate }[] => { const parsed = msgBlocks.map(cert => { return { pem: cert.content as string, certificate: forge.pki.certificateFromPem(cert.content as string) }; }); // Note: no signature check is performed. @@ -182,14 +247,9 @@ export class SmimeKey { } const fingerprint = forge.pki.getPublicKeyFingerprint(certificate.publicKey, { encoding: 'hex' }).toUpperCase(); const emails = SmimeKey.getNormalizedEmailsFromCertificate(certificate); - const issuerAndSerialNumberAsn1 = - forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [ - // Name - forge.pki.distinguishedNameToAsn1(certificate.issuer), - // Serial - forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.INTEGER, false, - forge.util.hexToBytes(certificate.serialNumber)) - ]); + const issuerAndSerialNumberAsn1 = SmimeKey.createIssuerAndSerialNumberAsn1( + forge.pki.distinguishedNameToAsn1(certificate.issuer), + certificate.serialNumber); const expiration = SmimeKey.dateToNumber(certificate.validity.notAfter)!; const expired = expiration < Date.now(); const usableIgnoringExpiration = SmimeKey.isEmailCertificate(certificate) && !SmimeKey.isKeyWeak(certificate); @@ -217,6 +277,33 @@ export class SmimeKey { return key; } + private static attributesToDistinguishedNameAsn1 = (attributes: forge.pki.Attribute[]): forge.asn1.Asn1 => { + return forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, attributes.map(attr => { + const valueTagClass = attr.valueTagClass || forge.asn1.Type.PRINTABLESTRING; + return forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SET, true, [ + forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [ + // AttributeType + forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false, + forge.asn1.oidToDer(attr.type).getBytes()), + // AttributeValue + forge.asn1.create(forge.asn1.Class.UNIVERSAL, attr.valueTagClass, false, + (valueTagClass === forge.asn1.Type.UTF8) ? forge.util.encodeUtf8(attr.value) : attr.value)]) // tslint:disable-line:no-unsafe-any + ]); + })); + } + + private static createIssuerAndSerialNumberAsn1 = (issuerAsn1: forge.asn1.Asn1, serialNumberHex: string) => { + return forge.asn1.create( + forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, + [ + // Issuer + issuerAsn1, + // Serial + forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.INTEGER, false, + forge.util.hexToBytes(serialNumberHex)) + ]); + } + private static getArmoredPrivateKey = (key: Key) => { return (key as unknown as { privateKeyArmored: string }).privateKeyArmored; } diff --git a/extension/js/common/core/mime.ts b/extension/js/common/core/mime.ts index 5392c9b2139..9c28e1da7ba 100644 --- a/extension/js/common/core/mime.ts +++ b/extension/js/common/core/mime.ts @@ -34,13 +34,13 @@ export type MimeContent = { bcc: string[]; }; -export type MimeEncodeType = 'pgpMimeEncrypted' | 'pgpMimeSigned' | 'smimeEncrypted' | undefined; +export type MimeEncodeType = 'pgpMimeEncrypted' | 'pgpMimeSigned' | 'smimeEncrypted' | 'smimeSigned' | undefined; export type RichHeaders = Dict; export type SendableMsgBody = { [key: string]: string | Buf | undefined; 'text/plain'?: string; 'text/html'?: string; - 'encrypted/buf'?: Buf; + 'pkcs7/buf'?: Buf; // DER-encoded PKCS#7 message }; export type MimeProccesedMsg = { rawSignedContent: string | undefined, @@ -58,7 +58,7 @@ export class Mime { if (decoded.text) { const blocksFromTextPart = MsgBlockParser.detectBlocks(Str.normalize(decoded.text)).blocks; // if there are some encryption-related blocks found in the text section, which we can use, and not look at the html section - if (blocksFromTextPart.find(b => b.type === 'encryptedMsg' || b.type === 'signedMsg' || b.type === 'publicKey' || b.type === 'privateKey')) { + if (blocksFromTextPart.find(b => ['pkcs7', 'encryptedMsg', 'signedMsg', 'publicKey', 'privateKey'].includes(b.type))) { blocks.push(...blocksFromTextPart); // because the html most likely containt the same thing, just harder to parse pgp sections cause it's html } else if (decoded.html) { // if no pgp blocks found in text part and there is html part, prefer html blocks.push(MsgBlock.fromContent('plainHtml', decoded.html)); @@ -220,8 +220,8 @@ export class Mime { return rootNode.build(); // tslint:disable-line:no-unsafe-any } - public static encodeSmime = async (body: Uint8Array, headers: RichHeaders): Promise => { - const rootContentType = 'application/pkcs7-mime; name="smime.p7m"; smime-type=enveloped-data'; + public static encodeSmime = async (body: Uint8Array, headers: RichHeaders, type: 'enveloped-data' | 'signed-data'): Promise => { + const rootContentType = `application/pkcs7-mime; name="smime.p7m"; smime-type=${type}`; const rootNode = new MimeBuilder(rootContentType, { includeBccInHeader: true }); // tslint:disable-line:no-unsafe-any for (const key of Object.keys(headers)) { rootNode.addHeader(key, headers[key]); // tslint:disable-line:no-unsafe-any @@ -229,7 +229,11 @@ export class Mime { rootNode.setContent(body); // tslint:disable-line:no-unsafe-any rootNode.addHeader('Content-Transfer-Encoding', 'base64'); // tslint:disable-line:no-unsafe-any rootNode.addHeader('Content-Disposition', 'attachment; filename="smime.p7m"'); // tslint:disable-line:no-unsafe-any - rootNode.addHeader('Content-Description', 'S/MIME Encrypted Message'); // tslint:disable-line:no-unsafe-any + let contentDescription = 'S/MIME Encrypted Message'; + if (type === 'signed-data') { + contentDescription = 'S/MIME Signed Message'; + } + rootNode.addHeader('Content-Description', contentDescription); // tslint:disable-line:no-unsafe-any return rootNode.build(); // tslint:disable-line:no-unsafe-any } diff --git a/extension/js/common/core/msg-block.ts b/extension/js/common/core/msg-block.ts index f4c15996d20..ff2ba4d54ff 100644 --- a/extension/js/common/core/msg-block.ts +++ b/extension/js/common/core/msg-block.ts @@ -8,7 +8,7 @@ import { AttachmentMeta } from './attachment.js'; import { Buf } from './buf.js'; export type KeyBlockType = 'publicKey' | 'privateKey' | 'certificate' | 'pkcs12' | 'pkcs8EncryptedPrivateKey' | 'pkcs8PrivateKey' | 'pkcs8RsaPrivateKey'; -export type ReplaceableMsgBlockType = KeyBlockType | 'signedMsg' | 'encryptedMsg'; +export type ReplaceableMsgBlockType = KeyBlockType | 'signedMsg' | 'encryptedMsg' | 'pkcs7'; export type MsgBlockType = ReplaceableMsgBlockType | 'plainText' | 'signedText' | 'plainHtml' | 'decryptedHtml' | 'plainAttachment' | 'encryptedAttachment' | 'decryptedAttachment' | 'encryptedAttachmentLink' | 'decryptErr' | 'verifiedMsg' | 'signedHtml'; diff --git a/extension/types/node-forge.d.ts b/extension/types/node-forge.d.ts index e7bf7d89d25..be1557426e4 100644 --- a/extension/types/node-forge.d.ts +++ b/extension/types/node-forge.d.ts @@ -641,8 +641,8 @@ declare module "node-forge" { getBagsByLocalKeyId: (localKeyId: string, bagType: string) => Bag[] } - function pkcs12FromAsn1(obj: any, strict?: boolean, password?: string): Pkcs12Pfx; - function pkcs12FromAsn1(obj: any, password?: string): Pkcs12Pfx; + function pkcs12FromAsn1(obj: asn1.Asn1, strict?: boolean, password?: string): Pkcs12Pfx; + function pkcs12FromAsn1(obj: asn1.Asn1, password?: string): Pkcs12Pfx; function toPkcs12Asn1( key: pki.PrivateKey, @@ -670,43 +670,59 @@ declare module "node-forge" { } namespace pkcs7 { - interface PkcsSignedData { + interface Pkcs7Data { + content?: util.ByteBuffer; + toAsn1(): asn1.Asn1; + } + + interface PkcsSignedData extends Pkcs7Data { type: '1.2.840.113549.1.7.2'; - content?: string | util.ByteBuffer; contentInfo?: { value: any[] }; addCertificate(certificate: pki.Certificate | string): void; addSigner(options: { key: string; certificate: pki.Certificate | string; - digestAlgorithm: string; - authenticatedAttributes: { type: string; value?: string }[]; + digestAlgorithm?: string; + authenticatedAttributes?: { type: string; value?: string }[]; }): void; sign(options?: { detached?: boolean }): void; - toAsn1(): asn1.Asn1; } function createSignedData(): PkcsSignedData; - interface PkcsEnvelopedData { + interface PkcsEnvelopedData extends Pkcs7Data { type: '1.2.840.113549.1.7.3'; - content?: string | util.ByteBuffer; + recipients: { issuer: pki.Attribute[], serialNumber: string }[]; addRecipient(certificate: pki.Certificate): void; + findRecipient(cert: pki.Certificate): Recipient; encrypt(): void; - toAsn1(): asn1.Asn1; + decrypt(recipient: Recipient, privKey: pki.PrivateKey): void; } interface PkcsEncryptedData { type: '1.2.840.113549.1.7.6'; // todo: encryptedContent; // todo: decrypt(key); - // todo: fromAsn1(obj); + } + + interface Recipient { + version: number, + issuer: any[], + serialNumber: string, + encryptedContent: { + algorithm: string, + parameter: string, + content: util.ByteBuffer + } } function createEnvelopedData(): PkcsEnvelopedData; function messageFromPem(pem: pki.PEM): PkcsEnvelopedData | PkcsSignedData | PkcsEncryptedData; + function messageToPem(msg: PkcsEnvelopedData | PkcsSignedData | PkcsEncryptedData, maxline?: number): pki.PEM; + function messageFromAsn1(obj: asn1.Asn1): PkcsEnvelopedData | PkcsSignedData | PkcsEncryptedData; } namespace pkcs5 { diff --git a/test/source/mock/google/exported-messages/draft-17c041fd27858466.json b/test/source/mock/google/exported-messages/draft-17c041fd27858466.json new file mode 100644 index 00000000000..096fd6e6152 --- /dev/null +++ b/test/source/mock/google/exported-messages/draft-17c041fd27858466.json @@ -0,0 +1,83 @@ +{ + "acctEmail": "flowcrypt.test.key.imported@gmail.com", + "full": { + "id": "17c041fd27858466", + "threadId": "17c041fd27858466", + "labelIds": [ + "DRAFT" + ], + "snippet": "[flowcrypt:link:draft_compose:mockfakedraftsave] -----BEGIN PKCS7----- MIICDQYJKoZIhvcNAQcDoIIB/jCCAfoCAQAxggGzMIIBrwIBADCBljCBgTELMAkG A1UEBhMCSVQxEDAOBgNVBAgMB0JlcmdhbW8xGTAXBgNVBAcMEFBvbnRlIFNhbiBQ", + "payload": { + "partId": "", + "mimeType": "multipart/mixed", + "filename": "", + "headers": [ + { + "name": "Content-Type", + "value": "multipart/mixed; boundary=\"----sinikael-?=_1-16321562277590.8883651737565523\"" + }, + { + "name": "From", + "value": "flowcrypt.test.key.imported@gmail.com" + }, + { + "name": "To", + "value": "smime@recipient.com" + }, + { + "name": "Subject", + "value": "Test S/MIME Encrypted Draft" + }, + { + "name": "Date", + "value": "Mon, 20 Sep 2021 16:43:47 +0000" + }, + { + "name": "MIME-Version", + "value": "1.0" + } + ], + "body": { + "size": 0 + }, + "parts": [ + { + "partId": "0", + "mimeType": "text/plain", + "filename": "", + "headers": [ + { + "name": "Content-Type", + "value": "text/plain" + }, + { + "name": "Content-Transfer-Encoding", + "value": "quoted-printable" + } + ], + "body": { + "size": 812, + "data": "W2Zsb3djcnlwdDpsaW5rOmRyYWZ0X2NvbXBvc2U6bW9ja2Zha2VkcmFmdHNhdmVdCgotLS0tLUJFR0lOIFBLQ1M3LS0tLS0KTUlJQ0RRWUpLb1pJaHZjTkFRY0RvSUlCL2pDQ0Fmb0NBUUF4Z2dHek1JSUJyd0lCQURDQmxqQ0JnVEVMTUFrRwpBMVVFQmhNQ1NWUXhFREFPQmdOVkJBZ01CMEpsY21kaGJXOHhHVEFYQmdOVkJBY01FRkJ2Ym5SbElGTmhiaUJRCmFXVjBjbTh4RnpBVkJnTlZCQW9NRGtGamRHRnNhWE1nVXk1d0xrRXVNU3d3S2dZRFZRUUREQ05CWTNSaGJHbHoKSUVOc2FXVnVkQ0JCZFhSb1pXNTBhV05oZEdsdmJpQkRRU0JITXdJUUlWbDBxNFg0M3JaVjB1RjJ4SUpUNlRBTgpCZ2txaGtpRzl3MEJBUUVGQUFTQ0FRQkh0YzF2ZUZPclVXTGV3SlIvREZMWFNxcHl2SEdCM2U0aGQyYzhTNjBjCk1UL0dnSlRMOUZNU0pKU1Y4UWVLUUFQNW1HRnJvWkUzWWZKaWtsVWRkUDVtOWhwZzhOUzJidTltR05qQTZpcFgKMmZ2QmsvZ25nVWthL0R5ZGVMdGpOTm1WQkhCMFkwd3VXQ0JhYW12d3lLVURXYmV3Z2t6ZUw4K2U3c28rVkVZUwpkL1MzamViR01LY0RrdjZ0NHdNdkkvWDB3aDhQU3JqWElaa1RxWk40RDhsbTVseHVmbHQ4b1dKbmpUZVY2d21RCmU3TE5hODEvdGZhU09TUlkyZ210aXhUckFPczVuM0Z3eGEwVjNJM0F5MWl0UkM2TWVMN1dBTk5ST2Z0WWVCSUsKUlQ0aDRrSjhVSFB6SUYxUE1iRjI2M09mSmJtaG1oTVpLUTExM0prZEJoVG5NRDRHQ1NxR1NJYjNEUUVIQVRBZApCZ2xnaGtnQlpRTUVBU29FRUhuaUM2TG5KdkZoK0VqbjlXeWVDaHVnRWdRUWJZTkZRcHcvQzRQc0lUQ2U3M2tRCndRPT0KLS0tLS1FTkQgUEtDUzctLS0tLQo=" + } + } + ] + }, + "sizeEstimate": 1573, + "historyId": "55298", + "internalDate": "1632156227000" + }, + "attachments": {}, + "raw": { + "id": "17c041fd27858466", + "threadId": "17c041fd27858466", + "labelIds": [ + "DRAFT", + "INBOX" + ], + "snippet": "[flowcrypt:link:draft_compose:mockfakedraftsave] -----BEGIN PKCS7----- MIICDQYJKoZIhvcNAQcDoIIB/jCCAfoCAQAxggGzMIIBrwIBADCBljCBgTELMAkG A1UEBhMCSVQxEDAOBgNVBAgMB0JlcmdhbW8xGTAXBgNVBAcMEFBvbnRlIFNhbiBQ", + "sizeEstimate": 1573, + "raw": "Q29udGVudC1UeXBlOiBtdWx0aXBhcnQvbWl4ZWQ7CiBib3VuZGFyeT0iLS0tLXNpbmlrYWVsLT89XzEtMTYzMjE1NjIyNzc1OTAuODg4MzY1MTczNzU2NTUyMyIKRnJvbTogZmxvd2NyeXB0LnRlc3Qua2V5LmltcG9ydGVkQGdtYWlsLmNvbQpUbzogc21pbWVAcmVjaXBpZW50LmNvbQpTdWJqZWN0OiBUZXN0IFMvTUlNRSBFbmNyeXB0ZWQgRHJhZnQKRGF0ZTogTW9uLCAyMCBTZXAgMjAyMSAxNjo0Mzo0NyArMDAwMApNZXNzYWdlLUlkOiA8MTYzMjE1NjIyNzc3MS1mYzBmNTRiYi0wZTFhYmQ2MS03MjEwNjcwMEBnbWFpbC5jb20+Ck1JTUUtVmVyc2lvbjogMS4wCgotLS0tLS1zaW5pa2FlbC0/PV8xLTE2MzIxNTYyMjc3NTkwLjg4ODM2NTE3Mzc1NjU1MjMKQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IHF1b3RlZC1wcmludGFibGUKCltmbG93Y3J5cHQ6bGluazpkcmFmdF9jb21wb3NlOm1vY2tmYWtlZHJhZnRzYXZlXQoKLS0tLS1CRUdJTiBQS0NTNy0tLS0tCk1JSUNEUVlKS29aSWh2Y05BUWNEb0lJQi9qQ0NBZm9DQVFBeGdnR3pNSUlCcndJQkFEQ0JsakNCZ1RFTE1Ba0cKQTFVRUJoTUNTVlF4RURBT0JnTlZCQWdNQjBKbGNtZGhiVzh4R1RBWEJnTlZCQWNNRUZCdmJuUmxJRk5oYmlCUQphV1YwY204eEZ6QVZCZ05WQkFvTURrRmpkR0ZzYVhNZ1V5NXdMa0V1TVN3d0tnWURWUVFERENOQlkzUmhiR2x6CklFTnNhV1Z1ZENCQmRYUm9aVzUwYVdOaGRHbHZiaUJEUVNCSE13SVFJVmwwcTRYNDNyWlYwdUYyeElKVDZUQU4KQmdrcWhraUc5dzBCQVFFRkFBU0NBUUJIdGMxdmVGT3JVV0xld0pSL0RGTFhTcXB5dkhHQjNlNGhkMmM4UzYwYwpNVC9HZ0pUTDlGTVNKSlNWOFFlS1FBUDVtR0Zyb1pFM1lmSmlrbFVkZFA1bTlocGc4TlMyYnU5bUdOakE2aXBYCjJmdkJrL2duZ1VrYS9EeWRlTHRqTk5tVkJIQjBZMHd1V0NCYWFtdnd5S1VEV2Jld2dremVMOCtlN3NvK1ZFWVMKZC9TM2plYkdNS2NEa3Y2dDR3TXZJL1gwd2g4UFNyalhJWmtUcVpONEQ4bG01bHh1Zmx0OG9XSm5qVGVWNndtUQplN0xOYTgxL3RmYVNPU1JZMmdtdGl4VHJBT3M1bjNGd3hhMFYzSTNBeTFpdFJDNk1lTDdXQU5OUk9mdFllQklLClJUNGg0a0o4VUhQeklGMVBNYkYyNjNPZkpibWhtaE1aS1ExMTNKa2RCaFRuTUQ0R0NTcUdTSWIzRFFFSEFUQWQKQmdsZ2hrZ0JaUU1FQVNvRUVIbmlDNkxuSnZGaCtFam45V3llQ2h1Z0VnUVFiWU5GUXB3L0M0UHNJVENlNzNrUQp3UT0zRD0zRAotLS0tLUVORCBQS0NTNy0tLS0tCgotLS0tLS1zaW5pa2FlbC0/PV8xLTE2MzIxNTYyMjc3NTkwLjg4ODM2NTE3Mzc1NjU1MjMtLQoK", + "historyId": "55298", + "internalDate": "1632156227000" + } +} diff --git a/test/source/mock/google/strategies/send-message-strategy.ts b/test/source/mock/google/strategies/send-message-strategy.ts index 8e77d752bc0..bd8386727f3 100644 --- a/test/source/mock/google/strategies/send-message-strategy.ts +++ b/test/source/mock/google/strategies/send-message-strategy.ts @@ -1,5 +1,6 @@ /* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */ +import * as forge from 'node-forge'; import { AddressObject, ParsedMail, StructuredHeader } from 'mailparser'; import { ITestMsgStrategy, UnsuportableStrategyError } from './strategy-base.js'; import { Buf } from '../../../core/buf'; @@ -175,9 +176,30 @@ class SmimeEncryptedMessageStrategy implements ITestMsgStrategy { expect(mimeMsg.attachments![0].contentType).to.equal('application/pkcs7-mime'); expect(mimeMsg.attachments![0].filename).to.equal('smime.p7m'); expect(mimeMsg.attachments![0].size).to.be.greaterThan(300); + const msg = new Buf(mimeMsg.attachments![0].content).toRawBytesStr(); + const p7 = forge.pkcs7.messageFromAsn1(forge.asn1.fromDer(msg)); + expect(p7.type).to.equal('1.2.840.113549.1.7.3'); } } +class SmimeSignedMessageStrategy implements ITestMsgStrategy { + public test = async (mimeMsg: ParsedMail) => { + expect((mimeMsg.headers.get('content-type') as StructuredHeader).value).to.equal('application/pkcs7-mime'); + expect((mimeMsg.headers.get('content-type') as StructuredHeader).params.name).to.equal('smime.p7m'); + expect((mimeMsg.headers.get('content-type') as StructuredHeader).params['smime-type']).to.equal('signed-data'); + expect(mimeMsg.headers.get('content-transfer-encoding')).to.equal('base64'); + expect((mimeMsg.headers.get('content-disposition') as StructuredHeader).value).to.equal('attachment'); + expect((mimeMsg.headers.get('content-disposition') as StructuredHeader).params.filename).to.equal('smime.p7m'); + expect(mimeMsg.headers.get('content-description')).to.equal('S/MIME Signed Message'); + expect(mimeMsg.attachments!.length).to.equal(1); + expect(mimeMsg.attachments![0].contentType).to.equal('application/pkcs7-mime'); + expect(mimeMsg.attachments![0].filename).to.equal('smime.p7m'); + expect(mimeMsg.attachments![0].size).to.be.greaterThan(300); + const msg = new Buf(mimeMsg.attachments![0].content).toRawBytesStr(); + const p7 = forge.pkcs7.messageFromAsn1(forge.asn1.fromDer(msg)); + expect(p7.type).to.equal('1.2.840.113549.1.7.2'); + } +} export class TestBySubjectStrategyContext { private strategy: ITestMsgStrategy; @@ -208,6 +230,8 @@ export class TestBySubjectStrategyContext { this.strategy = new SmimeEncryptedMessageStrategy(); } else if (subject.includes('send with S/MIME attachment')) { this.strategy = new SmimeEncryptedMessageStrategy(); + } else if (subject.includes('send signed S/MIME without attachment')) { + this.strategy = new SmimeSignedMessageStrategy(); } else { throw new UnsuportableStrategyError(`There isn't any strategy for this subject: ${subject}`); } diff --git a/test/source/tests/compose.ts b/test/source/tests/compose.ts index 098949b1609..fb4beb32a7b 100644 --- a/test/source/tests/compose.ts +++ b/test/source/tests/compose.ts @@ -582,6 +582,25 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te } })); + ava.default('compose - loading drafts - PKCS#7 encrypted draft', testWithBrowser(undefined, async (t, browser) => { + const acctEmail = 'flowcrypt.test.key.imported@gmail.com'; + const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acctEmail); + await SetupPageRecipe.setupSmimeAccount(settingsPage, { + title: 's/mime pkcs12 unprotected key', + filePath: 'test/samples/smime/human-unprotected-PKCS12.p12', + armored: null, // tslint:disable-line:no-null-keyword + passphrase: 'test pp to encrypt unprotected key', + longid: null // tslint:disable-line:no-null-keyword + }); + await settingsPage.close(); + const appendUrl = 'draftId=17c041fd27858466'; + const composePage = await ComposePageRecipe.openStandalone(t, browser, acctEmail, { appendUrl }); + await expectRecipientElements(composePage, { to: ['smime@recipient.com'] }); + const subjectElem = await composePage.waitAny('@input-subject'); + expect(await PageRecipe.getElementPropertyJson(subjectElem, 'value')).to.equal('Test S/MIME Encrypted Draft'); + expect((await composePage.read('@input-body')).trim()).to.equal('test text'); + })); + ava.default('compose - loading drafts - reply', testWithBrowser('compatibility', async (t, browser) => { const appendUrl = 'threadId=16cfa9001baaac0a&skipClickPrompt=___cu_false___&ignoreDraft=___cu_false___&replyMsgId=16cfa9001baaac0a&draftId=draft-3'; const composePage = await ComposePageRecipe.openStandalone(t, browser, 'compatibility', { appendUrl, hasReplyPrompt: true, skipClickPropt: true }); @@ -1031,6 +1050,22 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te ava.todo('compose - reply - skip click prompt'); + ava.default('send signed S/MIME message', testWithBrowser(undefined, async (t, browser) => { + const acctEmail = 'flowcrypt.test.key.imported@gmail.com'; + const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acctEmail); + await SetupPageRecipe.setupSmimeAccount(settingsPage, { + title: 's/mime pkcs12 unprotected key', + filePath: 'test/samples/smime/human-unprotected-PKCS12.p12', + armored: null, // tslint:disable-line:no-null-keyword + passphrase: 'test pp to encrypt unprotected key', + longid: null // tslint:disable-line:no-null-keyword + }); + await settingsPage.close(); + const composePage = await ComposePageRecipe.openStandalone(t, browser, acctEmail); + await ComposePageRecipe.fillMsg(composePage, { to: 'smime@recipient.com' }, 'send signed S/MIME without attachment', { encrypt: false, sign: true }); + await composePage.waitAndClick('@action-send', { delay: 2 }); + })); + ava.default('send with single S/MIME cert', testWithBrowser('ci.tests.gmail', async (t, browser) => { const inboxPage = await browser.newPage(t, TestUrls.extensionInbox('ci.tests.gmail@flowcrypt.test')); const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage); diff --git a/test/source/tests/page-recipe/setup-page-recipe.ts b/test/source/tests/page-recipe/setup-page-recipe.ts index c4804b0f271..f46b1448e87 100644 --- a/test/source/tests/page-recipe/setup-page-recipe.ts +++ b/test/source/tests/page-recipe/setup-page-recipe.ts @@ -1,6 +1,6 @@ /* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */ -import { Config, Util } from '../../util'; +import { Config, TestKeyInfoWithFilepath, Util } from '../../util'; import { ControllablePage } from '../../browser'; import { PageRecipe } from './abstract-page-recipe'; @@ -19,7 +19,7 @@ type ManualEnterOpts = { enforceAttesterSubmitOrgRule?: boolean, noPubSubmitRule?: boolean, fillOnly?: boolean, - key?: { title: string, passphrase: string, armored: string | null, longid: string | null, filePath?: string } + key?: TestKeyInfoWithFilepath }; type CreateKeyOpts = { @@ -266,6 +266,15 @@ export class SetupPageRecipe extends PageRecipe { } } + public static setupSmimeAccount = async (settingsPage: ControllablePage, key: TestKeyInfoWithFilepath) => { + await SetupPageRecipe.manualEnter(settingsPage, key.title, { fillOnly: true, submitPubkey: false, usedPgpBefore: false, key }); + await settingsPage.waitAndClick('@input-step2bmanualenter-save', { delay: 1 }); + await Util.sleep(1); + await settingsPage.waitAndRespondToModal('confirm', 'confirm', 'Using S/MIME as the only key on account is experimental.'); + await settingsPage.waitAndClick('@action-step4done-account-settings', { delay: 1 }); + await SettingsPageRecipe.ready(settingsPage); + }; + private static createBegin = async (settingsPage: ControllablePage, keyTitle: string, { key, usedPgpBefore = false }: { key?: { passphrase: string }, usedPgpBefore?: boolean } = {}) => { const k = key || Config.key(keyTitle); if (usedPgpBefore) { diff --git a/test/source/tests/setup.ts b/test/source/tests/setup.ts index 641cdc6e5e2..794d4a127fa 100644 --- a/test/source/tests/setup.ts +++ b/test/source/tests/setup.ts @@ -741,19 +741,13 @@ AN8G3r5Htj8olot+jm9mIa5XLXWzMNUZgg== 'setup - s/mime private key', testWithBrowser(undefined, async (t, browser) => { const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, 'flowcrypt.test.key.imported@gmail.com'); - const key = { + await SetupPageRecipe.setupSmimeAccount(settingsPage, { title: 's/mime pkcs12 unprotected key', filePath: 'test/samples/smime/human-unprotected-PKCS12.p12', armored: null, // tslint:disable-line:no-null-keyword passphrase: 'test pp to encrypt unprotected key', longid: null // tslint:disable-line:no-null-keyword - }; - await SetupPageRecipe.manualEnter(settingsPage, key.title, { fillOnly: true, submitPubkey: false, usedPgpBefore: false, key }); - await settingsPage.waitAndClick('@input-step2bmanualenter-save', { delay: 1 }); - await Util.sleep(1); - await settingsPage.waitAndRespondToModal('confirm', 'confirm', 'Using S/MIME as the only key on account is experimental.'); - await settingsPage.waitAndClick('@action-step4done-account-settings', { delay: 1 }); - await SettingsPageRecipe.ready(settingsPage); + }); }) ); diff --git a/test/source/tests/unit-node.ts b/test/source/tests/unit-node.ts index b46eb41a30e..83575eb551d 100644 --- a/test/source/tests/unit-node.ts +++ b/test/source/tests/unit-node.ts @@ -643,7 +643,7 @@ ${testConstants.smimeCert}`), { instanceOf: Error, message: `Invalid PEM formatt ava.default('[unit][KeyUtil.parse] issuerAndSerialNumber of S/MIME certificate is constructed according to PKCS#7', async t => { const key = await KeyUtil.parse(testConstants.smimeCert); const buf = Buf.with((await MsgUtil.encryptMessage( - { pubkeys: [key], data: Buf.fromUtfStr('anything'), armor: true }) as PgpMsgMethod.EncryptX509Result).data); + { pubkeys: [key], data: Buf.fromUtfStr('anything'), armor: false }) as PgpMsgMethod.EncryptX509Result).data); const raw = buf.toRawBytesStr(); expect(raw).to.include(key.issuerAndSerialNumber); t.pass(); @@ -1753,6 +1753,23 @@ jA== t.pass(); }); + ava.default('[unit][SmimeKey.decryptMessage] decrypts an armored S/MIME PKCS#7 message', async t => { + const p8 = readFileSync("test/samples/smime/human-unprotected-pem.txt", 'utf8'); + const privateSmimeKey = await KeyUtil.parse(p8); + const publicSmimeKey = await KeyUtil.asPublicKey(privateSmimeKey); + const text = 'this is a text to be encrypted'; + const buf = Buf.with((await MsgUtil.encryptMessage( + { pubkeys: [publicSmimeKey], data: Buf.fromUtfStr(text), armor: true }) as PgpMsgMethod.EncryptX509Result).data); + const encryptedMessage = buf.toRawBytesStr(); + expect(encryptedMessage).to.include(PgpArmor.headers('pkcs7').begin); + const p7 = SmimeKey.readArmoredPkcs7Message(buf); + expect(p7.type).to.equal('1.2.840.113549.1.7.3'); + const decrypted = SmimeKey.decryptMessage(p7 as forge.pkcs7.PkcsEnvelopedData, privateSmimeKey); + const decryptedMessage = Buf.with(decrypted).toRawBytesStr(); + expect(decryptedMessage).to.equal(text); + t.pass(); + }); + ava.default(`[unit][OpenPGPKey.parse] sets usableForEncryption and usableForSigning to false for RSA key less than 2048`, async t => { const rsa1024secret = `-----BEGIN PGP PRIVATE KEY BLOCK----- diff --git a/test/source/util/index.ts b/test/source/util/index.ts index 2be5f7bc255..a1419fa4163 100644 --- a/test/source/util/index.ts +++ b/test/source/util/index.ts @@ -41,10 +41,16 @@ export type TestMessage = { signature?: string[], }; +export type TestKeyInfo = { + title: string, passphrase: string, armored: string | null, longid: string | null +}; + +export type TestKeyInfoWithFilepath = TestKeyInfo & { filePath?: string }; + interface TestSecretsInterface { ci_admin_token: string; auth: { google: { email: string, password?: string, secret_2fa?: string }[], }; - keys: { title: string, passphrase: string, armored: string | null, longid: string | null }[]; + keys: TestKeyInfo[]; } export class Config {