From 93c6307027b180a7c446acea7afb5aca3f7c0b1b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:52:00 +0100 Subject: [PATCH] fix: stop saving pgp keys from messages on their own and offer to save a key only for the sender's address --- locales/rn/en.json | 3 + src/components/email/MessageContent.tsx | 2 +- src/components/email/SecureMessageBanner.tsx | 35 ++++- src/components/email/use-secure-message.ts | 3 +- src/lib/crypto/__tests__/banner-rows.test.ts | 21 ++- .../__tests__/secure-message-keys.test.ts | 137 ++++++++++++++++++ src/lib/crypto/banner-rows.ts | 27 +++- src/lib/crypto/keyring.ts | 37 +++-- src/lib/crypto/secure-message.ts | 53 +++++-- 9 files changed, 285 insertions(+), 33 deletions(-) create mode 100644 src/lib/crypto/__tests__/secure-message-keys.test.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index 37bf230c..381918ba 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -707,8 +707,11 @@ "failed": "Could not decrypt: {reason}", "invalid": "Invalid signature ({kind})", "invalid_reason": "Invalid signature ({kind}): {reason}", + "key_offered": "The message contains a public key for {email}. Fingerprint: {fingerprint}", "locked": "Encrypted ({kind}). Unlock your key to read it.", "no_key": "Encrypted ({kind}) to a key that is not on this device.", + "save_key": "Save key for {email}", + "save_key_failed": "Could not save the key", "self_signed": "The certificate is self-signed, not issued by a known authority.", "signed": "Valid signature ({kind})", "signed_by": "Valid signature ({kind}) from {signer}", diff --git a/src/components/email/MessageContent.tsx b/src/components/email/MessageContent.tsx index 17318918..95f64b38 100644 --- a/src/components/email/MessageContent.tsx +++ b/src/components/email/MessageContent.tsx @@ -130,7 +130,7 @@ export function MessageContent({ /> )} - {(secure.loading || secure.result) && } + {(secure.loading || secure.result) && } diff --git a/src/components/email/SecureMessageBanner.tsx b/src/components/email/SecureMessageBanner.tsx index 9bb159df..7ef5e46f 100644 --- a/src/components/email/SecureMessageBanner.tsx +++ b/src/components/email/SecureMessageBanner.tsx @@ -7,16 +7,22 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; import { useCryptoKeysStore, type CryptoKeyRecord } from '../../stores/crypto-keys-store'; -import type { SecureStatus } from '../../lib/crypto/secure-message'; -import { secureBannerRows, type BannerTone as Tone } from '../../lib/crypto/banner-rows'; +import type { OfferedPgpKey, SecureStatus } from '../../lib/crypto/secure-message'; +import { offeredKeyRow, secureBannerRows, type BannerTone as Tone } from '../../lib/crypto/banner-rows'; +import { savePgpKeyFor } from '../../lib/crypto/keyring'; +import { toast } from '../../stores/toast-store'; -/** Status banner above a PGP / S/MIME message, with Unlock for a locked key. */ -export function SecureMessageBanner({ status, loading }: { status: SecureStatus | null; loading?: boolean }) { +/** + * Status banner above a PGP / S/MIME message, with Unlock for a locked key + * and Save for a public key the message carries for its sender. + */ +export function SecureMessageBanner({ status, loading, offeredKey }: { status: SecureStatus | null; loading?: boolean; offeredKey?: OfferedPgpKey }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const keys = useCryptoKeysStore((s) => s.keys); const [unlockRecord, setUnlockRecord] = React.useState(null); + const [saving, setSaving] = React.useState(false); if (loading) { return ( @@ -28,11 +34,23 @@ export function SecureMessageBanner({ status, loading }: { status: SecureStatus } if (!status) return null; const rows = secureBannerRows(status, t); - if (rows.length === 0) return null; const color = (tone: Tone) => (tone === 'ok' ? c.success : tone === 'warn' ? c.warning : tone === 'error' ? c.error : c.textMuted); const lockedRecord = status.lockedKeyIds?.length ? keys.find((k) => k.kind === status.kind && status.lockedKeyIds!.includes(k.id)) ?? null : null; + const offer = offeredKeyRow(offeredKey, keys, t); + if (rows.length === 0 && !offer) return null; + const saveOffered = async () => { + if (!offeredKey) return; + setSaving(true); + try { + await savePgpKeyFor(offeredKey.armored, offeredKey.email); + } catch (err) { + toast.error(t('encryption.banner.save_key_failed', 'Could not save the key'), { message: err instanceof Error ? err.message : String(err) }); + } finally { + setSaving(false); + } + }; return ( @@ -48,6 +66,13 @@ export function SecureMessageBanner({ status, loading }: { status: SecureStatus ); })} + {offer && ( + + + {offer.text} + + + )} (ownKeys('pgp') as PgpKeyRecord[]).map((k) => ({ id: k.id, publicKeyArmored: k.publicKeyArmored, unlocked: isUnlocked(k) })), pgpPublicKeys: allPgpPublicKeys, smimeKeys: () => (ownKeys('smime') as SmimeCertRecord[]).map((k) => ({ id: k.id, certificate: base64Decode(k.certificate), pkcs8: unlockedSmimeKey(k.id)?.pkcs8 })), - rememberPgpKey: rememberSignerPgpKey, rememberCertificate: rememberSignerCertificate, }; } diff --git a/src/lib/crypto/__tests__/banner-rows.test.ts b/src/lib/crypto/__tests__/banner-rows.test.ts index 6514982c..8a232241 100644 --- a/src/lib/crypto/__tests__/banner-rows.test.ts +++ b/src/lib/crypto/__tests__/banner-rows.test.ts @@ -1,6 +1,6 @@ // What the reader's banner says for each PGP / S/MIME outcome (#58). import { describe, expect, it } from 'vitest'; -import { secureBannerRows } from '../banner-rows'; +import { offeredKeyRow, secureBannerRows } from '../banner-rows'; const t = (_k: string, fallback: string, vars?: Record) => fallback.replace(/\{(\w+)\}/g, (_, name: string) => String(vars?.[name] ?? '')); @@ -29,3 +29,22 @@ describe('secureBannerRows', () => { expect(secureBannerRows({ kind: 'pgp', encrypted: false, decrypted: false, signature: 'none' }, t)).toEqual([]); }); }); + +describe('offeredKeyRow', () => { + const offer = { armored: 'k', fingerprint: 'abcd1234abcd1234abcd1234abcd1234abcd1234', email: 'a@example.org' }; + + it('offers to save the key for the sender, with its fingerprint', () => { + expect(offeredKeyRow(offer, [], t)).toEqual({ + key: 'offer', + tone: 'muted', + text: 'The message contains a public key for a@example.org. Fingerprint: ABCD 1234 ABCD 1234 ABCD 1234 ABCD 1234 ABCD 1234', + action: 'Save key for a@example.org', + }); + }); + + it('offers nothing without a key, or once that key is stored', () => { + expect(offeredKeyRow(undefined, [], t)).toBeNull(); + expect(offeredKeyRow(offer, [{ kind: 'pgp', id: offer.fingerprint }], t)).toBeNull(); + expect(offeredKeyRow(offer, [{ kind: 'smime', id: offer.fingerprint }], t)).not.toBeNull(); + }); +}); diff --git a/src/lib/crypto/__tests__/secure-message-keys.test.ts b/src/lib/crypto/__tests__/secure-message-keys.test.ts new file mode 100644 index 00000000..7ba6ec67 --- /dev/null +++ b/src/lib/crypto/__tests__/secure-message-keys.test.ts @@ -0,0 +1,137 @@ +// Keys that arrive in a message (#58): any sender can put a public key in a +// message, and the app used to store every one it found, for whatever address +// it named, whatever the signature said. Stored keys verify signatures and +// are encrypted to, so that let a sender plant a key for someone else. Now a +// key is only offered (when it names the From address) and saved by the +// user; an S/MIME certificate is kept only when it names the From address. +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as openpgp from 'openpgp'; +import fs from 'node:fs'; +import path from 'node:path'; +import { buildSecureMessage } from '../secure-send'; +import { openSecureMessage } from '../secure-message'; +import { importPgpKey, lockAll, savePgpKeyFor } from '../keyring'; +import { useCryptoKeysStore } from '../../../stores/crypto-keys-store'; +import { keyringAccess } from '../../../components/email/use-secure-message'; +import type { MimeMessageInput } from '../mime'; +import { makePgpKey, makeSmimeCert, useNodeCrypto, type TestCert } from './helpers'; + +const secure = vi.hoisted(() => new Map()); +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 6, + setItemAsync: vi.fn(async (k: string, v: string) => { secure.set(k, v); }), + getItemAsync: vi.fn(async (k: string) => secure.get(k) ?? null), + deleteItemAsync: vi.fn(async (k: string) => { secure.delete(k); }), +})); + +const encode = (s: string) => new TextEncoder().encode(s); +const stored = () => useCryptoKeysStore.getState().keys; + +let alice: Awaited>; +let mallory: Awaited>; +let bob: Awaited>; + +beforeAll(async () => { + useNodeCrypto(); + [alice, mallory, bob] = await Promise.all([ + makePgpKey('alice@example.org'), makePgpKey('mallory@example.org'), makePgpKey('bob@example.org'), + ]); +}, 60_000); + +beforeEach(() => { + secure.clear(); + lockAll(); + useCryptoKeysStore.setState({ keys: [] }); +}); + +/** An inline cleartext-signed message from `from` whose text carries `key`. */ +async function inlineSigned(from: string, signer: openpgp.PrivateKey, key: string, tamper = false): Promise { + const signed = await openpgp.sign({ + message: await openpgp.createCleartextMessage({ text: `Here is my key.\n\n${key}` }), + signingKeys: signer, + }) as string; + const body = tamper ? signed.replace('Here is my key.', 'Here is my kex.') : signed; + return encode(`From: ${from}\r\nContent-Type: text/plain\r\n\r\n${body.replace(/\r?\n/g, '\r\n')}`); +} + +describe('a PGP key in a message', () => { + it('is offered, not stored, when it names the From address and the signer is not known yet', async () => { + const res = await openSecureMessage(await inlineSigned(alice.email, alice.unlocked, alice.publicKey), alice.email, keyringAccess()); + expect(res?.status.signature).toBe('unknown'); + const fingerprint = (await openpgp.readKey({ armoredKey: alice.publicKey })).getFingerprint(); + expect(res?.offeredKey).toMatchObject({ email: alice.email, fingerprint }); + expect(stored()).toEqual([]); + + // Stored only once the user saves it. + await savePgpKeyFor(res!.offeredKey!.armored, res!.offeredKey!.email); + expect(stored()).toEqual([expect.objectContaining({ kind: 'pgp', id: fingerprint, emails: [alice.email], source: 'message' })]); + }, 60_000); + + it('is neither stored nor offered when none of its user ids is the From address', async () => { + const res = await openSecureMessage(await inlineSigned(alice.email, alice.unlocked, mallory.publicKey), alice.email, keyringAccess()); + expect(res?.offeredKey).toBeUndefined(); + expect(stored()).toEqual([]); + await expect(savePgpKeyFor(mallory.publicKey, alice.email)).rejects.toThrow(/not for alice@example.org/); + expect(stored()).toEqual([]); + }, 60_000); + + it('is neither stored nor offered when the signature fails', async () => { + await importPgpKey(alice.publicKey); + const res = await openSecureMessage(await inlineSigned(alice.email, alice.unlocked, alice.publicKey, true), alice.email, keyringAccess()); + expect(res?.status.signature).toBe('invalid'); + expect(res?.offeredKey).toBeUndefined(); + expect(stored()).toHaveLength(1); + }, 60_000); + + it('is not stored from a decrypted message either', async () => { + await importPgpKey(bob.privateKey, bob.passphrase); + const input: MimeMessageInput = { + from: { email: alice.email }, + to: [{ email: bob.email }], + subject: 'key', + textBody: `My new key:\n\n${mallory.publicKey}`, + }; + const raw = await buildSecureMessage(input, { mode: 'pgp', sign: false, encrypt: true }, { pgp: { encryptionKeys: [bob.publicKey] } }); + const access = { ...keyringAccess(), pgpDecryptionKeys: () => [bob.unlocked], pgpOwnKeys: () => [{ id: 'bob', publicKeyArmored: bob.publicKey, unlocked: true }] }; + const res = await openSecureMessage(raw, alice.email, access); + expect(res?.status.decrypted).toBe(true); + expect(res?.body?.text).toContain('BEGIN PGP PUBLIC KEY BLOCK'); + expect(res?.offeredKey).toBeUndefined(); + expect(stored().map((k) => k.emails)).toEqual([[bob.email]]); + }, 60_000); +}); + +describe('an S/MIME signer certificate', () => { + let signer: TestCert; + beforeAll(async () => { + signer = await makeSmimeCert('alice@example.org'); + }, 60_000); + + const signed = () => buildSecureMessage( + { from: { email: signer.email }, to: [{ email: 'bob@example.org' }], subject: 's', textBody: 'signed' }, + { mode: 'smime', sign: true, encrypt: false }, + { smime: { signer: { pkcs8: signer.pkcs8, certificate: signer.certificate, chain: [], algorithm: 'RSA-2048' } } }, + ); + + it('is not kept when it does not name the From address, even with a valid signature', async () => { + const res = await openSecureMessage(await signed(), 'mallory@example.org', keyringAccess()); + expect(res?.status.signature).toBe('valid'); + expect(stored()).toEqual([]); + }, 60_000); + + it('is kept when it names the From address', async () => { + await openSecureMessage(await signed(), signer.email, keyringAccess()); + await vi.waitFor(() => expect(stored()).toEqual([expect.objectContaining({ kind: 'smime', emails: [signer.email], source: 'message' })])); + }, 60_000); +}); + +describe('the reader', () => { + it('hands the offered key to the banner, which saves it only on the button', () => { + const root = path.resolve(__dirname, '../../../..'); + const content = fs.readFileSync(path.join(root, 'src/components/email/MessageContent.tsx'), 'utf8'); + expect(content).toContain('offeredKey={secure.result?.offeredKey}'); + const banner = fs.readFileSync(path.join(root, 'src/components/email/SecureMessageBanner.tsx'), 'utf8'); + expect(banner).toMatch(/onPress=\{\(\) => \{ void saveOffered\(\); \}\}>\{offer\.action\}/); + expect(banner.match(/savePgpKeyFor\(/g)).toHaveLength(1); + }); +}); diff --git a/src/lib/crypto/banner-rows.ts b/src/lib/crypto/banner-rows.ts index d1d36171..00d20ff2 100644 --- a/src/lib/crypto/banner-rows.ts +++ b/src/lib/crypto/banner-rows.ts @@ -1,5 +1,6 @@ // Banner rows for a PGP / S/MIME status (#58). -import type { SecureStatus } from './secure-message'; +import type { OfferedPgpKey, SecureStatus } from './secure-message'; +import { formatFingerprint } from './key-labels'; export type BannerTone = 'ok' | 'warn' | 'error' | 'muted'; type Translate = (key: string, fallback: string, vars?: Record) => string; @@ -49,3 +50,27 @@ export function secureBannerRows(status: SecureStatus, t: Translate): BannerRow[ return rows; } +/** + * The row that offers to save the public key a message carries for its + * From address, with its fingerprint to compare, and the label of the save + * button. Null without an offer, or once a key with that fingerprint is + * stored. + */ +export function offeredKeyRow( + offer: OfferedPgpKey | undefined, + stored: ReadonlyArray<{ kind: string; id: string }>, + t: Translate, +): (BannerRow & { action: string }) | null { + if (!offer) return null; + if (stored.some((k) => k.kind === 'pgp' && k.id === offer.fingerprint)) return null; + return { + key: 'offer', + tone: 'muted', + text: t('encryption.banner.key_offered', 'The message contains a public key for {email}. Fingerprint: {fingerprint}', { + email: offer.email, + fingerprint: formatFingerprint(offer.fingerprint), + }), + action: t('encryption.banner.save_key', 'Save key for {email}', { email: offer.email }), + }; +} + diff --git a/src/lib/crypto/keyring.ts b/src/lib/crypto/keyring.ts index 3bd8a666..d13d022a 100644 --- a/src/lib/crypto/keyring.ts +++ b/src/lib/crypto/keyring.ts @@ -219,23 +219,38 @@ export function allPgpPublicKeys(): string[] { return records().filter((k): k is PgpKeyRecord => k.kind === 'pgp').map((k) => k.publicKeyArmored); } -/** Remember the key of someone who signed a message, unless it is known already. */ -export async function rememberSignerPgpKey(armored: string): Promise { - try { - const info = await inspectKey(armored); - if (info.isPrivate) return; - if (records().some((k) => k.kind === 'pgp' && k.id === info.fingerprint.toLowerCase())) return; - upsert(pgpRecord(info, false, 'message')); - } catch { - // a broken embedded key: ignore +/** + * Save a public key that came in a message from `email`, when the user asks + * to (the reader's banner offers it). Refused unless one of the key's user + * ids is that address: a sender must not plant a key for someone else. + */ +export async function savePgpKeyFor(armored: string, email: string): Promise { + const info = await inspectKey(armored); + if (info.isPrivate) throw new Error('Only a public key can be saved from a message'); + const address = email.trim().toLowerCase(); + if (!address || !info.userIDs.some((u) => u.email.trim().toLowerCase() === address)) { + throw new Error('The key is not for ' + email); } + const known = records().find((k): k is PgpKeyRecord => k.kind === 'pgp' && k.id === info.fingerprint.toLowerCase()); + if (known) return known; + const record = pgpRecord(info, false, 'message'); + upsert(record); + // Open messages verify again with the key. + notify(); + return record; } -export async function rememberSignerCertificate(der: Uint8Array): Promise { +/** + * Keep the certificate of a valid S/MIME signature on a message from + * `fromEmail`, unless it is known already. Only one that names that address + * (subject email or SAN rfc822Name) is kept. + */ +export async function rememberSignerCertificate(der: Uint8Array, fromEmail: string): Promise { try { const { describeCertificate } = await import('./smime/certificate'); const info = await describeCertificate(der); - if (!info.emailAddresses.length) return; + const from = fromEmail.trim().toLowerCase(); + if (!from || !info.emailAddresses.some((e) => e.toLowerCase() === from)) return; if (records().some((k) => k.kind === 'smime' && k.id === info.fingerprint)) return; upsert(smimeRecord(info, der, [], false, 'message')); } catch { diff --git a/src/lib/crypto/secure-message.ts b/src/lib/crypto/secure-message.ts index 46a4ad08..c2671ce6 100644 --- a/src/lib/crypto/secure-message.ts +++ b/src/lib/crypto/secure-message.ts @@ -9,7 +9,7 @@ import type { BodyPart, Email } from '../../api/types'; import { binaryToBytes, bytesToBinary, decodeBody, findNode, parseEntity, type MimeNode } from './mime'; import { detectPgp, decryptMessage, verifyDetached, verifyCleartext, encryptionKeyIds, keyHasId, canonicalSignedBytes, - extractPublicKeyBlock, PgpArgon2UnsupportedError, type PgpSignatureSummary, + extractPublicKeyBlock, inspectKey, PgpArgon2UnsupportedError, type PgpSignatureSummary, } from './pgp'; import type { SmimeKeyMaterial, SmimeSignatureStatus } from './smime/cms'; @@ -43,11 +43,24 @@ export interface SecureAttachment { bytes: Uint8Array; } +/** + * A public key in the message for its From address. Never saved on its own: + * any sender can put a key in a message, so the banner offers it and only + * the user saves it (keyring savePgpKeyFor). + */ +export interface OfferedPgpKey { + armored: string; + fingerprint: string; + /** The From address, which one of the key's user ids names. */ + email: string; +} + export interface SecureResult { status: SecureStatus; /** The decrypted/verified body, or null when there is none to show (locked, failed). */ body: { html: string | null; text: string | null } | null; attachments: SecureAttachment[]; + offeredKey?: OfferedPgpKey; } /** What the reader needs from the key store; injected so tests need no SecureStore. */ @@ -60,8 +73,8 @@ export interface SecureKeyAccess { pgpPublicKeys(): string[]; /** Own S/MIME keys; `pkcs8` set when unlocked. */ smimeKeys(): SmimeKeyMaterial[]; - rememberPgpKey?(armored: string): Promise; - rememberCertificate?(der: Uint8Array): Promise; + /** Keep the certificate of a valid signature, if it names `fromEmail`. */ + rememberCertificate?(der: Uint8Array, fromEmail: string): Promise; } type PartLike = Pick & { subParts?: PartLike[] }; @@ -164,8 +177,7 @@ async function openPgp(raw: Uint8Array, fromEmail: string | undefined, keys: Sec } applyPgpSignature(status, sig, fromEmail); const rendered = await renderPayload(payload); - await maybeRememberEmbeddedKey(rendered.body?.text, keys); - return { status, ...rendered }; + return withOfferedKey({ status, ...rendered }, rendered.body?.text, fromEmail); } if (info.kind === 'pgp-mime-signed') { applyPgpSignature(status, await verifySignedPart(info.signedBytes, info.signature, verificationKeys), fromEmail); @@ -174,8 +186,7 @@ async function openPgp(raw: Uint8Array, fromEmail: string | undefined, keys: Sec // inline-signed const v = await verifyCleartext({ cleartext: info.cleartext, verificationKeysArmored: verificationKeys }); applyPgpSignature(status, v.signature, fromEmail); - await maybeRememberEmbeddedKey(v.text, keys); - return { status, body: { html: null, text: v.text }, attachments: [] }; + return withOfferedKey({ status, body: { html: null, text: v.text }, attachments: [] }, v.text, fromEmail); } catch (err) { status.error = err instanceof PgpArgon2UnsupportedError ? err.message : err instanceof Error ? err.message : String(err); if (status.encrypted) status.decrypted = false; @@ -194,9 +205,27 @@ async function verifySignedPart(signed: Uint8Array, signature: string, verificat return retry.status === 'valid' ? retry : first; } -async function maybeRememberEmbeddedKey(text: string | null | undefined, keys: SecureKeyAccess): Promise { +/** + * Offers a public key the text carries, only when one of its user ids is the + * From address and the signature (if any) did not fail. Nothing is stored + * here: the webmail's bulwark-gpg plugin imports keys only from its + * settings, and a key that arrived in a message would otherwise be used to + * verify and encrypt for whatever address it claims. + */ +async function withOfferedKey(result: SecureResult, text: string | null | undefined, fromEmail: string | undefined): Promise { + const from = fromEmail?.trim().toLowerCase(); + if (!from || result.status.signature === 'invalid') return result; const block = extractPublicKeyBlock(text ?? ''); - if (block && keys.rememberPgpKey) await keys.rememberPgpKey(block); + if (!block) return result; + try { + const info = await inspectKey(block); + if (info.isPrivate) return result; + if (!info.userIDs.some((u) => u.email.trim().toLowerCase() === from)) return result; + return { ...result, offeredKey: { armored: info.publicKeyArmored, fingerprint: info.fingerprint.toLowerCase(), email: from } }; + } catch { + // a broken embedded key: nothing to offer + return result; + } } function applySmimeSignature(status: SecureStatus, sig: SmimeSignatureStatus): void { @@ -228,7 +257,7 @@ async function openSmime(raw: Uint8Array, fromEmail: string | undefined, keys: S const entity = binaryToBytes(signed.raw); const sig = await cms.smimeVerifyDetached(decodeBody(sigPart), entity, fromEmail); applySmimeSignature(status, sig); - if (sig.signatureValid && sig.signerCertificate) await keys.rememberCertificate?.(sig.signerCertificate); + if (sig.signatureValid && sig.signerCertificate && fromEmail) await keys.rememberCertificate?.(sig.signerCertificate, fromEmail); return { status, ...(await renderPayload(entity)) }; } @@ -236,7 +265,7 @@ async function openSmime(raw: Uint8Array, fromEmail: string | undefined, keys: S if (cms.isSignedData(der)) { const v = await cms.smimeVerifyOpaque(der, fromEmail); applySmimeSignature(status, v.status); - if (v.status.signatureValid && v.status.signerCertificate) await keys.rememberCertificate?.(v.status.signerCertificate); + if (v.status.signatureValid && v.status.signerCertificate && fromEmail) await keys.rememberCertificate?.(v.status.signerCertificate, fromEmail); return { status, ...(await renderPayload(v.mimeBytes)) }; } @@ -274,7 +303,7 @@ async function openSmime(raw: Uint8Array, fromEmail: string | undefined, keys: S if (innerSigned && cms.isSignedData(innerSigned)) { const v = await cms.smimeVerifyOpaque(innerSigned, fromEmail); applySmimeSignature(status, v.status); - if (v.status.signatureValid && v.status.signerCertificate) await keys.rememberCertificate?.(v.status.signerCertificate); + if (v.status.signatureValid && v.status.signerCertificate && fromEmail) await keys.rememberCertificate?.(v.status.signerCertificate, fromEmail); payload = v.mimeBytes; } else if (inner.type === 'multipart/signed' && /pkcs7-signature/i.test(inner.params.protocol || '')) { const full = parseEntity(bytesToBinary(decrypted));