From b0997c0a13b9064e7879d6dc6b4bf54e6c086243 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:10:01 +0100 Subject: [PATCH] feat: sign and encrypt mail with pgp or s/mime from the composer options #58 --- src/api/__tests__/email-send-raw.test.ts | 72 +++++++ src/api/email.ts | 21 +- .../compose/ComposerOptionsSheet.tsx | 74 ++++++- .../__tests__/compose-secure-wiring.test.ts | 35 ++++ .../crypto/__tests__/compose-secure.test.ts | 115 ++++++++++ src/lib/crypto/compose-secure.ts | 198 ++++++++++++++++++ src/screens/ComposeScreen.tsx | 128 ++++++++++- 7 files changed, 629 insertions(+), 14 deletions(-) create mode 100644 src/api/__tests__/email-send-raw.test.ts create mode 100644 src/lib/__tests__/compose-secure-wiring.test.ts create mode 100644 src/lib/crypto/__tests__/compose-secure.test.ts create mode 100644 src/lib/crypto/compose-secure.ts diff --git a/src/api/__tests__/email-send-raw.test.ts b/src/api/__tests__/email-send-raw.test.ts new file mode 100644 index 00000000..a15a4111 --- /dev/null +++ b/src/api/__tests__/email-send-raw.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Sending an uploaded raw message (#58): a signed or encrypted PGP / S/MIME +// message is added with Email/import and submitted like any other, and the +// envelope names every recipient, Bcc included, since the raw headers don't. + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + request: vi.fn(), + learnHoldLimit: vi.fn(), + getSubmissionAccountIds: vi.fn(() => ['acc-1']), + hasDelayedSend: vi.fn(() => true), + hasAccountCapability: vi.fn(() => false), + getMaxCallsInRequest: vi.fn(() => 16), + getMaxObjectsInGet: vi.fn(() => 500), + getMaxObjectsInSet: vi.fn(() => 500), + getAccountCapability: vi.fn(), + }, +})); + +import { jmapClient } from '../jmap-client'; +import { sendEmail } from '../email'; + +const mockRequest = jmapClient.request as ReturnType; + +const OUTGOING = { + from: [{ email: 'me@example.com' }], + to: [{ email: 'you@example.com' }], + bcc: [{ email: 'hidden@example.com' }], + subject: 'Secret', + textBody: 'not sent as is', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Email/import', { created: { draft: { id: 'e-raw' } } }, '0'], + ['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'], + ], + }); +}); + +describe('sendEmail with a raw blob', () => { + it('imports the blob into Drafts, submits it with every recipient in the envelope and files it in Sent', async () => { + const result = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts', rawBlobId: 'blob-raw' }); + const calls = mockRequest.mock.calls[0][0] as Array<[string, Record, string]>; + expect(calls.find(([name]) => name === 'Email/set')).toBeUndefined(); + const [, importArgs] = calls.find(([name]) => name === 'Email/import')!; + expect(importArgs.emails.draft).toEqual({ blobId: 'blob-raw', mailboxIds: { drafts: true }, keywords: { $seen: true, $draft: true } }); + const submission = calls.find(([name]) => name === 'EmailSubmission/set')![1]; + expect(submission.create['sub-1'].emailId).toBe('#draft'); + expect(submission.create['sub-1'].envelope).toEqual({ + mailFrom: { email: 'me@example.com' }, + rcptTo: [{ email: 'you@example.com' }, { email: 'hidden@example.com' }], + }); + expect(submission.onSuccessUpdateEmail['#sub-1'].mailboxIds).toEqual({ sent: true }); + expect(result.emailId).toBe('e-raw'); + expect(result.emailSubmissionId).toBe('s-1'); + }); + + it('reports an import the server refused', async () => { + mockRequest.mockResolvedValueOnce({ + methodResponses: [ + ['Email/import', { notCreated: { draft: { type: 'invalidEmail', description: 'bad message' } } }, '0'], + ['EmailSubmission/set', { notCreated: { 'sub-1': { type: 'invalidProperties' } } }, '1'], + ], + }); + await expect(sendEmail(OUTGOING, 'id-1', 'sent', undefined, { rawBlobId: 'blob-raw' })).rejects.toThrow(/bad message/); + }); +}); diff --git a/src/api/email.ts b/src/api/email.ts index 23f2853c..31a3c724 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -1754,6 +1754,13 @@ export interface SendEmailOptions { requestDsn?: boolean; /** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */ requireTls?: boolean; + /** + * A complete message already uploaded as a blob (a signed or encrypted + * PGP / S/MIME message, #58): it is added with Email/import instead of + * built with Email/set, and the envelope always names every recipient, + * since Bcc is not in its headers. The webmail's sendRawEmail. + */ + rawBlobId?: string; } export async function sendEmail( @@ -1767,7 +1774,8 @@ export async function sendEmail( opts?: SendEmailOptions, ): Promise { const accountId = opts?.accountId ?? jmapClient.accountId; - const emailCreate = buildEmailCreate(email); + const raw = !!opts?.rawBlobId; + const emailCreate: Record = raw ? { blobId: opts!.rawBlobId } : buildEmailCreate(email); const viaDrafts = !!opts?.draftsMailboxId; if (viaDrafts) { emailCreate.mailboxIds = { [opts!.draftsMailboxId!]: true }; @@ -1776,6 +1784,9 @@ export async function sendEmail( emailCreate.mailboxIds = { [sentMailboxId]: true }; emailCreate.keywords = { $seen: true }; } + const createCall: [string, Record, string] = raw + ? ['Email/import', { accountId, emails: { draft: emailCreate } }, '0'] + : ['Email/set', { accountId, create: { draft: emailCreate } }, '0']; const submissionCreate: Record = { emailId: '#draft', identityId }; // For a deferred send the envelope must be set explicitly so the HOLDFOR @@ -1791,7 +1802,7 @@ export async function sendEmail( ); const hasMailFromParams = Object.keys(parameters.mailFrom).length > 0; const hasRcptToParams = Object.keys(parameters.rcptTo).length > 0; - if (hasMailFromParams || hasRcptToParams || email.envelopeMailFrom) { + if (hasMailFromParams || hasRcptToParams || email.envelopeMailFrom || raw) { const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])] .map((r) => r.email.trim()) .filter(Boolean) @@ -1818,7 +1829,7 @@ export async function sendEmail( let res = await jmapClient.request( [ - ['Email/set', { accountId, create: { draft: emailCreate } }, '0'], + createCall, ['EmailSubmission/set', submissionArgs, '1'], // Which recipients the server took. A refused one is lost without a // bounce while the create succeeds and files the message in Sent. @@ -1830,7 +1841,7 @@ export async function sendEmail( // Stalwart 1.0 refuses a submission once the account holds `maxSubmissions` // records (overQuota). The message exists by then, so make room and submit // that copy once more instead of failing the send. - const copyCreated = res.methodResponses.find(([name, , callId]) => name === 'Email/set' && callId === '0'); + const copyCreated = res.methodResponses.find(([name, , callId]) => (name === 'Email/set' || name === 'Email/import') && callId === '0'); const copyId = (copyCreated?.[1] as { created?: Record } | undefined)?.created?.draft?.id; if (copyCreated && copyId && submissionCreateError(res, 'sub-1')?.type === 'overQuota') { await makeRoomForSubmission(accountId); @@ -1871,7 +1882,7 @@ export async function sendEmail( failure = new Error((result as { description?: string }).description ?? 'Send failed'); break; } - if (methodName === 'Email/set') { + if (methodName === 'Email/set' || methodName === 'Email/import') { const notCreated = (result as { notCreated?: Record }).notCreated?.draft; if (notCreated) { const props = notCreated.properties?.length ? ` (properties: ${notCreated.properties.join(', ')})` : ''; diff --git a/src/components/compose/ComposerOptionsSheet.tsx b/src/components/compose/ComposerOptionsSheet.tsx index 268db679..b6472f2c 100644 --- a/src/components/compose/ComposerOptionsSheet.tsx +++ b/src/components/compose/ComposerOptionsSheet.tsx @@ -8,12 +8,23 @@ import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { MESSAGE_PRIORITIES, type MessagePriority } from '../../lib/message-priority'; import { useSheetBottomInset } from '../../lib/sheet-bottom-inset'; +import type { SecureMode } from '../../lib/crypto/secure-send'; export interface ComposerOptions { requestReadReceipt: boolean; requestDsn: boolean; requireTls: boolean; priority: MessagePriority; + /** Sign / encrypt with OpenPGP or S/MIME (#58; the plugins' Options entries). */ + secureMode: SecureMode; + sign: boolean; + encrypt: boolean; +} + +/** Which kinds of signing/encryption the From address has a key for. */ +export interface SecureAvailability { + pgp: boolean; + smime: boolean; } /** @@ -21,23 +32,28 @@ export interface ComposerOptions { * toolbar button (webmail composer options menu `activeCount`). */ export function activeComposerOptionCount(options: ComposerOptions): number { - return [options.requestReadReceipt, options.requestDsn, options.requireTls, options.priority !== 'normal'] + return [options.requestReadReceipt, options.requestDsn, options.requireTls, options.priority !== 'normal', options.sign, options.encrypt] .filter(Boolean).length; } /** * The composer's per-message options (webmail email-composer.tsx options * menu): read receipt, delivery status notification and REQUIRETLS - the - * last two only when the account's submission extensions list them - and - * the message priority. + * last two only when the account's submission extensions list them - the + * message priority, and Sign / Encrypt with OpenPGP or S/MIME when the From + * address has a key (the PGP and S/MIME plugins' Options entries). */ export function ComposerOptionsSheet({ - visible, options, dsnAvailable, requireTlsAvailable, onChange, onClose, + visible, options, dsnAvailable, requireTlsAvailable, secureAvailable, secureHint, onChange, onClose, }: { visible: boolean; options: ComposerOptions; dsnAvailable: boolean; requireTlsAvailable: boolean; + /** Own keys for the From address; the section is left out without any. */ + secureAvailable?: SecureAvailability; + /** Why the chosen sign/encrypt can't go out yet (a recipient without a key). */ + secureHint?: string | null; onChange: (next: Partial) => void; onClose: () => void; }) { @@ -46,7 +62,7 @@ export function ComposerOptionsSheet({ const t = useLocaleStore((s) => s.t); const bottomInset = useSheetBottomInset(visible); - const toggleRow = (label: string, hint: string, value: boolean, key: 'requestReadReceipt' | 'requestDsn' | 'requireTls') => ( + const toggleRow = (label: string, hint: string, value: boolean, key: 'requestReadReceipt' | 'requestDsn' | 'requireTls' | 'sign' | 'encrypt') => ( onChange({ [key]: !value })} @@ -92,6 +108,47 @@ export function ComposerOptionsSheet({ options.requireTls, 'requireTls', )} + {secureAvailable && (secureAvailable.pgp || secureAvailable.smime) && ( + <> + + {t('email_composer.option_security', 'Signature and encryption')} + {secureAvailable.pgp && secureAvailable.smime && ( + + {(['pgp', 'smime'] as const).map((mode) => { + const selected = options.secureMode === mode; + const label = mode === 'pgp' ? 'OpenPGP' : 'S/MIME'; + return ( + onChange({ secureMode: mode })} + accessibilityRole="radio" + accessibilityLabel={label} + accessibilityState={{ checked: selected, selected }} + > + {label} + + ); + })} + + )} + {toggleRow( + t('email_composer.option_sign', 'Sign'), + options.secureMode === 'pgp' + ? t('email_composer.option_sign_pgp_hint', 'Add an OpenPGP signature with your key') + : t('email_composer.option_sign_smime_hint', 'Add an S/MIME signature with your certificate'), + options.sign, + 'sign', + )} + {toggleRow( + t('email_composer.option_encrypt', 'Encrypt'), + t('email_composer.option_encrypt_hint', 'Only the recipients (and you) can read it; every recipient needs a key'), + options.encrypt, + 'encrypt', + )} + {!!secureHint && (options.sign || options.encrypt) && {secureHint}} + + )} {t('email_composer.option_priority', 'Priority')} @@ -159,6 +216,13 @@ function makeStyles(c: ThemePalette) { rowHint: { ...typography.caption, color: c.textMuted }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.borderLight, marginVertical: spacing.xs }, groupLabel: { ...typography.captionMedium, color: c.textSecondary }, + segment: { flexDirection: 'row', gap: spacing.xs, paddingVertical: spacing.xs }, + segmentItem: { + flex: 1, alignItems: 'center', paddingVertical: spacing.sm, borderRadius: radius.sm, borderWidth: 1, borderColor: c.border, + }, + segmentItemSelected: { borderColor: c.primary, backgroundColor: c.primaryBg }, + segmentTextSelected: { color: c.primary }, + warning: { ...typography.caption, color: c.warning }, done: { alignItems: 'center', paddingVertical: spacing.sm, marginTop: spacing.xs }, doneText: { ...typography.bodyMedium, color: c.primary }, }); diff --git a/src/lib/__tests__/compose-secure-wiring.test.ts b/src/lib/__tests__/compose-secure-wiring.test.ts new file mode 100644 index 00000000..d6c7259b --- /dev/null +++ b/src/lib/__tests__/compose-secure-wiring.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Sign / encrypt in the composer (#58) as wired in ComposeScreen, checked on +// its source (the suite has no RN renderer): the options sheet gets the +// availability, a secure send goes out as the uploaded raw message, and it +// never lands in the Outbox, which would send it as plain mail later. +const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8'); +const reader = fs.readFileSync(path.resolve(__dirname, '../../components/email/MessageContent.tsx'), 'utf8'); + +describe('ComposeScreen sign / encrypt wiring', () => { + it('passes the keys the From address has to the options sheet', () => { + expect(source).toMatch(/ { + expect(source).toMatch(/const blobId = await prepareSecureBlob\(secureChoice, outgoing, target\.accountId\);/); + expect(source).toMatch(/requireTls: requireTls && supportsSubmissionExtension\('REQUIRETLS', target\.accountId\),\s*rawBlobId,/); + }); + + it('keeps a secure send out of the Outbox', () => { + const block = source.slice(source.indexOf('if (secureChoice) {'), source.indexOf('const target = await sendTargetFor(primaryIdentity')); + expect(block).toMatch(/if \(!useNetworkStore\.getState\(\)\.online\) \{[\s\S]*?return;/); + expect(block).toMatch(/\} else \{\s*queuedOutgoing = outgoing;/); + }); +}); + +describe('reader wiring', () => { + it('shows the decrypted body and the banner', () => { + expect(reader).toMatch(/const secure = useSecureMessage\(email, jmapAccountId\);/); + expect(reader).toMatch(/bodyOverride=\{bodyOverride\}/); + expect(reader).toMatch(/ 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); }), +})); + +let me: Awaited>; +let friend: Awaited>; + +beforeAll(async () => { + useNodeCrypto(); + me = await makePgpKey('me@example.org'); + friend = await makePgpKey('friend@example.org'); +}, 60_000); + +beforeEach(() => { + secure.clear(); + lockAll(); + useCryptoKeysStore.setState({ keys: [] }); +}); + +const outgoing = (bcc: string[] = []): OutgoingEmail => ({ + from: [{ name: 'Me', email: 'me@example.org' }], + to: [{ email: 'friend@example.org' }], + bcc: bcc.map((email) => ({ email })), + subject: 'Encrypted with attachment', + textBody: 'see attached', + htmlBody: '

see attached

', + attachments: [{ blobId: 'b1', name: 'report.pdf', type: 'application/pdf', size: 3, disposition: 'attachment' }], + priority: 'high', + requestReadReceipt: true, +}); + +const contactWithKey = (armored: string): ContactCard => ({ + id: 'c1', + emails: { e1: { address: 'Friend@Example.org' } }, + cryptoKeys: { k1: { uri: `data:application/pgp-keys;base64,${Buffer.from(armored).toString('base64')}` } }, +} as unknown as ContactCard); + +describe('recipient keys', () => { + it('finds a key in a contact card when none is stored, and names recipients without one', async () => { + expect(resolveRecipientKeys('pgp', ['friend@example.org']).missing).toEqual(['friend@example.org']); + const r = resolveRecipientKeys('pgp', ['friend@example.org', 'stranger@example.org'], [contactWithKey(friend.publicKey)]); + expect(r.missing).toEqual(['stranger@example.org']); + expect(r.found).toEqual([friend.publicKey]); + }); +}); + +describe('prepareSecureSend', () => { + it('uploads a signed and encrypted PGP/MIME message the recipient can read, attachment and headers included', async () => { + await importPgpKey(me.privateKey, me.passphrase); + await importPgpKey(friend.publicKey); + let uploaded: Uint8Array | null = null; + const blobId = await prepareSecureSend(outgoing(), { mode: 'pgp', sign: true, encrypt: true }, { + fetchBlob: async () => new Uint8Array([1, 2, 3]), + upload: async (bytes) => { + uploaded = bytes; + return 'raw-blob'; + }, + }); + expect(blobId).toBe('raw-blob'); + const text = new TextDecoder().decode(uploaded!); + expect(text).toMatch(/^X-Priority: 1 \(Highest\)$/m); + expect(text).toMatch(/^Disposition-Notification-To: me@example.org$/m); + expect(text).toMatch(/^From: "Me" $/m); + + const res = await openSecureMessage(uploaded!, 'me@example.org', { + pgpDecryptionKeys: () => [friend.unlocked], + pgpOwnKeys: () => [{ id: 'friend', publicKeyArmored: friend.publicKey, unlocked: true }], + pgpPublicKeys: () => [me.publicKey], + smimeKeys: () => [], + }); + expect(res?.status).toMatchObject({ decrypted: true, signature: 'valid', signerEmail: 'me@example.org' }); + expect(res?.attachments.map((a) => [a.name, Array.from(a.bytes)])).toEqual([['report.pdf', [1, 2, 3]]]); + }, 60_000); + + it('never writes Bcc into the message', async () => { + await importPgpKey(me.privateKey, me.passphrase); + await importPgpKey(friend.publicKey); + let uploaded: Uint8Array | null = null; + // The Bcc recipient needs a key too: give them the friend's for the test. + const contacts = [{ id: 'c2', emails: { e: { address: 'bcc@example.org' } }, cryptoKeys: contactWithKey(friend.publicKey).cryptoKeys } as unknown as ContactCard]; + await prepareSecureSend(outgoing(['bcc@example.org']), { mode: 'pgp', sign: false, encrypt: true }, { + contacts, + fetchBlob: async () => new Uint8Array([1]), + upload: async (bytes) => { uploaded = bytes; return 'x'; }, + }); + expect(new TextDecoder().decode(uploaded!)).not.toMatch(/bcc@example.org/i); + }, 60_000); + + it('stops when a recipient has no key, or when the signing key is locked', async () => { + await importPgpKey(me.privateKey, me.passphrase); + const deps = { fetchBlob: async () => new Uint8Array(), upload: async () => 'x' }; + await expect(prepareSecureSend(outgoing(), { mode: 'pgp', sign: false, encrypt: true }, deps)) + .rejects.toMatchObject({ code: 'missing-recipients', detail: ['friend@example.org'] }); + lockAll(); + await expect(prepareSecureSend(outgoing(), { mode: 'pgp', sign: true, encrypt: false }, deps)).rejects.toBeInstanceOf(SecureSendError); + useCryptoKeysStore.setState({ keys: [] }); + await expect(prepareSecureSend(outgoing(), { mode: 'pgp', sign: true, encrypt: false }, deps)).rejects.toMatchObject({ code: 'no-own-key' }); + }, 60_000); +}); diff --git a/src/lib/crypto/compose-secure.ts b/src/lib/crypto/compose-secure.ts new file mode 100644 index 00000000..053f7b30 --- /dev/null +++ b/src/lib/crypto/compose-secure.ts @@ -0,0 +1,198 @@ +// Signing / encrypting from the composer (#58): which keys a send needs, +// whether every recipient has one, and turning the composer's outgoing +// message into the raw signed/encrypted blob that sendEmail imports. The +// plugins' onComposeSend (repos/plugins/bulwark-gpg/src/index.js, +// repos/plugins/smime/src/index.js) do the same with host.jmap.sendRaw. + +import type { OutgoingEmail } from '../../api/email'; +import type { ContactCard } from '../../api/types'; +import { useCryptoKeysStore, keysForEmail, isRecordCurrent, type CryptoKeyRecord, type PgpKeyRecord, type SmimeCertRecord } from '../../stores/crypto-keys-store'; +import { messagePriorityHeaders } from '../message-priority'; +import { base64Decode, formatHeader, encodeHeaderWords, type MimeAttachment, type MimeMessageInput } from './mime'; +import { buildSecureMessage, type SecureMode } from './secure-send'; +import { ownKeyFor, unlockedPgpKey, unlockedSmimeKey } from './keyring'; + +export interface SecureChoice { + mode: SecureMode; + sign: boolean; + encrypt: boolean; +} + +export const NO_SECURE_CHOICE: SecureChoice = Object.freeze({ mode: 'pgp', sign: false, encrypt: false }) as SecureChoice; + +export function isSecureChoice(choice: SecureChoice | null | undefined): choice is SecureChoice { + return !!choice && (choice.sign || choice.encrypt); +} + +/** A recipient key found in a contact card (JSContact cryptoKeys, vCard KEY). */ +function keysFromContacts(kind: SecureMode, email: string, contacts: ContactCard[]): Array { + const e = email.toLowerCase(); + const out: Array = []; + for (const card of contacts) { + const emails = Object.values(card.emails ?? {}).map((m) => (m.address ?? '').toLowerCase()); + if (!emails.includes(e)) continue; + for (const key of Object.values(card.cryptoKeys ?? {})) { + const decoded = decodeDataUri(key.uri); + if (!decoded) continue; + const type = (key.mediaType || decoded.type).toLowerCase(); + const text = new TextDecoder('utf-8', { fatal: false }).decode(decoded.bytes); + if (kind === 'pgp' && (type.includes('pgp') || text.includes('BEGIN PGP PUBLIC KEY BLOCK'))) { + if (text.includes('BEGIN PGP PUBLIC KEY BLOCK')) out.push(text); + } else if (kind === 'smime' && (type.includes('pkix') || type.includes('x509') || type.includes('x-x509') || text.includes('BEGIN CERTIFICATE'))) { + out.push(text.includes('BEGIN CERTIFICATE') ? base64Decode(text.replace(/-----[^-]+-----/g, '')) : decoded.bytes); + } + } + } + return out; +} + +function decodeDataUri(uri: string | undefined): { type: string; bytes: Uint8Array } | null { + const m = /^data:([^;,]*)((?:;[^,]*)*?),(.*)$/s.exec(uri ?? ''); + if (!m) return null; + const isBase64 = /;base64/i.test(m[2]); + try { + const bytes = isBase64 ? base64Decode(decodeURIComponent(m[3])) : new TextEncoder().encode(decodeURIComponent(m[3])); + return { type: m[1] || 'application/octet-stream', bytes }; + } catch { + return null; + } +} + +export interface RecipientKeys { + /** Armored PGP keys or DER certificates, one or more per found recipient. */ + found: Array; + missing: string[]; +} + +/** Public keys for every recipient: the stored keys first, then contact cards. */ +export function resolveRecipientKeys(kind: SecureMode, emails: string[], contacts: ContactCard[] = []): RecipientKeys { + const keys = useCryptoKeysStore.getState().keys; + const found: Array = []; + const missing: string[] = []; + for (const email of [...new Set(emails.map((e) => e.trim().toLowerCase()).filter(Boolean))]) { + const stored = keysForEmail(keys, email).filter((k): k is CryptoKeyRecord => k.kind === kind && isRecordCurrent(k)); + const usable = kind === 'smime' ? stored.filter((k) => (k as SmimeCertRecord).canEncrypt) : stored; + if (usable.length) { + for (const k of usable) found.push(k.kind === 'pgp' ? (k as PgpKeyRecord).publicKeyArmored : base64Decode((k as SmimeCertRecord).certificate)); + continue; + } + const fromContacts = keysFromContacts(kind, email, contacts); + if (fromContacts.length) found.push(...fromContacts); + else missing.push(email); + } + return { found, missing }; +} + +export interface SecureReadiness { + /** Your own key for the From address (needed to sign, and to keep Sent readable). */ + ownKey?: CryptoKeyRecord; + /** Recipients without a key (encrypt only). */ + missingRecipients: string[]; +} + +export function secureReadiness(choice: SecureChoice, fromEmail: string, recipients: string[], contacts: ContactCard[] = []): SecureReadiness { + const ownKey = ownKeyFor(choice.mode, fromEmail); + const missingRecipients = choice.encrypt ? resolveRecipientKeys(choice.mode, recipients, contacts).missing : []; + return { ownKey, missingRecipients }; +} + +/** Header lines the composer's options add (priority, read receipt, Reply-To). */ +function extraHeaders(outgoing: OutgoingEmail): string[] { + const lines: string[] = []; + if (outgoing.replyTo?.length) { + lines.push(formatHeader('Reply-To', outgoing.replyTo.map((a) => (a.name ? `${encodeHeaderWords(a.name)} <${a.email}>` : a.email)).join(', '))); + } + for (const [prop, value] of Object.entries(messagePriorityHeaders(outgoing.priority))) { + const name = /^header:([^:]+):/.exec(prop)?.[1]; + if (name) lines.push(formatHeader(name, value)); + } + if (outgoing.requestReadReceipt && outgoing.from[0]?.email) lines.push(formatHeader('Disposition-Notification-To', outgoing.from[0].email)); + return lines; +} + +export class SecureSendError extends Error { + code: 'no-own-key' | 'locked' | 'missing-recipients'; + detail?: string[]; + constructor(code: SecureSendError['code'], message: string, detail?: string[]) { + super(message); + this.name = 'SecureSendError'; + this.code = code; + this.detail = detail; + } +} + +/** + * Build the signed/encrypted message for `outgoing` and upload it; returns + * the blob id for sendEmail's `rawBlobId`. The own key must be unlocked + * first when signing (the composer asks for the passphrase). + */ +export async function prepareSecureSend( + outgoing: OutgoingEmail, + choice: SecureChoice, + deps: { + contacts?: ContactCard[]; + fetchBlob: (blobId: string, name: string, type: string) => Promise; + upload: (bytes: Uint8Array) => Promise; + }, +): Promise { + const fromEmail = outgoing.from[0]?.email ?? ''; + const own = ownKeyFor(choice.mode, fromEmail); + if (!own) throw new SecureSendError('no-own-key', `No ${choice.mode === 'pgp' ? 'PGP key' : 'S/MIME certificate'} of yours for ${fromEmail}`); + const recipients = [...outgoing.to, ...(outgoing.cc ?? []), ...(outgoing.bcc ?? [])].map((r) => r.email); + + const attachments: MimeAttachment[] = []; + for (const a of outgoing.attachments ?? []) { + attachments.push({ + filename: a.name || 'attachment', + contentType: a.type || 'application/octet-stream', + content: await deps.fetchBlob(a.blobId, a.name || 'attachment', a.type || 'application/octet-stream'), + cid: a.disposition === 'inline' ? a.cid : undefined, + }); + } + const input: MimeMessageInput = { + from: outgoing.from[0], + to: outgoing.to, + cc: outgoing.cc, + subject: outgoing.subject, + textBody: outgoing.textBody, + htmlBody: outgoing.htmlBody, + attachments, + inReplyTo: outgoing.inReplyTo, + references: outgoing.references, + messageId: outgoing.messageId, + extraHeaders: extraHeaders(outgoing), + }; + + let raw: Uint8Array; + if (choice.mode === 'pgp') { + const record = own as PgpKeyRecord; + const signingKey = choice.sign ? unlockedPgpKey(record.id) : null; + if (choice.sign && !signingKey) throw new SecureSendError('locked', 'Unlock your PGP key to sign'); + let encryptionKeys: string[] | undefined; + if (choice.encrypt) { + const r = resolveRecipientKeys('pgp', recipients, deps.contacts); + if (r.missing.length) throw new SecureSendError('missing-recipients', `No public key for ${r.missing.join(', ')}`, r.missing); + // Encrypted to yourself too, so Sent stays readable. + encryptionKeys = [...(r.found as string[]), record.publicKeyArmored]; + } + raw = await buildSecureMessage(input, choice, { pgp: { signingKey, encryptionKeys } }); + } else { + const record = own as SmimeCertRecord; + const unlocked = choice.sign ? unlockedSmimeKey(record.id) : undefined; + if (choice.sign && !unlocked) throw new SecureSendError('locked', 'Unlock your S/MIME key to sign'); + let recipientCerts: Uint8Array[] | undefined; + if (choice.encrypt) { + const r = resolveRecipientKeys('smime', recipients, deps.contacts); + if (r.missing.length) throw new SecureSendError('missing-recipients', `No certificate for ${r.missing.join(', ')}`, r.missing); + recipientCerts = r.found as Uint8Array[]; + } + raw = await buildSecureMessage(input, choice, { + smime: { + signer: unlocked ? { pkcs8: unlocked.pkcs8, certificate: base64Decode(record.certificate), chain: record.chain.map(base64Decode), algorithm: record.algorithm } : undefined, + recipientCerts, + senderCert: base64Decode(record.certificate), + }, + }); + } + return deps.upload(raw); +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 868396da..275d4c4a 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -77,6 +77,13 @@ import { attachmentFromFileNode } from '../lib/compose-files'; import { supportsFiles } from '../api/files'; import { FilesPickerSheet } from '../components/compose/FilesPickerSheet'; import { ComposerOptionsSheet, activeComposerOptionCount, type ComposerOptions } from '../components/compose/ComposerOptionsSheet'; +import { UnlockKeyDialog } from '../components/crypto/UnlockKeyDialog'; +import { ownKeyFor, isUnlocked, needsPassphrase, unlock } from '../lib/crypto/keyring'; +import { prepareSecureSend, resolveRecipientKeys, SecureSendError, type SecureChoice } from '../lib/crypto/compose-secure'; +import type { SecureMode } from '../lib/crypto/secure-send'; +import { useCryptoKeysStore, type CryptoKeyRecord } from '../stores/crypto-keys-store'; +import { usePolicyFeature } from '../stores/policy-store'; +import { fetchBlobBytes } from '../lib/email-export'; import type { MessagePriority } from '../lib/message-priority'; import { supportsSubmissionExtension } from '../lib/submission-options'; import { @@ -630,9 +637,15 @@ export default function ComposeScreen({ route, navigation }: Props) { const initialPriority: MessagePriority = draft?.priority ?? 'normal'; const [priority, setPriority] = React.useState(initialPriority); const [optionsSheetOpen, setOptionsSheetOpen] = React.useState(false); + // Sign / encrypt with OpenPGP or S/MIME (#58), per message. + const [secureMode, setSecureMode] = React.useState('pgp'); + const [secureSign, setSecureSign] = React.useState(false); + const [secureEncrypt, setSecureEncrypt] = React.useState(false); + const [unlockRecord, setUnlockRecord] = React.useState(null); + const unlockResolveRef = React.useRef<((ok: boolean) => void) | null>(null); const composerOptions = React.useMemo( - () => ({ requestReadReceipt, requestDsn, requireTls, priority }), - [requestReadReceipt, requestDsn, requireTls, priority], + () => ({ requestReadReceipt, requestDsn, requireTls, priority, secureMode, sign: secureSign, encrypt: secureEncrypt }), + [requestReadReceipt, requestDsn, requireTls, priority, secureMode, secureSign, secureEncrypt], ); // Read when the sheet opens: the session may have arrived since the // composer did. @@ -643,6 +656,9 @@ export default function ComposeScreen({ route, navigation }: Props) { if (next.requestDsn !== undefined) setRequestDsn(next.requestDsn); if (next.requireTls !== undefined) setRequireTls(next.requireTls); if (next.priority !== undefined) setPriority(next.priority); + if (next.secureMode !== undefined) setSecureMode(next.secureMode); + if (next.sign !== undefined) setSecureSign(next.sign); + if (next.encrypt !== undefined) setSecureEncrypt(next.encrypt); }, []); const [subAddressTag, setSubAddressTag] = React.useState(''); const [fromOverride, setFromOverride] = React.useState<{ name: string; email: string } | null>(null); @@ -1445,6 +1461,78 @@ export default function ComposeScreen({ route, navigation }: Props) { }; }, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, priority, t]); + // Which of OpenPGP / S/MIME the From address has a key for, and who of the + // recipients has none (read while the options sheet is open). + const cryptoKeys = useCryptoKeysStore((s) => s.keys); + const smimeAllowed = usePolicyFeature('smimeEnabled'); + const secureFromEmail = primaryIdentity ? senderAddress(primaryIdentity).from.email : ''; + const secureAvailable = React.useMemo( + () => ({ pgp: !!ownKeyFor('pgp', secureFromEmail), smime: smimeAllowed && !!ownKeyFor('smime', secureFromEmail) }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [cryptoKeys, secureFromEmail, smimeAllowed], + ); + React.useEffect(() => { + if (!secureAvailable[secureMode] && secureAvailable[secureMode === 'pgp' ? 'smime' : 'pgp']) { + setSecureMode(secureMode === 'pgp' ? 'smime' : 'pgp'); + } + }, [secureAvailable, secureMode]); + const secureHint = React.useMemo(() => { + if (!optionsSheetOpen || !secureEncrypt) return null; + const emails = [...expandRecipients(finalTo), ...expandRecipients(finalCc), ...expandRecipients(finalBcc)].map((r) => toAddress(r).email); + const { missing } = resolveRecipientKeys(secureMode, emails, useContactsStore.getState().contacts); + return missing.length + ? t('email_composer.secure_missing_keys', 'No key for {recipients}. Import one under Settings → Encryption.', { recipients: missing.join(', ') }) + : null; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [optionsSheetOpen, secureEncrypt, secureMode, finalTo, finalCc, finalBcc, cryptoKeys, t]); + + // Unlock the own key for a signed send; resolves false when cancelled. + const askUnlock = (record: CryptoKeyRecord): Promise => new Promise((resolve) => { + unlockResolveRef.current = resolve; + setUnlockRecord(record); + }); + const finishUnlock = (ok: boolean) => { + setUnlockRecord(null); + unlockResolveRef.current?.(ok); + unlockResolveRef.current = null; + }; + + /** + * Build, sign/encrypt and upload the raw message for a secure send + * (#58); the blob id for sendEmail, or null when the user was told why + * it can't go out. + */ + const prepareSecureBlob = async (choice: SecureChoice, outgoing: OutgoingEmail, accountId: string | undefined): Promise => { + const title = t('email_composer.secure_failed_title', 'Cannot sign or encrypt'); + const own = ownKeyFor(choice.mode, outgoing.from[0]?.email ?? ''); + if (!own) { + Alert.alert(title, t('email_composer.secure_no_key', 'There is no key for {email} on this device. Import one under Settings → Encryption.', { email: outgoing.from[0]?.email ?? '' })); + return null; + } + if (choice.sign && !isUnlocked(own)) { + if (await needsPassphrase(own)) { + if (!(await askUnlock(own))) return null; + } else { + await unlock(own, ''); + } + } + try { + return await prepareSecureSend(outgoing, choice, { + contacts: useContactsStore.getState().contacts, + fetchBlob: (blobId, name, type) => fetchBlobBytes(blobId, name, type), + upload: async (bytes) => (await uploadBytesForLogin(ownerLoginRef.current, bytes, 'message/rfc822', accountId)).blobId, + }); + } catch (e) { + if (e instanceof SecureSendError) { + Alert.alert(title, e.code === 'missing-recipients' + ? t('email_composer.secure_missing_keys', 'No key for {recipients}. Import one under Settings → Encryption.', { recipients: (e.detail ?? []).join(', ') }) + : e.message); + return null; + } + throw e; + } + }; + // Save one draft version (create, then destroy the previous one - #849). const saveDraftOnce = async (opts: { live: boolean }): Promise => { const identity = primaryIdentity; @@ -2559,8 +2647,23 @@ export default function ComposeScreen({ route, navigation }: Props) { setSending(true); try { const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false }); - queuedOutgoing = outgoing; - if (!useNetworkStore.getState().online && queueForLater(outgoing, false)) return; + const secureChoice: SecureChoice | null = secureSign || secureEncrypt + ? { mode: secureMode, sign: secureSign, encrypt: secureEncrypt } + : null; + if (secureChoice) { + // The Outbox would send it as plain mail later: a signed or + // encrypted message only goes out now. + if (!useNetworkStore.getState().online) { + Alert.alert( + t('email_composer.secure_failed_title', 'Cannot sign or encrypt'), + t('email_composer.secure_offline', 'Signed or encrypted messages can\'t wait in the Outbox. Send it when you are online.'), + ); + return; + } + } else { + queuedOutgoing = outgoing; + if (!useNetworkStore.getState().online && queueForLater(outgoing, false)) return; + } // The account the submission is held in, for Undo (read before the // request: a switch may land while it is out). // A group or shared identity sends from its own account, and its Sent @@ -2568,6 +2671,13 @@ export default function ComposeScreen({ route, navigation }: Props) { const target = await sendTargetFor(primaryIdentity, outgoing.from[0]?.email ?? primaryIdentity.email, mailboxes); if (refuseSendAfterSwitch()) return; const submissionAccountId = target.accountId ?? jmapClient.connectedAccountId ?? undefined; + let rawBlobId: string | undefined; + if (secureChoice) { + const blobId = await prepareSecureBlob(secureChoice, outgoing, target.accountId); + if (!blobId) return; + if (refuseSendAfterSwitch()) return; + rawBlobId = blobId; + } const result = await sendEmail( outgoing, target.identityId, @@ -2582,6 +2692,7 @@ export default function ComposeScreen({ route, navigation }: Props) { // hides the toggles the account's submission extensions lack). requestDsn: requestDsn && supportsSubmissionExtension('DSN', target.accountId), requireTls: requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId), + rawBlobId, }, ); draftIdRef.current = null; @@ -3600,6 +3711,15 @@ export default function ComposeScreen({ route, navigation }: Props) { requireTlsAvailable={requireTlsAvailable} onChange={onComposerOptionsChange} onClose={() => setOptionsSheetOpen(false)} + secureAvailable={secureAvailable} + secureHint={secureHint} + /> + + finishUnlock(true)} + onCancel={() => finishUnlock(false)} />