diff --git a/extension/chrome/elements/compose-modules/compose-err-module.ts b/extension/chrome/elements/compose-modules/compose-err-module.ts index 866b810308d..5d781483da9 100644 --- a/extension/chrome/elements/compose-modules/compose-err-module.ts +++ b/extension/chrome/elements/compose-modules/compose-err-module.ts @@ -9,7 +9,6 @@ import { NewMsgData, SendBtnTexts } from './compose-types.js'; import { ApiErr } from '../../../js/common/api/shared/api-error.js'; import { BrowserExtension } from '../../../js/common/browser/browser-extension.js'; import { BrowserMsg } from '../../../js/common/browser/browser-msg.js'; -import { KeyInfo } from '../../../js/common/core/crypto/key.js'; import { Settings } from '../../../js/common/settings.js'; import { Str } from '../../../js/common/core/common.js'; import { Xss } from '../../../js/common/platform/xss.js'; @@ -131,10 +130,9 @@ export class ComposeErrModule extends ViewModule { } } - public throwIfEncryptionPasswordInvalid = async (senderKi: KeyInfo, { subject, pwd }: { subject: string, pwd?: string }) => { + public throwIfEncryptionPasswordInvalid = async ({ subject, pwd }: { subject: string, pwd?: string }) => { if (pwd) { - const pp = await this.view.storageModule.passphraseGet(senderKi); - if (pp && pwd.toLowerCase() === pp.toLowerCase()) { + if (await this.view.storageModule.isPwdMatchingPassphrase(pwd)) { throw new ComposerUserError('Please do not use your private key pass phrase as a password for this message.\n\n' + 'You should come up with some other unique password that you can share with recipient.'); } diff --git a/extension/chrome/elements/compose-modules/compose-my-pubkey-module.ts b/extension/chrome/elements/compose-modules/compose-my-pubkey-module.ts index f30813b0777..1e09ada4e5a 100644 --- a/extension/chrome/elements/compose-modules/compose-my-pubkey-module.ts +++ b/extension/chrome/elements/compose-modules/compose-my-pubkey-module.ts @@ -3,7 +3,7 @@ 'use strict'; import { ApiErr } from '../../../js/common/api/shared/api-error.js'; -import { KeyInfo, KeyUtil } from '../../../js/common/core/crypto/key.js'; +import { KeyUtil } from '../../../js/common/core/crypto/key.js'; import { Lang } from '../../../js/common/lang.js'; import { Ui } from '../../../js/common/browser/ui.js'; import { ViewModule } from '../../../js/common/view-module.js'; @@ -31,21 +31,13 @@ export class ComposeMyPubkeyModule extends ViewModule { return this.view.S.cached('icon_pubkey').is('.active'); } - public chooseMyPublicKeyBySenderEmail = async (keys: KeyInfo[], email: string) => { - for (const key of keys) { - if (key.emails?.includes(email.toLowerCase())) { - return key; - } - } - return undefined; - } - public reevaluateShouldAttachOrNot = () => { if (this.toggledManually) { // leave it as is if toggled manually before return; } (async () => { const senderEmail = this.view.senderModule.getSender(); + // todo: disable attaching S/MIME certificate #4075 const senderKi = await this.view.storageModule.getKey(senderEmail); const primaryFingerprint = (await KeyUtil.parse(senderKi.private)).id; // if we have cashed this fingerprint, setAttachPreference(false) rightaway and return diff --git a/extension/chrome/elements/compose-modules/compose-send-btn-module.ts b/extension/chrome/elements/compose-modules/compose-send-btn-module.ts index 63d22dfc71e..d9878c0a653 100644 --- a/extension/chrome/elements/compose-modules/compose-send-btn-module.ts +++ b/extension/chrome/elements/compose-modules/compose-send-btn-module.ts @@ -13,7 +13,7 @@ import { ComposerUserError } from './compose-err-module.js'; import { ComposeSendBtnPopoverModule } from './compose-send-btn-popover-module.js'; import { GeneralMailFormatter } from './formatters/general-mail-formatter.js'; import { GmailRes } from '../../../js/common/api/email-provider/gmail/gmail-parser.js'; -import { KeyInfo, Key, KeyUtil } from '../../../js/common/core/crypto/key.js'; +import { KeyInfo } from '../../../js/common/core/crypto/key.js'; import { SendBtnTexts } from './compose-types.js'; import { SendableMsg } from '../../../js/common/api/email-provider/sendable-msg.js'; import { Str } from '../../../js/common/core/common.js'; @@ -112,18 +112,12 @@ export class ComposeSendBtnModule extends ViewModule { this.view.S.cached('send_btn_note').text(''); const newMsgData = this.view.inputModule.extractAll(); await this.view.errModule.throwIfFormValsInvalid(newMsgData); - const senderKi = await this.view.storageModule.getKey(this.view.senderModule.getSender()); - let signingPrv: Key | undefined; - if (this.popover.choices.sign) { - signingPrv = await this.decryptSenderKey(senderKi); - if (!signingPrv) { - return; // user has canceled the pass phrase dialog, or didn't respond to it in time - } - } await ContactStore.update(undefined, Array.prototype.concat.apply([], Object.values(newMsgData.recipients)), { lastUse: Date.now() }); - const msgObj = await GeneralMailFormatter.processNewMsg(this.view, newMsgData, senderKi, signingPrv); - await this.finalizeSendableMsg(msgObj, senderKi); - await this.doSendMsg(msgObj); + const msgObj = await GeneralMailFormatter.processNewMsg(this.view, newMsgData); + if (msgObj) { + await this.finalizeSendableMsg(msgObj); + await this.doSendMsg(msgObj.msg); + } } catch (e) { await this.view.errModule.handleSendErr(e); } finally { @@ -132,7 +126,7 @@ export class ComposeSendBtnModule extends ViewModule { } } - private finalizeSendableMsg = async (msg: SendableMsg, senderKi: KeyInfo) => { + private finalizeSendableMsg = async ({ msg, senderKi }: { msg: SendableMsg, senderKi: KeyInfo | undefined }) => { const choices = this.view.sendBtnModule.popover.choices; for (const k of Object.keys(this.additionalMsgHeaders)) { msg.headers[k] = this.additionalMsgHeaders[k]; @@ -149,7 +143,7 @@ export class ComposeSendBtnModule extends ViewModule { msg.body['text/html'] = htmlWithCidImages; msg.attachments.push(...imgAttachments); } - if (this.view.myPubkeyModule.shouldAttach()) { + if (this.view.myPubkeyModule.shouldAttach() && senderKi) { // todo: report on undefined? msg.attachments.push(Attachment.keyinfoAsPubkeyAttachment(senderKi)); } await this.addNamesToMsg(msg); @@ -230,25 +224,6 @@ export class ComposeSendBtnModule extends ViewModule { } } - private decryptSenderKey = async (senderKi: KeyInfo): Promise => { - const prv = await KeyUtil.parse(senderKi.private); - const passphrase = await this.view.storageModule.passphraseGet(senderKi); - if (typeof passphrase === 'undefined' && !prv.fullyDecrypted) { - BrowserMsg.send.passphraseDialog(this.view.parentTabId, { type: 'sign', longids: [senderKi.longid] }); - if ((typeof await this.view.storageModule.whenMasterPassphraseEntered(60)) !== 'undefined') { // pass phrase entered - return await this.decryptSenderKey(senderKi); - } else { // timeout - reset - no passphrase entered - this.resetSendBtn(); - return undefined; - } - } else { - if (!prv.fullyDecrypted) { - await KeyUtil.decrypt(prv, passphrase!); // checked !== undefined above - } - return prv; - } - } - private addNamesToMsg = async (msg: SendableMsg): Promise => { const { sendAs } = await AcctStore.get(this.view.acctEmail, ['sendAs']); const addNameToEmail = async (emails: string[]): Promise => { diff --git a/extension/chrome/elements/compose-modules/compose-storage-module.ts b/extension/chrome/elements/compose-modules/compose-storage-module.ts index 00ac7b82f15..af5ccb0ee70 100644 --- a/extension/chrome/elements/compose-modules/compose-storage-module.ts +++ b/extension/chrome/elements/compose-modules/compose-storage-module.ts @@ -3,11 +3,11 @@ 'use strict'; import { Bm, BrowserMsg } from '../../../js/common/browser/browser-msg.js'; -import { KeyInfo, KeyUtil, Key } from '../../../js/common/core/crypto/key.js'; +import { KeyInfo, KeyUtil, Key, PubkeyResult } from '../../../js/common/core/crypto/key.js'; import { ApiErr } from '../../../js/common/api/shared/api-error.js'; import { Assert } from '../../../js/common/assert.js'; -import { Catch } from '../../../js/common/platform/catch.js'; -import { CollectPubkeysResult } from './compose-types.js'; +import { Catch, UnreportableError } from '../../../js/common/platform/catch.js'; +import { CollectKeysResult } from './compose-types.js'; import { PUBKEY_LOOKUP_RESULT_FAIL } from './compose-err-module.js'; import { ViewModule } from '../../../js/common/view-module.js'; import { ComposeView } from '../compose.js'; @@ -31,48 +31,115 @@ export class ComposeStorageModule extends ViewModule { }); } - public getKey = async (senderEmail: string): Promise => { - const keys = await KeyStore.get(this.view.acctEmail); - let result = await this.view.myPubkeyModule.chooseMyPublicKeyBySenderEmail(keys, senderEmail); - if (!result) { - this.view.errModule.debug(`ComposerStorage.getKey: could not find key based on senderEmail: ${senderEmail}, using primary instead`); - result = keys[0]; - Assert.abortAndRenderErrorIfKeyinfoEmpty(result); + // if `type` is supplied, returns undefined if no keys of this type are found + public getKeyOptional = async (senderEmail: string | undefined, type?: 'openpgp' | 'x509' | undefined) => { + const keys = await KeyStore.getTypedKeyInfos(this.view.acctEmail); + let result: KeyInfo | undefined; + if (senderEmail !== undefined) { + const filteredKeys = KeyUtil.filterKeysByTypeAndSenderEmail(keys, senderEmail, type); + if (type === undefined) { + // prioritize openpgp + result = filteredKeys.find(key => key.type === 'openpgp'); + } + if (result === undefined) { + result = filteredKeys[0]; + } + } + if (result === undefined) { + this.view.errModule.debug(`ComposerStorage.getKeyOptional: could not find key based on senderEmail: ${senderEmail}, using primary instead`); + result = keys.find(k => type === undefined || type === k.type); } else { - this.view.errModule.debug(`ComposerStorage.getKey: found key based on senderEmail: ${senderEmail}`); + this.view.errModule.debug(`ComposerStorage.getKeyOptional: found key based on senderEmail: ${senderEmail}`); } + return result; + } + + public getKey = async (senderEmail: string | undefined, type?: 'openpgp' | 'x509' | undefined): Promise => { + const result = await this.getKeyOptional(senderEmail, type); + Assert.abortAndRenderErrorIfKeyinfoEmpty(result); this.view.errModule.debug(`ComposerStorage.getKey: returning key longid: ${result!.longid}`); return result!; } - public collectAllAvailablePublicKeys = async (senderEmail: string, senderKi: KeyInfo, recipients: string[]): Promise => { + // used when encryption is needed + // returns a set of keys of a single family ('openpgp' or 'x509') + public collectSingleFamilyKeys = async (recipients: string[], senderEmail: string, needSigning: boolean): Promise => { const contacts = await ContactStore.getEncryptionKeys(undefined, recipients); - const pubkeys = [{ pubkey: await KeyUtil.parse(senderKi.public), email: senderEmail, isMine: true }]; - const emailsWithoutPubkeys = []; - for (const contact of contacts) { - let keysPerEmail = contact.keys; - // if non-expired present, return non-expired only - if (keysPerEmail.some(k => k.usableForEncryption)) { - keysPerEmail = keysPerEmail.filter(k => k.usableForEncryption); + const resultsPerType: { [type: string]: CollectKeysResult } = {}; + const OPENPGP = 'openpgp'; + const X509 = 'x509'; + for (const i of [OPENPGP, X509]) { + const type = i as ('openpgp' | 'x509'); + // senderKi for draft encryption! + const senderKi = await this.getKeyOptional(senderEmail, type); + const { pubkeys, emailsWithoutPubkeys } = this.collectPubkeysByType(type, contacts); + if (senderKi !== undefined) { + // add own key for encryption + pubkeys.push({ pubkey: await KeyUtil.parse(senderKi.public), email: senderEmail, isMine: true }); } - if (keysPerEmail.length) { - for (const pubkey of keysPerEmail) { - pubkeys.push({ pubkey, email: contact.email, isMine: false }); - } - } else { - emailsWithoutPubkeys.push(contact.email); + const result = { senderKi, pubkeys, emailsWithoutPubkeys }; + if (!emailsWithoutPubkeys.length && (senderKi !== undefined || !needSigning)) { + return result; // return right away } + resultsPerType[type] = result; } - return { pubkeys, emailsWithoutPubkeys }; + // per discussion https://github.com/FlowCrypt/flowcrypt-browser/issues/4069#issuecomment-957313631 + // if one emailsWithoutPubkeys isn't subset of the other, throw an error + if (!resultsPerType[OPENPGP].emailsWithoutPubkeys.every(email => resultsPerType[X509].emailsWithoutPubkeys.includes(email)) && + !resultsPerType[X509].emailsWithoutPubkeys.every(email => resultsPerType[OPENPGP].emailsWithoutPubkeys.includes(email))) { + let err = `Cannot use mixed OpenPGP (${resultsPerType[OPENPGP].pubkeys.filter(p => !p.isMine).map(p => p.email).join(', ')}) and ` + + `S/MIME (${resultsPerType[X509].pubkeys.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); + } + const rank = (x: [string, CollectKeysResult]) => { + return x[1].emailsWithoutPubkeys.length * 100 + (x[1].senderKi ? 0 : 10) + (x[0] === 'openpgp' ? 0 : 1); + }; + return Object.entries(resultsPerType).sort((a, b) => rank(a) - rank(b))[0][1]; } - public passphraseGet = async (senderKi?: KeyInfo) => { + public passphraseGet = async (senderKi?: { longid: string }) => { if (!senderKi) { senderKi = await KeyStore.getFirstRequired(this.view.acctEmail); } return await PassphraseStore.get(this.view.acctEmail, senderKi); } + public decryptSenderKey = async (senderKi: KeyInfo): Promise => { + const prv = await KeyUtil.parse(senderKi.private); + const passphrase = await this.passphraseGet(senderKi); + if (typeof passphrase === 'undefined' && !prv.fullyDecrypted) { + BrowserMsg.send.passphraseDialog(this.view.parentTabId, { type: 'sign', longids: [senderKi.longid] }); + if ((typeof await this.whenMasterPassphraseEntered(60)) !== 'undefined') { // pass phrase entered + return await this.decryptSenderKey(senderKi); + } else { // timeout - reset - no passphrase entered + this.view.sendBtnModule.resetSendBtn(); + return undefined; + } + } else { + if (!prv.fullyDecrypted) { + await KeyUtil.decrypt(prv, passphrase!); // checked !== undefined above + } + return prv; + } + } + + public isPwdMatchingPassphrase = async (pwd: string): Promise => { + const kis = await KeyStore.get(this.view.acctEmail); + for (const ki of kis) { + const pp = await PassphraseStore.get(this.view.acctEmail, ki, true); + if (pp && pwd.toLowerCase() === pp.toLowerCase()) { + return true; + } + // check whether this pwd unlocks the ki + const parsed = await KeyUtil.parse(ki.private); + if (!parsed.fullyDecrypted && await KeyUtil.decrypt(parsed, pwd)) { + return true; + } + } + return false; + } + public lookupPubkeyFromKeyserversThenOptionallyFetchExpiredByFingerprintAndUpsertDb = async ( email: string, name: string | undefined ): Promise => { @@ -217,4 +284,23 @@ export class ComposeStorageModule extends ViewModule { } } + private collectPubkeysByType = (type: 'openpgp' | 'x509', contacts: { email: string, keys: Key[] }[]): { pubkeys: PubkeyResult[], emailsWithoutPubkeys: string[] } => { + const pubkeys: PubkeyResult[] = []; + const emailsWithoutPubkeys: string[] = []; + for (const contact of contacts) { + let keysPerEmail = contact.keys.filter(k => k.type === type); + // if non-expired present, return non-expired only + if (keysPerEmail.some(k => k.usableForEncryption)) { + keysPerEmail = keysPerEmail.filter(k => k.usableForEncryption); + } + if (keysPerEmail.length) { + for (const pubkey of keysPerEmail) { + pubkeys.push({ pubkey, email: contact.email, isMine: false }); + } + } else { + emailsWithoutPubkeys.push(contact.email); + } + } + return { pubkeys, emailsWithoutPubkeys }; + } } diff --git a/extension/chrome/elements/compose-modules/compose-types.ts b/extension/chrome/elements/compose-modules/compose-types.ts index 90032d54eac..c3c586308ce 100644 --- a/extension/chrome/elements/compose-modules/compose-types.ts +++ b/extension/chrome/elements/compose-modules/compose-types.ts @@ -4,7 +4,7 @@ import { RecipientType } from '../../../js/common/api/shared/api.js'; import { Recipients } from '../../../js/common/api/email-provider/email-provider-api.js'; -import { PubkeyResult } from '../../../js/common/core/crypto/key.js'; +import { KeyInfo, PubkeyResult } from '../../../js/common/core/crypto/key.js'; export enum RecipientStatus { EVALUATING, @@ -37,7 +37,7 @@ export type MessageToReplyOrForward = { decryptedFiles: File[] }; -export type CollectPubkeysResult = { pubkeys: PubkeyResult[], emailsWithoutPubkeys: string[] }; +export type CollectKeysResult = { pubkeys: PubkeyResult[], emailsWithoutPubkeys: string[], senderKi: KeyInfo | undefined }; export type PopoverOpt = 'encrypt' | 'sign' | 'richtext'; export type PopoverChoices = { [key in PopoverOpt]: boolean }; diff --git a/extension/chrome/elements/compose-modules/formatters/base-mail-formatter.ts b/extension/chrome/elements/compose-modules/formatters/base-mail-formatter.ts index 934ee612195..4e804e223f6 100644 --- a/extension/chrome/elements/compose-modules/formatters/base-mail-formatter.ts +++ b/extension/chrome/elements/compose-modules/formatters/base-mail-formatter.ts @@ -4,6 +4,10 @@ import { NewMsgData } from '../compose-types.js'; import { ComposeView } from '../../compose.js'; +import { Key } from '../../../../js/common/core/crypto/key.js'; +import { SmimeKey } from '../../../../js/common/core/crypto/smime/smime-key.js'; +import { SendableMsg } from '../../../../js/common/api/email-provider/sendable-msg.js'; +import { Buf } from '../../../../js/common/core/buf.js'; export class BaseMailFormatter { @@ -21,4 +25,8 @@ export class BaseMailFormatter { return { from: newMsg.from, recipients: newMsg.recipients, subject: newMsg.subject, thread: this.view.threadId }; } + protected signMimeMessage = async (signingPrv: Key, mimeEncodedMessage: string, newMsg: NewMsgData) => { + const data = await SmimeKey.sign(signingPrv, Buf.fromUtfStr(mimeEncodedMessage)); + return await SendableMsg.createSMimeSigned(this.acctEmail, this.headers(newMsg), data); + } } 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 5b02d02ca5f..f74b44d1697 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 @@ -100,8 +100,7 @@ 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); + const pubsForEncryption = pubs.map(entry => entry.pubkey); 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 }); @@ -111,7 +110,12 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { const attachments: Attachment[] = this.isDraft ? [] : await this.view.attachmentsModule.attachment.collectAttachments(); // collects attachments 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), armor: false }); + let mimeData = Buf.fromUtfStr(mimeEncodedPlainMessage); + if (signingPrv) { + const signedMessage = await this.signMimeMessage(signingPrv, mimeEncodedPlainMessage, newMsg); + mimeData = Buf.fromUtfStr(await signedMessage.toMime()); + } + const encryptedMessage = await SmimeKey.encryptMessage({ pubkeys: x509certs, data: mimeData, armor: false }); const data = encryptedMessage.data; return await SendableMsg.createSMimeEncrypted(this.acctEmail, this.headers(newMsg), data, { isDraft: this.isDraft }); } else { // openpgp @@ -146,7 +150,7 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { private encryptDataArmor = async (data: Buf, pwd: string | undefined, pubs: PubkeyResult[], signingPrv?: Key): Promise => { const pgpPubs = pubs.filter(pub => pub.pubkey.type === 'openpgp'); const encryptAsOfDate = await this.encryptMsgAsOfDateIfSomeAreExpiredAndUserConfirmedModal(pgpPubs); - const pubsForEncryption = KeyUtil.choosePubsBasedOnKeyTypeCombinationForPartialSmimeSupport(pubs); + const pubsForEncryption = pubs.map(entry => entry.pubkey); return await MsgUtil.encryptMessage({ pubkeys: pubsForEncryption, signingPrv, pwd, data, armor: true, date: encryptAsOfDate }) as PgpMsgMethod.EncryptAnyArmorResult; } diff --git a/extension/chrome/elements/compose-modules/formatters/general-mail-formatter.ts b/extension/chrome/elements/compose-modules/formatters/general-mail-formatter.ts index dbe432c35e0..5bc686cbeef 100644 --- a/extension/chrome/elements/compose-modules/formatters/general-mail-formatter.ts +++ b/extension/chrome/elements/compose-modules/formatters/general-mail-formatter.ts @@ -3,7 +3,7 @@ 'use strict'; import { EncryptedMsgMailFormatter } from './encrypted-mail-msg-formatter.js'; -import { KeyInfo, Key } from "../../../../js/common/core/crypto/key.js"; +import { Key, KeyInfo } from "../../../../js/common/core/crypto/key.js"; import { NewMsgData } from "../compose-types.js"; import { PlainMsgMailFormatter } from './plain-mail-msg-formatter.js'; import { SendableMsg } from '../../../../js/common/api/email-provider/sendable-msg.js'; @@ -12,23 +12,36 @@ import { ComposeView } from '../../compose.js'; export class GeneralMailFormatter { - public static processNewMsg = async (view: ComposeView, newMsgData: NewMsgData, senderKi: KeyInfo, signingPrv?: Key): Promise => { + // returns undefined in case user cancelled decryption of the signing key + public static processNewMsg = async (view: ComposeView, newMsgData: NewMsgData): Promise<{ msg: SendableMsg, senderKi: KeyInfo | undefined } | undefined> => { const choices = view.sendBtnModule.popover.choices; const recipientsEmails = Array.prototype.concat.apply([], Object.values(newMsgData.recipients).filter(arr => !!arr)) as string[]; if (!choices.encrypt && !choices.sign) { // plain - return await new PlainMsgMailFormatter(view).sendableMsg(newMsgData); + return { senderKi: undefined, msg: await new PlainMsgMailFormatter(view).sendableMsg(newMsgData) }; } + let signingPrv: Key | undefined; if (!choices.encrypt && choices.sign) { // sign only view.S.now('send_btn_text').text('Signing...'); - return await new SignedMsgMailFormatter(view).sendableMsg(newMsgData, signingPrv!); + const senderKi = await view.storageModule.getKey(newMsgData.from); + signingPrv = await view.storageModule.decryptSenderKey(senderKi); + if (!signingPrv) { + return undefined; + } + return { senderKi, msg: await new SignedMsgMailFormatter(view).sendableMsg(newMsgData, signingPrv) }; } // encrypt (optionally sign) - const { pubkeys, emailsWithoutPubkeys } = await view.storageModule.collectAllAvailablePublicKeys(newMsgData.from, senderKi, recipientsEmails); - if (emailsWithoutPubkeys.length) { - await view.errModule.throwIfEncryptionPasswordInvalid(senderKi, newMsgData); + const result = await view.storageModule.collectSingleFamilyKeys(recipientsEmails, newMsgData.from, choices.sign); + if (choices.sign && result.senderKi !== undefined) { + signingPrv = await view.storageModule.decryptSenderKey(result.senderKi); + if (!signingPrv) { + return undefined; + } + } + if (result.emailsWithoutPubkeys.length) { + await view.errModule.throwIfEncryptionPasswordInvalid(newMsgData); } view.S.now('send_btn_text').text('Encrypting...'); - return await new EncryptedMsgMailFormatter(view).sendableMsg(newMsgData, pubkeys, signingPrv); + return { senderKi: result.senderKi, msg: await new EncryptedMsgMailFormatter(view).sendableMsg(newMsgData, result.pubkeys, signingPrv) }; } } 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 186a0a44f81..edefc35d9b0 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 @@ -11,8 +11,6 @@ import { MsgUtil } from '../../../../js/common/core/crypto/pgp/msg-util.js'; import { SendableMsg } from '../../../../js/common/api/email-provider/sendable-msg.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 { @@ -26,8 +24,7 @@ export class SignedMsgMailFormatter extends BaseMailFormatter { } 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); + return await this.signMimeMessage(signingPrv, mimeEncodedPlainMessage, newMsg); } if (!this.richtext) { // Folding the lines or GMAIL WILL RAPE THE TEXT, regardless of what encoding is used diff --git a/extension/chrome/elements/pgp_pubkey.htm b/extension/chrome/elements/pgp_pubkey.htm index 66e7398ad7f..aef62d16e17 100644 --- a/extension/chrome/elements/pgp_pubkey.htm +++ b/extension/chrome/elements/pgp_pubkey.htm @@ -19,7 +19,7 @@
Fingerprint:
- +

diff --git a/extension/js/common/core/crypto/key.ts b/extension/js/common/core/crypto/key.ts
index 009ec6b9c04..496890c0776 100644
--- a/extension/js/common/core/crypto/key.ts
+++ b/extension/js/common/core/crypto/key.ts
@@ -3,7 +3,7 @@
 'use strict';
 
 import { Buf } from '../buf.js';
-import { Catch, UnreportableError } from '../../platform/catch.js';
+import { Catch } from '../../platform/catch.js';
 import { MsgBlockParser } from '../msg-block-parser.js';
 import { PgpArmor } from './pgp/pgp-armor.js';
 import { opgp } from './pgp/openpgpjs-custom.js';
@@ -96,6 +96,29 @@ export class KeyUtil {
     return kis.filter(ki => ids.some(i => KeyUtil.identityEquals(i, ki)));
   }
 
+  public static filterKeysByTypeAndSenderEmail = (keys: TypedKeyInfo[], email: string, type: 'openpgp' | 'x509' | undefined): TypedKeyInfo[] => {
+    let foundKeys: TypedKeyInfo[] = [];
+    if (type) {
+      foundKeys = keys.filter(key => key.emails?.includes(email.toLowerCase()) && key.type === type);
+      if (!foundKeys.length) {
+        foundKeys = keys.filter(key => key.type === type);
+      }
+    } else {
+      foundKeys = keys.filter(key => key.emails?.includes(email.toLowerCase()));
+      if (!foundKeys.length) {
+        foundKeys = [...keys];
+      }
+    }
+    return foundKeys;
+  }
+
+  public static groupByType(items: T[]): { [type: string]: T[] } {
+    return items.reduce((rv: { [type: string]: T[] }, x: T) => {
+      (rv[x.type] = rv[x.type] || []).push(x);
+      return rv;
+    }, {});
+  }
+
   public static isWithoutSelfCertifications = async (key: Key) => {
     // all non-OpenPGP keys are automatically considered to be not
     // "without self certifications"
@@ -301,26 +324,6 @@ export class KeyUtil {
     }
   }
 
-  public static choosePubsBasedOnKeyTypeCombinationForPartialSmimeSupport = (pubs: PubkeyResult[]): Key[] => {
-    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 pgpPubs.concat(smimePubs).map(p => p.pubkey);
-  }
-
   public static decrypt = async (key: Key, passphrase: string, optionalKeyid?: OpenPGP.Keyid, optionalBehaviorFlag?: 'OK-IF-ALREADY-DECRYPTED'): Promise => {
     if (key.type === 'openpgp') {
       return await OpenPGPKey.decryptKey(key, passphrase, optionalKeyid, optionalBehaviorFlag);
diff --git a/extension/js/common/core/crypto/pgp/pgp-armor.ts b/extension/js/common/core/crypto/pgp/pgp-armor.ts
index d1c3e11a26c..5ef859d895f 100644
--- a/extension/js/common/core/crypto/pgp/pgp-armor.ts
+++ b/extension/js/common/core/crypto/pgp/pgp-armor.ts
@@ -9,7 +9,7 @@ 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';
+import { SmimeKey, ENVELOPED_DATA_OID } from '../smime/smime-key.js';
 
 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 }
@@ -92,7 +92,7 @@ export class PgpArmor {
     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') {
+      if (p7.type !== ENVELOPED_DATA_OID) {
         throw new Error('Not implemented');
       }
       return { isArmored: true, isCleartext: false, isPkcs7: true, message: p7 };
diff --git a/extension/js/common/core/crypto/smime/smime-key.ts b/extension/js/common/core/crypto/smime/smime-key.ts
index f67b504bc47..bb672e4fee9 100644
--- a/extension/js/common/core/crypto/smime/smime-key.ts
+++ b/extension/js/common/core/crypto/smime/smime-key.ts
@@ -9,6 +9,9 @@ import { MsgBlockParser } from '../../msg-block-parser.js';
 import { MsgBlock } from '../../msg-block.js';
 
 export type SmimeMsg = forge.pkcs7.PkcsEnvelopedData;
+export const DATA_OID = '1.2.840.113549.1.7.1';
+export const SIGNED_DATA_OID = '1.2.840.113549.1.7.2';
+export const ENVELOPED_DATA_OID = '1.2.840.113549.1.7.3';
 export class SmimeKey {
 
   public static parse = (text: string): Key => {
@@ -71,24 +74,36 @@ export class SmimeKey {
   /**
    * @param data: an already encoded plain mime message
    */
-  public static encryptMessage = async ({ pubkeys, data, armor }: { pubkeys: Key[], data: Uint8Array, armor: boolean }): Promise<{ data: Uint8Array, type: 'smime' }> => {
+  public static encryptMessage = async ({ pubkeys, data: input, armor }: { pubkeys: Key[], data: Uint8Array, armor: boolean }):
+    Promise<{ data: Uint8Array, type: 'smime' }> => {
     const p7 = forge.pkcs7.createEnvelopedData();
+    // collapse duplicate certificates into one
+    // check both fingerprints and longids (certificate Serial Number and Issuer)
+    const longids: string[] = [];
+    const ids: string[] = [];
     for (const pubkey of pubkeys) {
+      const longid = SmimeKey.getKeyLongid(pubkey);
+      if (ids.includes(pubkey.id) && longids.includes(longid)) {
+        continue;
+      } else {
+        ids.push(pubkey.id);
+        longids.push(longid);
+      }
       const certificate = SmimeKey.getCertificate(pubkey);
       if (SmimeKey.isKeyWeak(certificate)) {
         throw new Error(`The key can't be used for encryption as it doesn't meet the strength requirements`);
       }
       p7.addRecipient(certificate);
     }
-    p7.content = forge.util.createBuffer(data);
+    p7.content = forge.util.createBuffer(input);
     p7.encrypt();
-    let rawString: string;
+    let data: Uint8Array;
     if (armor) {
-      rawString = forge.pkcs7.messageToPem(p7);
+      data = Buf.fromRawBytesStr(forge.pkcs7.messageToPem(p7));
     } else {
-      rawString = forge.asn1.toDer(p7.toAsn1()).getBytes();
+      data = SmimeKey.messageToDer(p7);
     }
-    return { data: Buf.fromRawBytesStr(rawString), type: 'smime' };
+    return { data, type: 'smime' };
   }
 
   public static readArmoredPkcs7Message = (encrypted: Uint8Array):
@@ -125,8 +140,7 @@ export class SmimeKey {
     });
     p7.content = forge.util.createBuffer(data);
     p7.sign();
-    const derBuffer = forge.asn1.toDer(p7.toAsn1()).getBytes();
-    return Buf.fromRawBytesStr(derBuffer);
+    return SmimeKey.messageToDer(p7);
   }
 
   public static decryptKey = async (key: Key, passphrase: string, optionalBehaviorFlag?: 'OK-IF-ALREADY-DECRYPTED'): Promise => {
@@ -199,6 +213,11 @@ export class SmimeKey {
     });
   }
 
+  private static messageToDer = (p7: forge.pkcs7.Pkcs7Data): Uint8Array => {
+    const asn1 = p7.toAsn1();
+    return Buf.fromRawBytesStr(forge.asn1.toDer(asn1).getBytes());
+  }
+
   // 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();
diff --git a/extension/js/common/ui/attachment-ui.ts b/extension/js/common/ui/attachment-ui.ts
index 095cde87e8c..ff0a243ab43 100644
--- a/extension/js/common/ui/attachment-ui.ts
+++ b/extension/js/common/ui/attachment-ui.ts
@@ -7,7 +7,7 @@ import { Catch, UnreportableError } from '../platform/catch.js';
 import { Dict } from '../core/common.js';
 import { MsgUtil } from '../core/crypto/pgp/msg-util.js';
 import { Ui } from '../browser/ui.js';
-import { PubkeyResult, KeyUtil } from '../core/crypto/key.js';
+import { PubkeyResult } from '../core/crypto/key.js';
 
 declare const qq: any;
 
@@ -86,7 +86,7 @@ export class AttachmentUI {
     for (const uploadFileId of Object.keys(this.attachedFiles)) {
       const file = this.attachedFiles[uploadFileId];
       const data = await this.readAttachmentDataAsUint8(uploadFileId);
-      const pubsForEncryption = KeyUtil.choosePubsBasedOnKeyTypeCombinationForPartialSmimeSupport(pubs);
+      const pubsForEncryption = pubs.map(entry => entry.pubkey);
       if (pubs.find(pub => pub.pubkey.type === 'x509')) {
         throw new UnreportableError('Attachments are not yet supported when sending to recipients using S/MIME x509 certificates.');
       }
diff --git a/test/source/mock/google/strategies/send-message-strategy.ts b/test/source/mock/google/strategies/send-message-strategy.ts
index aaf0d46f712..fdf8ee80b90 100644
--- a/test/source/mock/google/strategies/send-message-strategy.ts
+++ b/test/source/mock/google/strategies/send-message-strategy.ts
@@ -9,9 +9,12 @@ import { expect } from 'chai';
 import { GoogleData } from '../google-data';
 import { HttpClientErr } from '../../lib/api';
 import { MsgUtil } from '../../../core/crypto/pgp/msg-util';
+import Parse from '../../../util/parse';
 import { parsedMailAddressObjectAsArray } from '../google-endpoints.js';
 import { Str } from '../../../core/common.js';
 import { GMAIL_RECOVERY_EMAIL_SUBJECTS } from '../../../core/const.js';
+import { ENVELOPED_DATA_OID, SIGNED_DATA_OID, SmimeKey } from '../../../core/crypto/smime/smime-key.js';
+import { testConstants } from '../../../tests/tooling/consts.js';
 
 // TODO: Make a better structure of ITestMsgStrategy. Because this class doesn't test anything, it only saves message in the Mock
 class SaveMessageInStorageStrategy implements ITestMsgStrategy {
@@ -176,10 +179,28 @@ class SmimeEncryptedMessageStrategy implements ITestMsgStrategy {
     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 withAttachments = mimeMsg.subject?.includes(' with attachment');
+    expect(mimeMsg.attachments![0].size).to.be.greaterThan(withAttachments ? 20000 : 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');
+    expect(p7.type).to.equal(ENVELOPED_DATA_OID);
+    if (p7.type === ENVELOPED_DATA_OID) {
+      const key = SmimeKey.parse(testConstants.testKeyMultipleSmimeCEA2D53BB9D24871);
+      const decrypted = SmimeKey.decryptMessage(p7, key);
+      const decryptedMessage = Buf.with(decrypted).toRawBytesStr();
+      if (mimeMsg.subject?.includes(' signed ')) {
+        expect(decryptedMessage).to.contain('smime-type=signed-data');
+        // todo: parse PKCS#7, check that is of SIGNED_DATA_OID content type, extract content?
+        // todo: #4046
+      } else {
+        expect(decryptedMessage).to.contain('This text should be encrypted into PKCS#7 data');
+        if (withAttachments) {
+          const nestedMimeMsg = await Parse.parseMixed(decryptedMessage);
+          expect(nestedMimeMsg.attachments!.length).to.equal(3);
+          expect(nestedMimeMsg.attachments![0].content.toString()).to.equal(`small text file\nnot much here\nthis worked\n`);
+        }
+      }
+    }
   }
 }
 
@@ -198,7 +219,7 @@ class SmimeSignedMessageStrategy implements ITestMsgStrategy {
     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');
+    expect(p7.type).to.equal(SIGNED_DATA_OID);
   }
 }
 export class TestBySubjectStrategyContext {
@@ -229,9 +250,7 @@ export class TestBySubjectStrategyContext {
       this.strategy = new SmimeEncryptedMessageStrategy();
     } else if (subject.includes('send with several S/MIME certs')) {
       this.strategy = new SmimeEncryptedMessageStrategy();
-    } else if (subject.includes('send with S/MIME attachment')) {
-      this.strategy = new SmimeEncryptedMessageStrategy();
-    } else if (subject.includes('send signed and encrypted S/MIME')) {
+    } else if (subject.includes('S/MIME message')) {
       this.strategy = new SmimeEncryptedMessageStrategy();
     } else if (subject.includes('send signed S/MIME without attachment')) {
       this.strategy = new SmimeSignedMessageStrategy();
diff --git a/test/source/tests/compose.ts b/test/source/tests/compose.ts
index 23a64eb7d16..f727cc201c0 100644
--- a/test/source/tests/compose.ts
+++ b/test/source/tests/compose.ts
@@ -77,6 +77,38 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te
       expect(await inboxPage.getOuterHeight('iframe')).to.eq(initialComposeFrameHeight);
     }));
 
+    ava.default('compose - trying to send PWD encrypted message with pass phrase - should show err', testWithBrowser('ci.tests.gmail', async (t, browser) => {
+      const acctEmail = 'ci.tests.gmail@flowcrypt.test';
+      const msgPwd = Config.key('ci.tests.gmail').passphrase;
+      const subject = 'PWD encrypted message with flowcrypt.com/api';
+      const composePage = await ComposePageRecipe.openStandalone(t, browser, acctEmail);
+      await ComposePageRecipe.fillMsg(composePage, { to: 'test@email.com' }, subject);
+      await composePage.waitAndType('@input-password', msgPwd);
+      await composePage.waitAndClick('@action-send', { delay: 1 });
+      await PageRecipe.waitForModalAndRespond(composePage, 'error', {
+        contentToCheck: 'Please do not use your private key pass phrase as a password for this message',
+        clickOn: 'confirm'
+      });
+      // changing case should result in this error too
+      await composePage.waitAndType('@input-password', msgPwd.toUpperCase());
+      await composePage.waitAndClick('@action-send', { delay: 1 });
+      await PageRecipe.waitForModalAndRespond(composePage, 'error', {
+        contentToCheck: 'Please do not use your private key pass phrase as a password for this message',
+        clickOn: 'confirm'
+      });
+      const forgottenPassphrase = 'this passphrase is forgotten';
+      await SettingsPageRecipe.addKeyTest(t, browser, acctEmail, testConstants.testKeyMultipleSmimeCEA2D53BB9D24871, forgottenPassphrase, {}, false);
+      const inboxPage = await browser.newPage(t, TestUrls.extensionInbox(acctEmail));
+      await InboxPageRecipe.finishSessionOnInboxPage(inboxPage);
+      await inboxPage.close();
+      await composePage.waitAndType('@input-password', forgottenPassphrase);
+      await composePage.waitAndClick('@action-send', { delay: 1 });
+      await PageRecipe.waitForModalAndRespond(composePage, 'error', {
+        contentToCheck: 'Please do not use your private key pass phrase as a password for this message',
+        clickOn: 'confirm'
+      });
+    }));
+
     ava.default('compose - signed with entered pass phrase + will remember pass phrase in session', testWithBrowser('ci.tests.gmail', async (t, browser) => {
       const k = Config.key('ci.tests.gmail');
       const settingsPage = await browser.newPage(t, TestUrls.extensionSettings('ci.tests.gmail@flowcrypt.test'));
@@ -1015,14 +1047,31 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te
       const composePage = await ComposePageRecipe.openStandalone(t, browser, acctEmail);
       await ComposePageRecipe.fillMsg(composePage, { to: 'smime@recipient.com' }, 'send signed S/MIME without attachment', undefined, { encrypt: false, sign: true });
       await composePage.waitAndClick('@action-send', { delay: 2 });
+      await composePage.waitForSelTestState('closed', 20); // succesfully sent
+      await composePage.close();
+    }));
+
+    ava.default('send signed and encrypted S/MIME message', testWithBrowser('compatibility', async (t, browser) => {
+      const acctEmail = 'flowcrypt.compatibility@gmail.com';
+      const passphrase = 'pa$$w0rd';
+      await SettingsPageRecipe.addKeyTest(t, browser, acctEmail, testConstants.testKeyMultipleSmimeCEA2D53BB9D24871, passphrase, {}, false);
+      const inboxPage = await browser.newPage(t, TestUrls.extensionInbox(acctEmail));
+      const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage);
+      await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime@recipient.com' }, 'send signed and encrypted S/MIME without attachment',
+        'This text should be encrypted into PKCS#7 data');
+      await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime@recipient.com',
+        testConstants.testCertificateMultipleSmimeCEA2D53BB9D24871);
+      await composeFrame.waitAndClick('@action-send', { delay: 2 });
+      await inboxPage.waitTillGone('@container-new-message');
     }));
 
     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);
-      await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime@recipient.com' }, t.title);
+      await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime@recipient.com' }, t.title,
+        'This text should be encrypted into PKCS#7 data', { sign: false, encrypt: true });
       await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime@recipient.com',
-        testConstants.smimeCert);
+        testConstants.testCertificateMultipleSmimeCEA2D53BB9D24871);
       await composeFrame.waitAndClick('@action-send', { delay: 2 });
       await inboxPage.waitTillGone('@container-new-message');
     }));
@@ -1030,19 +1079,41 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te
     ava.default('send with several S/MIME certs', 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);
-      await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime1@recipient.com', cc: 'smime2@recipient.com' }, t.title);
+      await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime1@recipient.com', cc: 'smime2@recipient.com' }, t.title,
+        'This text should be encrypted into PKCS#7 data', { sign: false, encrypt: true });
       await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime1@recipient.com', testConstants.smimeCert);
-      await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime2@recipient.com', testConstants.smimeCert);
+      await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime2@recipient.com', testConstants.testCertificateMultipleSmimeCEA2D53BB9D24871);
       await composeFrame.waitAndClick('@action-send', { delay: 2 });
       await inboxPage.waitTillGone('@container-new-message');
     }));
 
-    ava.default('send with S/MIME attachment', testWithBrowser('ci.tests.gmail', async (t, browser) => {
-      // todo - this is not yet looking for actual attachment in the result, just checks that it's s/mime message
+    ava.default('send encrypted-only S/MIME message with attachment', 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);
+      await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime.attachment@recipient.com' }, t.title,
+        'This text should be encrypted into PKCS#7 data', { sign: false, encrypt: true });
+      await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime.attachment@recipient.com', testConstants.testCertificateMultipleSmimeCEA2D53BB9D24871);
+      const fileInput = await composeFrame.target.$('input[type=file]');
+      await fileInput!.uploadFile('test/samples/small.txt', 'test/samples/small.png', 'test/samples/small.pdf');
+      /* todo: #4087 attachments in composer can be downloaded
+      const fileText = await inboxPage.awaitDownloadTriggeredByClicking(async () => {
+        await composeFrame.click('.qq-file-id-0');
+      });
+      expect(fileText.toString()).to.equal(`small text file\nnot much here\nthis worked\n`);
+      */
+      await composeFrame.waitAndClick('@action-send', { delay: 2 });
+      await inboxPage.waitTillGone('@container-new-message');
+    }));
+
+    ava.default('send signed and encrypted S/MIME message with attachment', testWithBrowser('ci.tests.gmail', async (t, browser) => {
+      // todo - this is not yet looking for actual attachment in the result, just checks that it's s/mime message
+      const acctEmail = 'ci.tests.gmail@flowcrypt.test';
+      const passphrase = 'pa$$w0rd';
+      await SettingsPageRecipe.addKeyTest(t, browser, acctEmail, testConstants.testKeyMultipleSmimeCEA2D53BB9D24871, passphrase, {}, false);
+      const inboxPage = await browser.newPage(t, TestUrls.extensionInbox(acctEmail));
+      const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage);
       await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime.attachment@recipient.com' }, t.title);
-      await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime.attachment@recipient.com', testConstants.smimeCert);
+      await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime.attachment@recipient.com', testConstants.testCertificateMultipleSmimeCEA2D53BB9D24871);
       const fileInput = await composeFrame.target.$('input[type=file]');
       await fileInput!.uploadFile('test/samples/small.txt', 'test/samples/small.png', 'test/samples/small.pdf');
       // attachments in composer can be downloaded
@@ -1066,6 +1137,46 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te
       });
     }));
 
+    ava.default('send with OpenPGP recipients as subset of S/MIME recipients', testWithBrowser('ci.tests.gmail', async (t, browser) => {
+      const acctEmail = 'ci.tests.gmail@flowcrypt.test';
+      const inboxPage = await browser.newPage(t, TestUrls.extensionInbox(acctEmail));
+      const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage);
+      await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime@recipient.com', cc: 'human@flowcrypt.com' }, 'send with several S/MIME certs with OpenPGP as subset',
+        'This text should be encrypted into PKCS#7 data');
+      await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime@recipient.com',
+        testConstants.testCertificateMultipleSmimeCEA2D53BB9D24871);
+      await composeFrame.waitAndClick('@action-send', { delay: 2 });
+      await PageRecipe.waitForModalAndRespond(composeFrame, 'error', {
+        contentToCheck: 'Failed to send message due to: Error: Cannot use mixed OpenPGP (human@flowcrypt.com) and S/MIME (smime@recipient.com) public keys yet.If you need to email S/MIME recipient, do not add any OpenPGP recipient at the same time.',
+        timeout: 40,
+        clickOn: 'confirm'
+      });
+      // adding an S/MIME certificate for human@flowcrypt.com will allow sending an S/MIME message
+      await PageRecipe.addPubkey(t, browser, acctEmail, testConstants.smimeCert, 'human@flowcrypt.com');
+      await composeFrame.waitAndClick('@action-send', { delay: 2 });
+      await inboxPage.waitTillGone('@container-new-message');
+    }));
+
+    ava.default('send with S/MIME recipients as subset of OpenPGP recipients', testWithBrowser('ci.tests.gmail', async (t, browser) => {
+      const acctEmail = 'ci.tests.gmail@flowcrypt.test';
+      const inboxPage = await browser.newPage(t, TestUrls.extensionInbox(acctEmail));
+      const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage);
+      await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime@recipient.com', cc: 'human@flowcrypt.com' }, t.title,
+        'This text should be encrypted into OpenPGP message');
+      await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime@recipient.com',
+        testConstants.testCertificateMultipleSmimeCEA2D53BB9D24871);
+      await composeFrame.waitAndClick('@action-send', { delay: 2 });
+      await PageRecipe.waitForModalAndRespond(composeFrame, 'error', {
+        contentToCheck: 'Failed to send message due to: Error: Cannot use mixed OpenPGP (human@flowcrypt.com) and S/MIME (smime@recipient.com) public keys yet.If you need to email S/MIME recipient, do not add any OpenPGP recipient at the same time.',
+        timeout: 40,
+        clickOn: 'confirm'
+      });
+      // adding an OpenPGP pubkey for smime@recipient.com will allow sending an OpenPGP message
+      await PageRecipe.addPubkey(t, browser, acctEmail, testConstants.pubkey2864E326A5BE488A, 'smime@recipient.com');
+      await composeFrame.waitAndClick('@action-send', { delay: 2 });
+      await inboxPage.waitTillGone('@container-new-message');
+    }));
+
     ava.default('send with broken S/MIME cert - err', 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/decrypt.ts b/test/source/tests/decrypt.ts
index 2e675d54b93..5b08cbe0b8e 100644
--- a/test/source/tests/decrypt.ts
+++ b/test/source/tests/decrypt.ts
@@ -11,6 +11,7 @@ import { TestUrls } from './../browser/test-urls';
 import { TestWithBrowser } from './../test';
 import { expect } from "chai";
 import { ComposePageRecipe } from './page-recipe/compose-page-recipe';
+import { PageRecipe } from './page-recipe/abstract-page-recipe';
 
 // tslint:disable:no-blank-lines-func
 // tslint:disable:max-line-length
@@ -320,7 +321,7 @@ export const defineDecryptTests = (testVariant: TestVariant, testWithBrowser: Te
       const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage);
       await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime@recipient.com' }, 'send signed and encrypted S/MIME without attachment');
       await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime@recipient.com',
-        testConstants.smimeCert);
+        testConstants.testCertificateMultipleSmimeCEA2D53BB9D24871);
       await composeFrame.waitAndClick('@action-send', { delay: 2 });
       await inboxPage.waitTillGone('@container-new-message');
     }));
@@ -401,11 +402,7 @@ export const defineDecryptTests = (testVariant: TestVariant, testWithBrowser: Te
       const textParams = `?frameId=none&message=&msgId=16a9c109bc51687d&` +
         `senderEmail=mismatch%40mail.com&isOutgoing=___cu_false___&signature=___cu_true___&acctEmail=flowcrypt.compatibility%40gmail.com`;
       await BrowserRecipe.pgpBlockVerifyDecryptedContent(t, browser, { params: textParams, content: ["1234"], signature: ["Missing pubkey"] });
-      const pubFrameUrl = `chrome/elements/pgp_pubkey.htm?frameId=none&armoredPubkey=${encodeURIComponent(testConstants.protonCompatPub)}&acctEmail=flowcrypt.compatibility%40gmail.com&parentTabId=0`;
-      const pubFrame = await browser.newPage(t, pubFrameUrl);
-      await pubFrame.waitAndClick('@action-add-contact');
-      await Util.sleep(1);
-      await pubFrame.close();
+      await PageRecipe.addPubkey(t, browser, 'flowcrypt.compatibility%40gmail.com', testConstants.protonCompatPub);
       await BrowserRecipe.pgpBlockVerifyDecryptedContent(t, browser, {
         params: textParams,
         content: ["1234"],
diff --git a/test/source/tests/page-recipe/abstract-page-recipe.ts b/test/source/tests/page-recipe/abstract-page-recipe.ts
index ec063920268..ed20fb8b8ce 100644
--- a/test/source/tests/page-recipe/abstract-page-recipe.ts
+++ b/test/source/tests/page-recipe/abstract-page-recipe.ts
@@ -5,6 +5,7 @@ import { BrowserHandle, Controllable, ControllablePage } from '../../browser';
 import { AvaContext } from '../tooling/';
 import { ElementHandle, JSHandle } from 'puppeteer';
 import { expect } from 'chai';
+import { Util } from '../../util';
 
 type ModalOpts = { contentToCheck?: string, clickOn?: 'confirm' | 'cancel', getTriggeredPage?: boolean, timeout?: number };
 type ModalType = 'confirm' | 'error' | 'info' | 'warning';
@@ -42,6 +43,16 @@ export abstract class PageRecipe {
     return (result as { result: { tabId: string } }).result.tabId;
   }
 
+  public static addPubkey = async (t: AvaContext, browser: BrowserHandle, acctEmail: string, pubkey: string, email?: string) => {
+    const pubFrameUrl = `chrome/elements/pgp_pubkey.htm?frameId=none&armoredPubkey=${encodeURIComponent(pubkey)}&acctEmail=${encodeURIComponent(acctEmail)}&parentTabId=0`;
+    const pubFrame = await browser.newPage(t, pubFrameUrl);
+    if (email) {
+      await pubFrame.waitAndType('@input-email', email);
+    }
+    await pubFrame.waitAndClick('@action-add-contact');
+    await Util.sleep(1);
+    await pubFrame.close();
+  }
   /**
    * responding to modal triggers a new page to be open, eg oauth login page
    */
diff --git a/test/source/tests/tooling/consts.ts b/test/source/tests/tooling/consts.ts
index 106ef762cc1..206a53806aa 100644
--- a/test/source/tests/tooling/consts.ts
+++ b/test/source/tests/tooling/consts.ts
@@ -1,5 +1,30 @@
 /* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */
-
+const testCertificateMultipleSmimeCEA2D53BB9D24871 = `-----BEGIN CERTIFICATE-----
+MIIEUzCCAjugAwIBAgIBAjANBgkqhkiG9w0BAQUFADB0MRMwEQYDVQQIDApTb21l
+LVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxFzAVBgNV
+BAMMDlNvbWUgQXV0aG9yaXR5MSEwHwYJKoZIhvcNAQkBFhJhdXRob3JpdHlAdGVz
+dC5jb20wIBcNMjEwOTI2MTAzOTUwWhgPMjEyMTA5MjYxMDM5NTBaMDAxLjAsBgNV
+BAMTJWZsb3djcnlwdC50ZXN0LmtleS5tdWx0aXBsZUBnbWFpbC5jb20wggEiMA0G
+CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaYbbw4ikp4aOLxmsUvVp0BD6id7dx
+K/ZbdC7rnyy1X/nkRAHEf3xrjPZk4KPPy8FGSgp/jIL1I+vEglcEPbBm61ZKdYFy
+Vty9kmWa0b6Ykf8urKR0hAfSCZheE5ua0OQ6cfmdOIZKJVRLLjAQql3MBGl4aWqn
+686sgoiazIxbgJ8HPi6I9GPSk3RSDHqksXqQUYoNL5Ugi2WGYhByf+BB5sS6vA0T
+gD+FkRnFGC56Y90Wkne3QofZP5/VPBeYV7bnxkJAl4I5yDNOMKrnBIL6pKBUqolj
+X6ARUFLCWF+yG1rMUBgGgT1GDx2S1+fgpSxRBpYmexfsesShA0nJy339AgMBAAGj
+MjAwMAwGA1UdEwQFMAMBAf8wCwYDVR0PBAQDAgL0MBMGA1UdJQQMMAoGCCsGAQUF
+BwMEMA0GCSqGSIb3DQEBBQUAA4ICAQDbBf3xb7esrnxEjwL/2dvnHAqgUTVaf6O4
+hW9mUp4c727fbDqat9OlilI/fLcwBN/NgEf+9jSWq9uHvjklCIXNTaK8zTkuQdtX
+FXZjT7N9EXWO93wUFtBY/fUIhJAEaL7Z6rmWUkK6UW/mtV7I6gxiZ2GAAE2wFKyS
+atb/Q+sgyeSOZjxux9NRMh8ZXmHTBQ/RUkzyLFM4Kgn5qfbM2vpkVuwjc4aH6r6F
+qeRKf1HULybJn/aHQ6zxVqNYodekUHe55DIAA71X2PVEaltN7szE4K+obdcE4y2I
+OW0w2H/SvBgngkn1aIvHIOltSBczvnSqKyQnLae4FW8PJJyk/X1HkAZPlMIRXXl9
+eUsv81oSiSK7YmRGuObgldBeDYmmECXnknsOPekK+mBaBbNGZNFMS6iJZV4anl4n
+UGgG49YEW97yFhJ4r7CxLhP2Eo7Fbfax4wUDPfaNLv9OK3zn27sjA/QSI3q7eBvi
+vn9dCnAk/hIBj/FsiyZstcczmvnHE9w9NDdCc0bbYcfwHAAs7XNejjDcenA+9ngq
+4hQ6ofK/4WIVekDJLRvoxNY94qjErT6iXL5aduYu8BVri2Q3Jqrp9YILLVH83rkl
+W8ZiWSb2P6bWZG3sxVNxS5iM6v39NbubnqK5UxE6+/GCeo+lHRNzZhhR3VRpKVw2
+xSncWuKSTg==
+-----END CERTIFICATE-----`;
 export const testConstants = {
   protonCompatPub: `-----BEGIN PGP PUBLIC KEY BLOCK-----
 Version: OpenPGP.js v3.0.5
@@ -279,6 +304,7 @@ Qhb2hMOaD7jK2qcP3mTFIBuYt0bPEZLhyMssa9ssK0dcY+JWs6LOeMPzCC1+bz7jCHiBCG5DBG8j
 h6VN43eiceJMKCGcndDbIkmHT/JiAtUj2VdKXAldLPIuECUzXtTGL8LdqgSYvxo0b0RiIv6pBNI+
 =Iqrh
 -----END PGP PRIVATE KEY BLOCK-----`,
+  testCertificateMultipleSmimeCEA2D53BB9D24871,
   testKeyMultipleSmimeCEA2D53BB9D24871: `-----BEGIN RSA PRIVATE KEY-----
 MIIEogIBAAKCAQEA2mG28OIpKeGji8ZrFL1adAQ+one3cSv2W3Qu658stV/55EQB
 xH98a4z2ZOCjz8vBRkoKf4yC9SPrxIJXBD2wZutWSnWBclbcvZJlmtG+mJH/Lqyk
@@ -306,32 +332,7 @@ ml2p7R0Z3cLoxnP2rPK4wqWdcr1iJB8BRchMhXgQ2gS3QoXjcUBR5jTtW/9lp3dL
 lpiLc4Tbuv6u4a6thk6X8i/hFQQs9Uo6cil5Onj7llOh9YrmAqPAeiZTZLwHhd1L
 6VL6r+WR5hdFzNldGFQdZXJwUXArSmUHebw8QESneljBahWgKC0=
 -----END RSA PRIVATE KEY-----
------BEGIN CERTIFICATE-----
-MIIEUzCCAjugAwIBAgIBAjANBgkqhkiG9w0BAQUFADB0MRMwEQYDVQQIDApTb21l
-LVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxFzAVBgNV
-BAMMDlNvbWUgQXV0aG9yaXR5MSEwHwYJKoZIhvcNAQkBFhJhdXRob3JpdHlAdGVz
-dC5jb20wIBcNMjEwOTI2MTAzOTUwWhgPMjEyMTA5MjYxMDM5NTBaMDAxLjAsBgNV
-BAMTJWZsb3djcnlwdC50ZXN0LmtleS5tdWx0aXBsZUBnbWFpbC5jb20wggEiMA0G
-CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaYbbw4ikp4aOLxmsUvVp0BD6id7dx
-K/ZbdC7rnyy1X/nkRAHEf3xrjPZk4KPPy8FGSgp/jIL1I+vEglcEPbBm61ZKdYFy
-Vty9kmWa0b6Ykf8urKR0hAfSCZheE5ua0OQ6cfmdOIZKJVRLLjAQql3MBGl4aWqn
-686sgoiazIxbgJ8HPi6I9GPSk3RSDHqksXqQUYoNL5Ugi2WGYhByf+BB5sS6vA0T
-gD+FkRnFGC56Y90Wkne3QofZP5/VPBeYV7bnxkJAl4I5yDNOMKrnBIL6pKBUqolj
-X6ARUFLCWF+yG1rMUBgGgT1GDx2S1+fgpSxRBpYmexfsesShA0nJy339AgMBAAGj
-MjAwMAwGA1UdEwQFMAMBAf8wCwYDVR0PBAQDAgL0MBMGA1UdJQQMMAoGCCsGAQUF
-BwMEMA0GCSqGSIb3DQEBBQUAA4ICAQDbBf3xb7esrnxEjwL/2dvnHAqgUTVaf6O4
-hW9mUp4c727fbDqat9OlilI/fLcwBN/NgEf+9jSWq9uHvjklCIXNTaK8zTkuQdtX
-FXZjT7N9EXWO93wUFtBY/fUIhJAEaL7Z6rmWUkK6UW/mtV7I6gxiZ2GAAE2wFKyS
-atb/Q+sgyeSOZjxux9NRMh8ZXmHTBQ/RUkzyLFM4Kgn5qfbM2vpkVuwjc4aH6r6F
-qeRKf1HULybJn/aHQ6zxVqNYodekUHe55DIAA71X2PVEaltN7szE4K+obdcE4y2I
-OW0w2H/SvBgngkn1aIvHIOltSBczvnSqKyQnLae4FW8PJJyk/X1HkAZPlMIRXXl9
-eUsv81oSiSK7YmRGuObgldBeDYmmECXnknsOPekK+mBaBbNGZNFMS6iJZV4anl4n
-UGgG49YEW97yFhJ4r7CxLhP2Eo7Fbfax4wUDPfaNLv9OK3zn27sjA/QSI3q7eBvi
-vn9dCnAk/hIBj/FsiyZstcczmvnHE9w9NDdCc0bbYcfwHAAs7XNejjDcenA+9ngq
-4hQ6ofK/4WIVekDJLRvoxNY94qjErT6iXL5aduYu8BVri2Q3Jqrp9YILLVH83rkl
-W8ZiWSb2P6bWZG3sxVNxS5iM6v39NbubnqK5UxE6+/GCeo+lHRNzZhhR3VRpKVw2
-xSncWuKSTg==
------END CERTIFICATE-----`,
+${testCertificateMultipleSmimeCEA2D53BB9D24871}`,
   testKeyMultipleSmimeA35068FD4E037879: `-----BEGIN RSA PRIVATE KEY-----
 MIIEpAIBAAKCAQEAuhfYg465kFwSBiy2l8HPsLIhqub64Hnf4cQW0j2tK5vmnP1k
 QR3QbiGj3e9fPItetecNmDRvqLp3VibqPEzsaDJEJK2S+FGKb6asde2pTg4zfkrf
@@ -933,29 +934,29 @@ sOwAnhJ+pD5iIPaF2oa0yN3PvI6IGxLpEv16tQO1N6e5bdP6ZDwqTQJyK+oNTNda
 yPLCqVTFJQWaCR5ZTekRQPTDZkjxjxbs
 -----END CERTIFICATE-----`,
   smimeCert: `-----BEGIN CERTIFICATE-----
-MIIEQjCCAiqgAwIBAgIBAjANBgkqhkiG9w0BAQUFADB0MRMwEQYDVQQIDApTb21l
-LVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxFzAVBgNV
-BAMMDlNvbWUgQXV0aG9yaXR5MSEwHwYJKoZIhvcNAQkBFhJhdXRob3JpdHlAdGVz
-dC5jb20wIBcNMjEwNTI5MTUzODA2WhgPMjEyMTA1MjkxNTM4MDZaMB8xHTAbBgNV
-BAMTFGNlcnRpZmljYXRlQHRlc3QuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
-MIIBCgKCAQEArvbSscBejWb7bSoYzCibk12nJfb918rlhqzB1o+CrnUaOFNYsBt3
-0GAf5GgZHE/4WoZQs4+xivFzYbB63HSIBgXsQubK+RaB5H+/J14CAQpUEI6LiMwu
-5QdjDOPSms+sUYo8Tno16TtkhT+rSG35FDTtnPGvkZ8bPti1bXGHfR3Y6NepYYiq
-FZe15jSj6hDj9Hf64bHc2excJLEK0AEDwYbeeL7Ih9C+225DtozBkyXZ0XDG5W/e
-PFNf7Ry+Df77tufCp8ID1iGyA6yad0QbcIxQqu1YRG8y6XvWtVdNo9EN6nKkF60Y
-7Spkisbter+hB0Pdi8uIR6nPiDWPdyXNnwIDAQABozIwMDAMBgNVHRMEBTADAQH/
-MAsGA1UdDwQEAwIC9DATBgNVHSUEDDAKBggrBgEFBQcDBDANBgkqhkiG9w0BAQUF
-AAOCAgEAHr7N/7udMjMs7POQsAXKpeE6aSIjV3IWcS37En4TCSpCmcehTrz2p3jp
-6VfbkISQR7zPjo5fNJBkBKHwTn2cB8CZC71o9PBc8CNbw5lyNbW5y/7TASxUD1d/
-Sn7Nbo8lRKRyKKaB5Oy5SZcD0Nw5UdmcuVBwT+AnMOZrLMXrrc1RVrO7U4tZjCaz
-TV8PS5ZO0PZzTpLj6VAYgem9SMo97VCdIodm0FTznbvuvfZvoTiuj3enpTMaZ4be
-NAAqeci/omzG4GkUAYEy0TqwHn2eMWJp+dRzYrxRuS6AWLZcyo3kNRdG5QWrl5qG
-BAZDGxVH9gZIEcUjK4tYhxfxtL5m/K6fI3/34w2RRCr2/zkiZtGsnoml5oWMo2uf
-0C80J9KOcv8yeT5ECCBSO/4tYQ8+kCRwMtnpJLkVHOWCF4qX7t3f5nuFKQyP3kGH
-CfOUFk41Wr7hjawn9CSiSojXdXuuMkGLyUqRPt0Q3Euv4uwIU0HP6F8bsw0SG36P
-nMK76zd2PZyyHibpqSxEIVfIsskS74jDMFRuAVzveM+2B8Ybk2gVgRqK6zfJbIid
-UqPKE723PXRotEA8hZQjdUNWxHHA6zDlYRi8b7gA0KnHgQjXsu67hpNk30c2y/vC
-XZGCPCqmoMeHrNWrsumw5SvKwfrXtnECPqummdaato26pYR6I5s=
+MIIERDCCAiygAwIBAgIEICERAjANBgkqhkiG9w0BAQUFADB0MRMwEQYDVQQIDApT
+b21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxFzAV
+BgNVBAMMDlNvbWUgQXV0aG9yaXR5MSEwHwYJKoZIhvcNAQkBFhJhdXRob3JpdHlA
+dGVzdC5jb20wIBcNMjExMTAyMDgxOTIzWhgPMjEyMTExMDIwODE5MjNaMB4xHDAa
+BgNVBAMTE3NtaW1lQHJlY2lwaWVudC5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IB
+DwAwggEKAoIBAQC5F2fXDZDBJVouHXtn2z2IlFWm+5Wh2FZfbFSp/0L6+jkuW5m3
+pc82DAcOaLR/xm/wmhNg3yENyYm2V+BreeYNuBCRDCskF/kMEhtJRpY/Sy6uJYRZ
+dFY9GoH+bW1UT8ikwBgSPzzK9aTRWYTEFp+dUYArtEpTPqOuJ1YHKGyBonf7y6SC
+kFw9ZyE9sGRno2DF3zznNn0y01QvcqBT3jmed5Sw6ogf5sRokmcZB3VxSSxsRrg+
+d2Qmg2xNmDFP+kdM8PVXOtehecGgbB9+KLtwl6h5P+/oeLpenz7kccLT1eo/LX0L
+wvBeRgph+mq4phWrGocH8CManSzUj8NpBRLnAgMBAAGjMjAwMAwGA1UdEwQFMAMB
+Af8wCwYDVR0PBAQDAgL0MBMGA1UdJQQMMAoGCCsGAQUFBwMEMA0GCSqGSIb3DQEB
+BQUAA4ICAQBclsGHcrpctzN6MeP00estvizRql/oeDog0xlMMlI6w8qBedR6EIlc
+V4hesXPIVu/ndLax9reo3qLVEVAEjvUVyzzhUyS3RgmoKcFeia9ziFA3I3kukABg
+T+XkbGdkHiPMhhtC4HJAIKyrpWhwwH5qVfszb9BXUU1fa3PpRGlMEyftpEJX+zmg
+OyVpM0+9NakATpj90COnrvvecGbXTWTeBfQvgxbBRkDgzcP85c5WfB1cUJdMdJ6v
+AURkQkOc0MhcaejSW9aR0nFhWTutHT8IzqlOJ7097Yy/xUjQjMI8TBs8RdbHQLSK
+DRLpM++3ogro1SBFGixNUYKe478gS3hbX9iDkXFa8ahPUKhkpbaTgUyrTbnmkDmW
+sO6pvIvNABfLjMDUVeqoqlu/ocyLQgP33LU6MZcBD8tsr262KH7iBdlJ3G/EyFRg
+vNEHxGYTv8QcY+l3vyEGx7DTINvdhLZgVbrvW6NcwmGP4eTr5N3chHDF8FkynuUc
+crVV5FtRAZnyIvdrvR7YDjxMqdt9NnRVu3tuAyVvFnsdAoQYnDYCNW9D799apgBC
+tQSzxcbLf5j+cNE61AZKjhfcywVUcxrm+7iNMQWam8F+kHUdvTgLsmqIU3yPAS5j
+2uN7aPZpX2URLnJrXn3A8A5+1mVZ56NmFjEDYd9az0ucypvCK3p/IA==
 -----END CERTIFICATE-----`,
   smimeEncryptedKey: `-----BEGIN ENCRYPTED PRIVATE KEY-----
 MIIE6jAcBgoqhkiG9w0BDAEDMA4ECJIfk+QWcy1fAgIDOgSCBMgdqRHl8ZE5L37t
diff --git a/test/source/tests/unit-node.ts b/test/source/tests/unit-node.ts
index 1f5d304345b..d20db40ebec 100644
--- a/test/source/tests/unit-node.ts
+++ b/test/source/tests/unit-node.ts
@@ -22,7 +22,7 @@ import { PgpArmor } from '../core/crypto/pgp/pgp-armor';
 import { ExpirationCache } from '../core/expiration-cache';
 import { readFileSync } from 'fs';
 import * as forge from 'node-forge';
-import { SmimeKey } from '../core/crypto/smime/smime-key';
+import { ENVELOPED_DATA_OID, SmimeKey } from '../core/crypto/smime/smime-key';
 
 chai.use(chaiAsPromised);
 const expect = chai.expect;
@@ -142,12 +142,12 @@ export const defineUnitNodeTests = (testVariant: TestVariant) => {
       csr.publicKey = keys.publicKey;
       csr.setSubject([{
         name: 'commonName',
-        value: 'certificate@test.com'
+        value: 'smime@recipient.com'
       }]);
       csr.sign(keys.privateKey);
       // issue a certificate based on the csr
       const cert = forge.pki.createCertificate();
-      cert.serialNumber = '2';
+      cert.serialNumber = '20211103'; // todo: set something unique here
       cert.validity.notBefore = new Date();
       cert.validity.notAfter = new Date();
       cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 100);
@@ -175,18 +175,24 @@ export const defineUnitNodeTests = (testVariant: TestVariant) => {
       cert.publicKey = csr.publicKey;
       cert.sign(caKey);
       const pem = forge.pki.certificateToPem(cert);
-      */
+      console.log(pem);
+      const p12asn1 = forge.pkcs12.toPkcs12Asn1(keys.privateKey, cert, 'try_me');
+      const rawString = forge.asn1.toDer(p12asn1).getBytes();
+      let buf = Buf.fromRawBytesStr(pem);
+      writeFileSync("./smime.crt", buf);
+      buf = Buf.fromRawBytesStr(rawString);
+      writeFileSync("./test.p12", buf); */
       const key = await KeyUtil.parse(testConstants.smimeCert);
-      expect(key.id).to.equal('6FE116D2759F0FFAC5623E7E10D6E37941EAA0BB');
+      expect(key.id).to.equal('1D695D97A7C8A473E36C6E1D8C150831E4061A74');
       expect(key.type).to.equal('x509');
       expect(key.usableForEncryption).to.equal(true);
       expect(key.usableForSigning).to.equal(true);
       expect(key.usableForEncryptionButExpired).to.equal(false);
       expect(key.usableForSigningButExpired).to.equal(false);
       expect(key.emails.length).to.equal(1);
-      expect(key.emails[0]).to.equal('certificate@test.com');
+      expect(key.emails[0]).to.equal('smime@recipient.com');
       expect(key.identities.length).to.equal(1);
-      expect(key.identities[0]).to.equal('certificate@test.com');
+      expect(key.identities[0]).to.equal('smime@recipient.com');
       expect(key.isPublic).to.equal(true);
       expect(key.isPrivate).to.equal(false);
       expect(key.expiration).to.not.equal(undefined);
@@ -558,7 +564,7 @@ vpQiyk4ceuTNkUZ/qmgiMpQLxXZnDDo=
       const { keys, errs } = await KeyUtil.readMany(Buf.fromUtfStr(testConstants.smimeCert));
       expect(keys.length).to.equal(1);
       expect(errs.length).to.equal(0);
-      expect(keys[0].id).to.equal('6FE116D2759F0FFAC5623E7E10D6E37941EAA0BB');
+      expect(keys[0].id).to.equal('1D695D97A7C8A473E36C6E1D8C150831E4061A74');
       expect(keys[0].type).to.equal('x509');
       t.pass();
     });
@@ -635,7 +641,7 @@ ${testConstants.smimeCert}`), { instanceOf: Error, message: `Invalid PEM formatt
       const { keys, errs } = await KeyUtil.readMany(Buf.fromRawBytesStr(pem.body));
       expect(keys.length).to.equal(1);
       expect(errs.length).to.equal(0);
-      expect(keys[0].id).to.equal('6FE116D2759F0FFAC5623E7E10D6E37941EAA0BB');
+      expect(keys[0].id).to.equal('1D695D97A7C8A473E36C6E1D8C150831E4061A74');
       expect(keys[0].type).to.equal('x509');
       t.pass();
     });
@@ -649,6 +655,19 @@ ${testConstants.smimeCert}`), { instanceOf: Error, message: `Invalid PEM formatt
       t.pass();
     });
 
+    ava.default('[unit][MsgUtil.encryptMessage] duplicate S/MIME recipients are collapsed into one', async t => {
+      const key = await KeyUtil.parse(testConstants.smimeCert);
+      const buf = Buf.with((await MsgUtil.encryptMessage(
+        { pubkeys: [key, key, key], data: Buf.fromUtfStr('anything'), armor: false }) as PgpMsgMethod.EncryptX509Result).data);
+      const msg = buf.toRawBytesStr();
+      const p7 = forge.pkcs7.messageFromAsn1(forge.asn1.fromDer(msg));
+      expect(p7.type).to.equal(ENVELOPED_DATA_OID);
+      if (p7.type === ENVELOPED_DATA_OID) {
+        expect(p7.recipients.length).to.equal(1);
+      }
+      t.pass();
+    });
+
     ava.default('[unit][KeyUtil.parse] Correctly extracting email from SubjectAltName of S/MIME certificate', async t => {
       /*
             // generate a key pair
@@ -741,7 +760,7 @@ jLwe8W9IMt765T5x5oux9MmPDXF05xHfm4qfH/BMO3a802x5u2gJjJjuknrFdgXY
       const { keys, errs } = await KeyUtil.readMany(Buf.fromUtfStr(smimeAndPgp));
       expect(keys.length).to.equal(2);
       expect(errs.length).to.equal(0);
-      expect(keys.some(key => key.id === '6FE116D2759F0FFAC5623E7E10D6E37941EAA0BB')).to.equal(true);
+      expect(keys.some(key => key.id === '1D695D97A7C8A473E36C6E1D8C150831E4061A74')).to.equal(true);
       expect(keys.some(key => key.id === '3449178FCAAF758E24CB68BE62CB4E6F9ECA6FA1')).to.equal(true);
       expect(keys.some(key => key.type === 'openpgp')).to.equal(true);
       expect(keys.some(key => key.type === 'x509')).to.equal(true);
@@ -1763,7 +1782,7 @@ jA==
       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');
+      expect(p7.type).to.equal(ENVELOPED_DATA_OID);
       const decrypted = SmimeKey.decryptMessage(p7 as forge.pkcs7.PkcsEnvelopedData, privateSmimeKey);
       const decryptedMessage = Buf.with(decrypted).toRawBytesStr();
       expect(decryptedMessage).to.equal(text);
diff --git a/test/source/util/parse.ts b/test/source/util/parse.ts
index da54e44eb05..46cb5662f70 100644
--- a/test/source/util/parse.ts
+++ b/test/source/util/parse.ts
@@ -31,8 +31,16 @@ const strictParse = async (source: string): Promise => {
   return result;
 };
 
+const parseMixed = async (source: string): Promise => {
+  if (source.startsWith('Content-Type: multipart/mixed')) {
+    return await simpleParser(new Buffer(source), { keepCidLinks: true });
+  } else {
+    throw new Error('multipart/mixed message wasn\'t found');
+  }
+};
+
 const convertBase64ToMimeMsg = async (base64: string) => {
   return await simpleParser(new Buffer(Buf.fromBase64Str(base64)), { keepCidLinks: true /* #3256 */ });
 };
 
-export default { strictParse, convertBase64ToMimeMsg };
+export default { strictParse, parseMixed, convertBase64ToMimeMsg };