mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
fix: stop saving pgp keys from messages on their own and offer to save a key only for the sender's address
This commit is contained in:
@@ -707,8 +707,11 @@
|
|||||||
"failed": "Could not decrypt: {reason}",
|
"failed": "Could not decrypt: {reason}",
|
||||||
"invalid": "Invalid signature ({kind})",
|
"invalid": "Invalid signature ({kind})",
|
||||||
"invalid_reason": "Invalid signature ({kind}): {reason}",
|
"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.",
|
"locked": "Encrypted ({kind}). Unlock your key to read it.",
|
||||||
"no_key": "Encrypted ({kind}) to a key that is not on this device.",
|
"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.",
|
"self_signed": "The certificate is self-signed, not issued by a known authority.",
|
||||||
"signed": "Valid signature ({kind})",
|
"signed": "Valid signature ({kind})",
|
||||||
"signed_by": "Valid signature ({kind}) from {signer}",
|
"signed_by": "Valid signature ({kind}) from {signer}",
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function MessageContent({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(secure.loading || secure.result) && <SecureMessageBanner status={secure.result?.status ?? null} loading={secure.loading} />}
|
{(secure.loading || secure.result) && <SecureMessageBanner status={secure.result?.status ?? null} loading={secure.loading} offeredKey={secure.result?.offeredKey} />}
|
||||||
<CalendarInvitationBanner email={email} jmapAccountId={jmapAccountId} />
|
<CalendarInvitationBanner email={email} jmapAccountId={jmapAccountId} />
|
||||||
<StructuredMailBanner email={email} jmapAccountId={jmapAccountId} />
|
<StructuredMailBanner email={email} jmapAccountId={jmapAccountId} />
|
||||||
|
|
||||||
|
|||||||
@@ -7,16 +7,22 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
|
|||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { useCryptoKeysStore, type CryptoKeyRecord } from '../../stores/crypto-keys-store';
|
import { useCryptoKeysStore, type CryptoKeyRecord } from '../../stores/crypto-keys-store';
|
||||||
import type { SecureStatus } from '../../lib/crypto/secure-message';
|
import type { OfferedPgpKey, SecureStatus } from '../../lib/crypto/secure-message';
|
||||||
import { secureBannerRows, type BannerTone as Tone } from '../../lib/crypto/banner-rows';
|
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 c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
const keys = useCryptoKeysStore((s) => s.keys);
|
const keys = useCryptoKeysStore((s) => s.keys);
|
||||||
const [unlockRecord, setUnlockRecord] = React.useState<CryptoKeyRecord | null>(null);
|
const [unlockRecord, setUnlockRecord] = React.useState<CryptoKeyRecord | null>(null);
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -28,11 +34,23 @@ export function SecureMessageBanner({ status, loading }: { status: SecureStatus
|
|||||||
}
|
}
|
||||||
if (!status) return null;
|
if (!status) return null;
|
||||||
const rows = secureBannerRows(status, t);
|
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 color = (tone: Tone) => (tone === 'ok' ? c.success : tone === 'warn' ? c.warning : tone === 'error' ? c.error : c.textMuted);
|
||||||
const lockedRecord = status.lockedKeyIds?.length
|
const lockedRecord = status.lockedKeyIds?.length
|
||||||
? keys.find((k) => k.kind === status.kind && status.lockedKeyIds!.includes(k.id)) ?? null
|
? keys.find((k) => k.kind === status.kind && status.lockedKeyIds!.includes(k.id)) ?? null
|
||||||
: 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 (
|
return (
|
||||||
<View style={styles.wrap} accessibilityRole="summary">
|
<View style={styles.wrap} accessibilityRole="summary">
|
||||||
@@ -48,6 +66,13 @@ export function SecureMessageBanner({ status, loading }: { status: SecureStatus
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{offer && (
|
||||||
|
<View style={[styles.row, { borderColor: c.border }]}>
|
||||||
|
<Shield size={16} color={c.textMuted} />
|
||||||
|
<Text style={[styles.text, { color: c.textMuted }]}>{offer.text}</Text>
|
||||||
|
<Button size="sm" variant="outline" disabled={saving} onPress={() => { void saveOffered(); }}>{offer.action}</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<UnlockKeyDialog
|
<UnlockKeyDialog
|
||||||
record={unlockRecord}
|
record={unlockRecord}
|
||||||
reason={t('encryption.unlock_read_reason', 'The message is encrypted to this key.')}
|
reason={t('encryption.unlock_read_reason', 'The message is encrypted to this key.')}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Email } from '../../api/types';
|
|||||||
import { fetchBlobBytes } from '../../lib/email-export';
|
import { fetchBlobBytes } from '../../lib/email-export';
|
||||||
import { secureKindHint, openSecureMessage, type SecureKeyAccess, type SecureResult } from '../../lib/crypto/secure-message';
|
import { secureKindHint, openSecureMessage, type SecureKeyAccess, type SecureResult } from '../../lib/crypto/secure-message';
|
||||||
import {
|
import {
|
||||||
unlockedPgpKeys, ownKeys, allPgpPublicKeys, isUnlocked, unlockedSmimeKey, onUnlockChange, rememberSignerCertificate, rememberSignerPgpKey,
|
unlockedPgpKeys, ownKeys, allPgpPublicKeys, isUnlocked, unlockedSmimeKey, onUnlockChange, rememberSignerCertificate,
|
||||||
unlockIfUnprotected,
|
unlockIfUnprotected,
|
||||||
} from '../../lib/crypto/keyring';
|
} from '../../lib/crypto/keyring';
|
||||||
import { base64Decode } from '../../lib/crypto/mime';
|
import { base64Decode } from '../../lib/crypto/mime';
|
||||||
@@ -17,7 +17,6 @@ export function keyringAccess(): SecureKeyAccess {
|
|||||||
pgpOwnKeys: () => (ownKeys('pgp') as PgpKeyRecord[]).map((k) => ({ id: k.id, publicKeyArmored: k.publicKeyArmored, unlocked: isUnlocked(k) })),
|
pgpOwnKeys: () => (ownKeys('pgp') as PgpKeyRecord[]).map((k) => ({ id: k.id, publicKeyArmored: k.publicKeyArmored, unlocked: isUnlocked(k) })),
|
||||||
pgpPublicKeys: allPgpPublicKeys,
|
pgpPublicKeys: allPgpPublicKeys,
|
||||||
smimeKeys: () => (ownKeys('smime') as SmimeCertRecord[]).map((k) => ({ id: k.id, certificate: base64Decode(k.certificate), pkcs8: unlockedSmimeKey(k.id)?.pkcs8 })),
|
smimeKeys: () => (ownKeys('smime') as SmimeCertRecord[]).map((k) => ({ id: k.id, certificate: base64Decode(k.certificate), pkcs8: unlockedSmimeKey(k.id)?.pkcs8 })),
|
||||||
rememberPgpKey: rememberSignerPgpKey,
|
|
||||||
rememberCertificate: rememberSignerCertificate,
|
rememberCertificate: rememberSignerCertificate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// What the reader's banner says for each PGP / S/MIME outcome (#58).
|
// What the reader's banner says for each PGP / S/MIME outcome (#58).
|
||||||
import { describe, expect, it } from 'vitest';
|
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<string, string | number>) =>
|
const t = (_k: string, fallback: string, vars?: Record<string, string | number>) =>
|
||||||
fallback.replace(/\{(\w+)\}/g, (_, name: string) => String(vars?.[name] ?? ''));
|
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([]);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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<string, string>());
|
||||||
|
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<ReturnType<typeof makePgpKey>>;
|
||||||
|
let mallory: Awaited<ReturnType<typeof makePgpKey>>;
|
||||||
|
let bob: Awaited<ReturnType<typeof makePgpKey>>;
|
||||||
|
|
||||||
|
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<Uint8Array> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// Banner rows for a PGP / S/MIME status (#58).
|
// 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';
|
export type BannerTone = 'ok' | 'warn' | 'error' | 'muted';
|
||||||
type Translate = (key: string, fallback: string, vars?: Record<string, string | number>) => string;
|
type Translate = (key: string, fallback: string, vars?: Record<string, string | number>) => string;
|
||||||
@@ -49,3 +50,27 @@ export function secureBannerRows(status: SecureStatus, t: Translate): BannerRow[
|
|||||||
return rows;
|
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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-11
@@ -219,23 +219,38 @@ export function allPgpPublicKeys(): string[] {
|
|||||||
return records().filter((k): k is PgpKeyRecord => k.kind === 'pgp').map((k) => k.publicKeyArmored);
|
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<void> {
|
* Save a public key that came in a message from `email`, when the user asks
|
||||||
try {
|
* to (the reader's banner offers it). Refused unless one of the key's user
|
||||||
const info = await inspectKey(armored);
|
* ids is that address: a sender must not plant a key for someone else.
|
||||||
if (info.isPrivate) return;
|
*/
|
||||||
if (records().some((k) => k.kind === 'pgp' && k.id === info.fingerprint.toLowerCase())) return;
|
export async function savePgpKeyFor(armored: string, email: string): Promise<PgpKeyRecord> {
|
||||||
upsert(pgpRecord(info, false, 'message'));
|
const info = await inspectKey(armored);
|
||||||
} catch {
|
if (info.isPrivate) throw new Error('Only a public key can be saved from a message');
|
||||||
// a broken embedded key: ignore
|
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<void> {
|
/**
|
||||||
|
* 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<void> {
|
||||||
try {
|
try {
|
||||||
const { describeCertificate } = await import('./smime/certificate');
|
const { describeCertificate } = await import('./smime/certificate');
|
||||||
const info = await describeCertificate(der);
|
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;
|
if (records().some((k) => k.kind === 'smime' && k.id === info.fingerprint)) return;
|
||||||
upsert(smimeRecord(info, der, [], false, 'message'));
|
upsert(smimeRecord(info, der, [], false, 'message'));
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { BodyPart, Email } from '../../api/types';
|
|||||||
import { binaryToBytes, bytesToBinary, decodeBody, findNode, parseEntity, type MimeNode } from './mime';
|
import { binaryToBytes, bytesToBinary, decodeBody, findNode, parseEntity, type MimeNode } from './mime';
|
||||||
import {
|
import {
|
||||||
detectPgp, decryptMessage, verifyDetached, verifyCleartext, encryptionKeyIds, keyHasId, canonicalSignedBytes,
|
detectPgp, decryptMessage, verifyDetached, verifyCleartext, encryptionKeyIds, keyHasId, canonicalSignedBytes,
|
||||||
extractPublicKeyBlock, PgpArgon2UnsupportedError, type PgpSignatureSummary,
|
extractPublicKeyBlock, inspectKey, PgpArgon2UnsupportedError, type PgpSignatureSummary,
|
||||||
} from './pgp';
|
} from './pgp';
|
||||||
import type { SmimeKeyMaterial, SmimeSignatureStatus } from './smime/cms';
|
import type { SmimeKeyMaterial, SmimeSignatureStatus } from './smime/cms';
|
||||||
|
|
||||||
@@ -43,11 +43,24 @@ export interface SecureAttachment {
|
|||||||
bytes: Uint8Array;
|
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 {
|
export interface SecureResult {
|
||||||
status: SecureStatus;
|
status: SecureStatus;
|
||||||
/** The decrypted/verified body, or null when there is none to show (locked, failed). */
|
/** The decrypted/verified body, or null when there is none to show (locked, failed). */
|
||||||
body: { html: string | null; text: string | null } | null;
|
body: { html: string | null; text: string | null } | null;
|
||||||
attachments: SecureAttachment[];
|
attachments: SecureAttachment[];
|
||||||
|
offeredKey?: OfferedPgpKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the reader needs from the key store; injected so tests need no SecureStore. */
|
/** What the reader needs from the key store; injected so tests need no SecureStore. */
|
||||||
@@ -60,8 +73,8 @@ export interface SecureKeyAccess {
|
|||||||
pgpPublicKeys(): string[];
|
pgpPublicKeys(): string[];
|
||||||
/** Own S/MIME keys; `pkcs8` set when unlocked. */
|
/** Own S/MIME keys; `pkcs8` set when unlocked. */
|
||||||
smimeKeys(): SmimeKeyMaterial[];
|
smimeKeys(): SmimeKeyMaterial[];
|
||||||
rememberPgpKey?(armored: string): Promise<void>;
|
/** Keep the certificate of a valid signature, if it names `fromEmail`. */
|
||||||
rememberCertificate?(der: Uint8Array): Promise<void>;
|
rememberCertificate?(der: Uint8Array, fromEmail: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PartLike = Pick<BodyPart, 'type'> & { subParts?: PartLike[] };
|
type PartLike = Pick<BodyPart, 'type'> & { subParts?: PartLike[] };
|
||||||
@@ -164,8 +177,7 @@ async function openPgp(raw: Uint8Array, fromEmail: string | undefined, keys: Sec
|
|||||||
}
|
}
|
||||||
applyPgpSignature(status, sig, fromEmail);
|
applyPgpSignature(status, sig, fromEmail);
|
||||||
const rendered = await renderPayload(payload);
|
const rendered = await renderPayload(payload);
|
||||||
await maybeRememberEmbeddedKey(rendered.body?.text, keys);
|
return withOfferedKey({ status, ...rendered }, rendered.body?.text, fromEmail);
|
||||||
return { status, ...rendered };
|
|
||||||
}
|
}
|
||||||
if (info.kind === 'pgp-mime-signed') {
|
if (info.kind === 'pgp-mime-signed') {
|
||||||
applyPgpSignature(status, await verifySignedPart(info.signedBytes, info.signature, verificationKeys), fromEmail);
|
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
|
// inline-signed
|
||||||
const v = await verifyCleartext({ cleartext: info.cleartext, verificationKeysArmored: verificationKeys });
|
const v = await verifyCleartext({ cleartext: info.cleartext, verificationKeysArmored: verificationKeys });
|
||||||
applyPgpSignature(status, v.signature, fromEmail);
|
applyPgpSignature(status, v.signature, fromEmail);
|
||||||
await maybeRememberEmbeddedKey(v.text, keys);
|
return withOfferedKey({ status, body: { html: null, text: v.text }, attachments: [] }, v.text, fromEmail);
|
||||||
return { status, body: { html: null, text: v.text }, attachments: [] };
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status.error = err instanceof PgpArgon2UnsupportedError ? err.message : err instanceof Error ? err.message : String(err);
|
status.error = err instanceof PgpArgon2UnsupportedError ? err.message : err instanceof Error ? err.message : String(err);
|
||||||
if (status.encrypted) status.decrypted = false;
|
if (status.encrypted) status.decrypted = false;
|
||||||
@@ -194,9 +205,27 @@ async function verifySignedPart(signed: Uint8Array, signature: string, verificat
|
|||||||
return retry.status === 'valid' ? retry : first;
|
return retry.status === 'valid' ? retry : first;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybeRememberEmbeddedKey(text: string | null | undefined, keys: SecureKeyAccess): Promise<void> {
|
/**
|
||||||
|
* 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<SecureResult> {
|
||||||
|
const from = fromEmail?.trim().toLowerCase();
|
||||||
|
if (!from || result.status.signature === 'invalid') return result;
|
||||||
const block = extractPublicKeyBlock(text ?? '');
|
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 {
|
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 entity = binaryToBytes(signed.raw);
|
||||||
const sig = await cms.smimeVerifyDetached(decodeBody(sigPart), entity, fromEmail);
|
const sig = await cms.smimeVerifyDetached(decodeBody(sigPart), entity, fromEmail);
|
||||||
applySmimeSignature(status, sig);
|
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)) };
|
return { status, ...(await renderPayload(entity)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +265,7 @@ async function openSmime(raw: Uint8Array, fromEmail: string | undefined, keys: S
|
|||||||
if (cms.isSignedData(der)) {
|
if (cms.isSignedData(der)) {
|
||||||
const v = await cms.smimeVerifyOpaque(der, fromEmail);
|
const v = await cms.smimeVerifyOpaque(der, fromEmail);
|
||||||
applySmimeSignature(status, v.status);
|
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)) };
|
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)) {
|
if (innerSigned && cms.isSignedData(innerSigned)) {
|
||||||
const v = await cms.smimeVerifyOpaque(innerSigned, fromEmail);
|
const v = await cms.smimeVerifyOpaque(innerSigned, fromEmail);
|
||||||
applySmimeSignature(status, v.status);
|
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;
|
payload = v.mimeBytes;
|
||||||
} else if (inner.type === 'multipart/signed' && /pkcs7-signature/i.test(inner.params.protocol || '')) {
|
} else if (inner.type === 'multipart/signed' && /pkcs7-signature/i.test(inner.params.protocol || '')) {
|
||||||
const full = parseEntity(bytesToBinary(decrypted));
|
const full = parseEntity(bytesToBinary(decrypted));
|
||||||
|
|||||||
Reference in New Issue
Block a user