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));