feat: sign and encrypt mail with pgp or s/mime from the composer options #58

This commit is contained in:
Linus Rath
2026-10-31 17:10:01 +01:00
parent 8aa1d8e03c
commit b0997c0a13
7 changed files with 629 additions and 14 deletions
+72
View File
@@ -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<typeof vi.fn>;
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, any>, 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/);
});
});
+16 -5
View File
@@ -1754,6 +1754,13 @@ export interface SendEmailOptions {
requestDsn?: boolean; requestDsn?: boolean;
/** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */ /** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */
requireTls?: boolean; 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( export async function sendEmail(
@@ -1767,7 +1774,8 @@ export async function sendEmail(
opts?: SendEmailOptions, opts?: SendEmailOptions,
): Promise<SendEmailResult> { ): Promise<SendEmailResult> {
const accountId = opts?.accountId ?? jmapClient.accountId; const accountId = opts?.accountId ?? jmapClient.accountId;
const emailCreate = buildEmailCreate(email); const raw = !!opts?.rawBlobId;
const emailCreate: Record<string, unknown> = raw ? { blobId: opts!.rawBlobId } : buildEmailCreate(email);
const viaDrafts = !!opts?.draftsMailboxId; const viaDrafts = !!opts?.draftsMailboxId;
if (viaDrafts) { if (viaDrafts) {
emailCreate.mailboxIds = { [opts!.draftsMailboxId!]: true }; emailCreate.mailboxIds = { [opts!.draftsMailboxId!]: true };
@@ -1776,6 +1784,9 @@ export async function sendEmail(
emailCreate.mailboxIds = { [sentMailboxId]: true }; emailCreate.mailboxIds = { [sentMailboxId]: true };
emailCreate.keywords = { $seen: true }; emailCreate.keywords = { $seen: true };
} }
const createCall: [string, Record<string, unknown>, string] = raw
? ['Email/import', { accountId, emails: { draft: emailCreate } }, '0']
: ['Email/set', { accountId, create: { draft: emailCreate } }, '0'];
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId }; const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
// For a deferred send the envelope must be set explicitly so the HOLDFOR // 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 hasMailFromParams = Object.keys(parameters.mailFrom).length > 0;
const hasRcptToParams = Object.keys(parameters.rcptTo).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 ?? [])] const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
.map((r) => r.email.trim()) .map((r) => r.email.trim())
.filter(Boolean) .filter(Boolean)
@@ -1818,7 +1829,7 @@ export async function sendEmail(
let res = await jmapClient.request( let res = await jmapClient.request(
[ [
['Email/set', { accountId, create: { draft: emailCreate } }, '0'], createCall,
['EmailSubmission/set', submissionArgs, '1'], ['EmailSubmission/set', submissionArgs, '1'],
// Which recipients the server took. A refused one is lost without a // Which recipients the server took. A refused one is lost without a
// bounce while the create succeeds and files the message in Sent. // 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` // Stalwart 1.0 refuses a submission once the account holds `maxSubmissions`
// records (overQuota). The message exists by then, so make room and submit // records (overQuota). The message exists by then, so make room and submit
// that copy once more instead of failing the send. // 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<string, { id?: string }> } | undefined)?.created?.draft?.id; const copyId = (copyCreated?.[1] as { created?: Record<string, { id?: string }> } | undefined)?.created?.draft?.id;
if (copyCreated && copyId && submissionCreateError(res, 'sub-1')?.type === 'overQuota') { if (copyCreated && copyId && submissionCreateError(res, 'sub-1')?.type === 'overQuota') {
await makeRoomForSubmission(accountId); await makeRoomForSubmission(accountId);
@@ -1871,7 +1882,7 @@ export async function sendEmail(
failure = new Error((result as { description?: string }).description ?? 'Send failed'); failure = new Error((result as { description?: string }).description ?? 'Send failed');
break; break;
} }
if (methodName === 'Email/set') { if (methodName === 'Email/set' || methodName === 'Email/import') {
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string; properties?: string[] }> }).notCreated?.draft; const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string; properties?: string[] }> }).notCreated?.draft;
if (notCreated) { if (notCreated) {
const props = notCreated.properties?.length ? ` (properties: ${notCreated.properties.join(', ')})` : ''; const props = notCreated.properties?.length ? ` (properties: ${notCreated.properties.join(', ')})` : '';
@@ -8,12 +8,23 @@ import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { MESSAGE_PRIORITIES, type MessagePriority } from '../../lib/message-priority'; import { MESSAGE_PRIORITIES, type MessagePriority } from '../../lib/message-priority';
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset'; import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
import type { SecureMode } from '../../lib/crypto/secure-send';
export interface ComposerOptions { export interface ComposerOptions {
requestReadReceipt: boolean; requestReadReceipt: boolean;
requestDsn: boolean; requestDsn: boolean;
requireTls: boolean; requireTls: boolean;
priority: MessagePriority; 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`). * toolbar button (webmail composer options menu `activeCount`).
*/ */
export function activeComposerOptionCount(options: ComposerOptions): number { 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; .filter(Boolean).length;
} }
/** /**
* The composer's per-message options (webmail email-composer.tsx options * The composer's per-message options (webmail email-composer.tsx options
* menu): read receipt, delivery status notification and REQUIRETLS - the * menu): read receipt, delivery status notification and REQUIRETLS - the
* last two only when the account's submission extensions list them - and * last two only when the account's submission extensions list them - the
* the message priority. * 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({ export function ComposerOptionsSheet({
visible, options, dsnAvailable, requireTlsAvailable, onChange, onClose, visible, options, dsnAvailable, requireTlsAvailable, secureAvailable, secureHint, onChange, onClose,
}: { }: {
visible: boolean; visible: boolean;
options: ComposerOptions; options: ComposerOptions;
dsnAvailable: boolean; dsnAvailable: boolean;
requireTlsAvailable: 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<ComposerOptions>) => void; onChange: (next: Partial<ComposerOptions>) => void;
onClose: () => void; onClose: () => void;
}) { }) {
@@ -46,7 +62,7 @@ export function ComposerOptionsSheet({
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
const bottomInset = useSheetBottomInset(visible); 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') => (
<Pressable <Pressable
style={styles.row} style={styles.row}
onPress={() => onChange({ [key]: !value })} onPress={() => onChange({ [key]: !value })}
@@ -92,6 +108,47 @@ export function ComposerOptionsSheet({
options.requireTls, options.requireTls,
'requireTls', 'requireTls',
)} )}
{secureAvailable && (secureAvailable.pgp || secureAvailable.smime) && (
<>
<View style={styles.separator} />
<Text style={styles.groupLabel}>{t('email_composer.option_security', 'Signature and encryption')}</Text>
{secureAvailable.pgp && secureAvailable.smime && (
<View style={styles.segment} accessibilityRole="radiogroup">
{(['pgp', 'smime'] as const).map((mode) => {
const selected = options.secureMode === mode;
const label = mode === 'pgp' ? 'OpenPGP' : 'S/MIME';
return (
<Pressable
key={mode}
style={[styles.segmentItem, selected && styles.segmentItemSelected]}
onPress={() => onChange({ secureMode: mode })}
accessibilityRole="radio"
accessibilityLabel={label}
accessibilityState={{ checked: selected, selected }}
>
<Text style={[styles.rowLabel, selected && styles.segmentTextSelected]}>{label}</Text>
</Pressable>
);
})}
</View>
)}
{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) && <Text style={styles.warning}>{secureHint}</Text>}
</>
)}
<View style={styles.separator} /> <View style={styles.separator} />
<Text style={styles.groupLabel}>{t('email_composer.option_priority', 'Priority')}</Text> <Text style={styles.groupLabel}>{t('email_composer.option_priority', 'Priority')}</Text>
<View accessibilityRole="radiogroup" accessibilityLabel={t('email_composer.option_priority', 'Priority')}> <View accessibilityRole="radiogroup" accessibilityLabel={t('email_composer.option_priority', 'Priority')}>
@@ -159,6 +216,13 @@ function makeStyles(c: ThemePalette) {
rowHint: { ...typography.caption, color: c.textMuted }, rowHint: { ...typography.caption, color: c.textMuted },
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.borderLight, marginVertical: spacing.xs }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.borderLight, marginVertical: spacing.xs },
groupLabel: { ...typography.captionMedium, color: c.textSecondary }, 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 }, done: { alignItems: 'center', paddingVertical: spacing.sm, marginTop: spacing.xs },
doneText: { ...typography.bodyMedium, color: c.primary }, doneText: { ...typography.bodyMedium, color: c.primary },
}); });
@@ -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(/<ComposerOptionsSheet[\s\S]{0,400}secureAvailable=\{secureAvailable\}/);
});
it('sends the signed / encrypted blob through sendEmail', () => {
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(/<SecureMessageBanner status=/);
});
});
@@ -0,0 +1,115 @@
// Signing / encrypting from the composer (#58): recipient keys come from the
// stored keys or a contact card, a missing key stops the send, and the
// uploaded message carries the attachments and the composer's options.
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { prepareSecureSend, resolveRecipientKeys, SecureSendError } from '../compose-secure';
import { importPgpKey, lockAll } from '../keyring';
import { openSecureMessage } from '../secure-message';
import { useCryptoKeysStore } from '../../../stores/crypto-keys-store';
import type { ContactCard } from '../../../api/types';
import type { OutgoingEmail } from '../../../api/email';
import { makePgpKey, useNodeCrypto } 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); }),
}));
let me: Awaited<ReturnType<typeof makePgpKey>>;
let friend: Awaited<ReturnType<typeof makePgpKey>>;
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: '<p>see attached</p>',
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" <me@example.org>$/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);
});
+198
View File
@@ -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<string | Uint8Array> {
const e = email.toLowerCase();
const out: Array<string | Uint8Array> = [];
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<string | Uint8Array>;
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<string | Uint8Array> = [];
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<Uint8Array>;
upload: (bytes: Uint8Array) => Promise<string>;
},
): Promise<string> {
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);
}
+124 -4
View File
@@ -77,6 +77,13 @@ import { attachmentFromFileNode } from '../lib/compose-files';
import { supportsFiles } from '../api/files'; import { supportsFiles } from '../api/files';
import { FilesPickerSheet } from '../components/compose/FilesPickerSheet'; import { FilesPickerSheet } from '../components/compose/FilesPickerSheet';
import { ComposerOptionsSheet, activeComposerOptionCount, type ComposerOptions } from '../components/compose/ComposerOptionsSheet'; 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 type { MessagePriority } from '../lib/message-priority';
import { supportsSubmissionExtension } from '../lib/submission-options'; import { supportsSubmissionExtension } from '../lib/submission-options';
import { import {
@@ -630,9 +637,15 @@ export default function ComposeScreen({ route, navigation }: Props) {
const initialPriority: MessagePriority = draft?.priority ?? 'normal'; const initialPriority: MessagePriority = draft?.priority ?? 'normal';
const [priority, setPriority] = React.useState<MessagePriority>(initialPriority); const [priority, setPriority] = React.useState<MessagePriority>(initialPriority);
const [optionsSheetOpen, setOptionsSheetOpen] = React.useState(false); const [optionsSheetOpen, setOptionsSheetOpen] = React.useState(false);
// Sign / encrypt with OpenPGP or S/MIME (#58), per message.
const [secureMode, setSecureMode] = React.useState<SecureMode>('pgp');
const [secureSign, setSecureSign] = React.useState(false);
const [secureEncrypt, setSecureEncrypt] = React.useState(false);
const [unlockRecord, setUnlockRecord] = React.useState<CryptoKeyRecord | null>(null);
const unlockResolveRef = React.useRef<((ok: boolean) => void) | null>(null);
const composerOptions = React.useMemo<ComposerOptions>( const composerOptions = React.useMemo<ComposerOptions>(
() => ({ requestReadReceipt, requestDsn, requireTls, priority }), () => ({ requestReadReceipt, requestDsn, requireTls, priority, secureMode, sign: secureSign, encrypt: secureEncrypt }),
[requestReadReceipt, requestDsn, requireTls, priority], [requestReadReceipt, requestDsn, requireTls, priority, secureMode, secureSign, secureEncrypt],
); );
// Read when the sheet opens: the session may have arrived since the // Read when the sheet opens: the session may have arrived since the
// composer did. // composer did.
@@ -643,6 +656,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
if (next.requestDsn !== undefined) setRequestDsn(next.requestDsn); if (next.requestDsn !== undefined) setRequestDsn(next.requestDsn);
if (next.requireTls !== undefined) setRequireTls(next.requireTls); if (next.requireTls !== undefined) setRequireTls(next.requireTls);
if (next.priority !== undefined) setPriority(next.priority); 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 [subAddressTag, setSubAddressTag] = React.useState('');
const [fromOverride, setFromOverride] = React.useState<{ name: string; email: string } | null>(null); 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]); }, [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<boolean> => 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<string | null> => {
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). // Save one draft version (create, then destroy the previous one - #849).
const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => { const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => {
const identity = primaryIdentity; const identity = primaryIdentity;
@@ -2559,8 +2647,23 @@ export default function ComposeScreen({ route, navigation }: Props) {
setSending(true); setSending(true);
try { try {
const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false }); const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false });
queuedOutgoing = outgoing; const secureChoice: SecureChoice | null = secureSign || secureEncrypt
if (!useNetworkStore.getState().online && queueForLater(outgoing, false)) return; ? { 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 // The account the submission is held in, for Undo (read before the
// request: a switch may land while it is out). // request: a switch may land while it is out).
// A group or shared identity sends from its own account, and its Sent // 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); const target = await sendTargetFor(primaryIdentity, outgoing.from[0]?.email ?? primaryIdentity.email, mailboxes);
if (refuseSendAfterSwitch()) return; if (refuseSendAfterSwitch()) return;
const submissionAccountId = target.accountId ?? jmapClient.connectedAccountId ?? undefined; 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( const result = await sendEmail(
outgoing, outgoing,
target.identityId, target.identityId,
@@ -2582,6 +2692,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
// hides the toggles the account's submission extensions lack). // hides the toggles the account's submission extensions lack).
requestDsn: requestDsn && supportsSubmissionExtension('DSN', target.accountId), requestDsn: requestDsn && supportsSubmissionExtension('DSN', target.accountId),
requireTls: requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId), requireTls: requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId),
rawBlobId,
}, },
); );
draftIdRef.current = null; draftIdRef.current = null;
@@ -3600,6 +3711,15 @@ export default function ComposeScreen({ route, navigation }: Props) {
requireTlsAvailable={requireTlsAvailable} requireTlsAvailable={requireTlsAvailable}
onChange={onComposerOptionsChange} onChange={onComposerOptionsChange}
onClose={() => setOptionsSheetOpen(false)} onClose={() => setOptionsSheetOpen(false)}
secureAvailable={secureAvailable}
secureHint={secureHint}
/>
<UnlockKeyDialog
record={unlockRecord}
reason={t('email_composer.secure_unlock_reason', 'Your key signs this message.')}
onUnlocked={() => finishUnlock(true)}
onCancel={() => finishUnlock(false)}
/> />
<FilesPickerSheet <FilesPickerSheet