From ddae1e63f26ea5f2808219341f2aa48cccf28496 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:11:13 +0200 Subject: [PATCH] feat: manage public keys and encryption at rest from account security --- locales/rn/en.json | 7 +- .../settings/AccountSecuritySettings.tsx | 330 +++++++++++++++++- 2 files changed, 318 insertions(+), 19 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index 065ceb7d..773c58d5 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -63,7 +63,12 @@ "no_archive_folder": "Could not find an Archive folder on the server.", "no_junk_folder": "Could not find a Spam/Junk folder on the server.", "no_trash_folder": "Could not find a Trash folder on the server. Please check your mailbox configuration.", - "undo_failed": "Undo failed" + "undo_failed": "Undo failed", + "marked_as_spam_count": "{count, plural, one {# email} other {# emails}} marked as spam", + "marked_not_spam_count": "{count, plural, one {# email} other {# emails}} marked as not spam", + "emails_archived_count": "{count, plural, one {# email} other {# emails}} archived", + "emails_moved_to": "{count, plural, one {# email} other {# emails}} moved to {mailbox}", + "moved_to_trash_count": "{count, plural, one {# email} other {# emails}} moved to Trash" }, "email_viewer": { "color_tag": { diff --git a/src/components/settings/AccountSecuritySettings.tsx b/src/components/settings/AccountSecuritySettings.tsx index d3974052..2ceebecf 100644 --- a/src/components/settings/AccountSecuritySettings.tsx +++ b/src/components/settings/AccountSecuritySettings.tsx @@ -17,11 +17,16 @@ import { isClientCertSupported, pickClientCertAlias, } from '../../lib/client-cert'; +import { useLocaleStore } from '../../stores/locale-store'; import { isStalwartSupported, fetchAuthInfo, - fetchEncryptionType, + fetchCryptoInfo, fetchPrincipal, + fetchPublicKeys, + createPublicKey, + removePublicKey, + updateEncryptionAtRest, changePassword, updateDisplayName, enableTotp, @@ -33,7 +38,9 @@ import { type AppCredentialInfo, type AppCredentialInput, type AuthInfo, + type CryptoInfo, type EncryptionType, + type PublicKeyInfo, } from '../../api/account-security'; import { generateTotpEnrolment, type TotpEnrolment } from '../../lib/totp'; @@ -535,22 +542,286 @@ function EmailClientSection() { ); } -// ── Encryption at rest (read-only status) ───────────────── -function EncryptionSection({ type }: { type: EncryptionType }) { +// ── Public keys (S/MIME / PGP) ──────────────────────────── +function PublicKeysSection({ + keys, + onChanged, +}: { + keys: PublicKeyInfo[]; + onChanged: () => Promise; +}) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const [showAdd, setShowAdd] = useState(false); + const [name, setName] = useState(''); + const [keyText, setKeyText] = useState(''); + const [saving, setSaving] = useState(false); + const [removingId, setRemovingId] = useState(null); + + const add = async () => { + if (!name.trim() || !keyText.trim()) return; + setSaving(true); + try { + await createPublicKey({ description: name.trim(), key: keyText.trim() }); + setName(''); setKeyText(''); setShowAdd(false); + await onChanged(); + } catch (err) { + Alert.alert( + t('settings.security.public_keys.add_error', 'Failed to add public key'), + err instanceof Error ? err.message : undefined, + ); + } finally { + setSaving(false); + } + }; + + const remove = (key: PublicKeyInfo) => { + Alert.alert( + t('settings.security.public_keys.remove_title', 'Remove public key?'), + t('settings.security.public_keys.remove_message', 'Encryption at rest that uses this key will stop working.'), + [ + { text: t('common.cancel', 'Cancel'), style: 'cancel' }, + { + text: t('common.remove', 'Remove'), + style: 'destructive', + onPress: async () => { + setRemovingId(key.id); + try { + await removePublicKey(key.id); + await onChanged(); + } catch (err) { + Alert.alert( + t('settings.security.public_keys.remove_error', 'Failed to remove public key'), + err instanceof Error ? err.message : undefined, + ); + } finally { + setRemovingId(null); + } + }, + }, + ], + ); + }; + + return ( + + + + + {t('settings.security.public_keys.title', 'Public Keys')} + + + + + {t('settings.security.public_keys.description', 'S/MIME certificates and PGP keys stored on the server. They are used for encryption at rest.')} + + + {showAdd && ( + + + {t('settings.security.public_keys.name_label', 'Public Key name')} + + + + {t('settings.security.public_keys.key_label', 'Public Key (S/MIME or PGP ASCII-armored)')} + + + + + + + + )} + + {keys.length > 0 ? ( + + {keys.map((key) => ( + + + {key.description || key.id} + + {[ + key.emailAddresses.join(', '), + key.expiresAt + ? t('settings.security.public_keys.expires', 'expires {date}', { date: new Date(key.expiresAt).toLocaleDateString() }) + : null, + ].filter(Boolean).join(' · ')} + + + remove(key)} + disabled={removingId !== null} + accessibilityRole="button" + accessibilityLabel={t('common.remove', 'Remove')} + > + {removingId === key.id + ? + : } + + + ))} + + ) : ( + {t('settings.security.public_keys.none', 'No public keys configured')} + )} + + ); +} + +// ── Encryption at rest ──────────────────────────────────── +function EncryptionSection({ + info, + keys, + onChanged, +}: { + info: CryptoInfo; + keys: PublicKeyInfo[]; + onChanged: () => Promise; +}) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const [type, setType] = useState(info.type); + const [keyId, setKeyId] = useState(info.publicKeyId ?? keys[0]?.id ?? null); + const [encryptOnAppend, setEncryptOnAppend] = useState(info.encryptOnAppend); + const [allowSpamTraining, setAllowSpamTraining] = useState(info.allowSpamTraining); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setType(info.type); + setKeyId(info.publicKeyId ?? keys[0]?.id ?? null); + setEncryptOnAppend(info.encryptOnAppend); + setAllowSpamTraining(info.allowSpamTraining); + }, [info, keys]); + const enabled = type !== 'Disabled'; + const dirty = + type !== info.type + || (enabled && keyId !== info.publicKeyId) + || (enabled && encryptOnAppend !== info.encryptOnAppend) + || (enabled && allowSpamTraining !== info.allowSpamTraining); + + const save = async () => { + setSaving(true); + try { + await updateEncryptionAtRest({ type, publicKeyId: keyId, encryptOnAppend, allowSpamTraining }); + await onChanged(); + Alert.alert( + t('settings.security.encryption.section_title', 'Encryption at Rest'), + enabled + ? t('settings.security.encryption.enabled_success', 'Encryption at rest enabled') + : t('settings.security.encryption.disabled_success', 'Encryption at rest disabled'), + ); + } catch (err) { + Alert.alert( + t('settings.security.encryption.error', 'Failed to update encryption settings'), + err instanceof Error ? err.message : undefined, + ); + } finally { + setSaving(false); + } + }; + + const algorithms: { value: EncryptionType; label: string }[] = [ + { value: 'Disabled', label: t('settings.security.encryption.inactive', 'Disabled') }, + { value: 'Aes128', label: 'AES-128' }, + { value: 'Aes256', label: 'AES-256' }, + ]; + return ( - Encryption at rest + {t('settings.security.encryption.section_title', 'Encryption at Rest')} + + + {t('settings.security.encryption.description', 'Encrypt stored emails on the server for additional privacy')} + {' '} + {info.type !== 'Disabled' + ? t('settings.security.encryption.active', '{type} encryption enabled', { type: info.type }) + : t('settings.security.encryption.inactive', 'Disabled')} + + + + {t('settings.security.encryption.algorithm_label', 'Encryption Algorithm')} + + {algorithms.map((opt) => ( + + ))} + + + + {enabled && ( + + {t('settings.security.public_keys.title', 'Public Keys')} + {keys.length === 0 ? ( + + {t('settings.security.encryption.no_keys', 'Add a public key first - encryption at rest needs one.')} + + ) : ( + + {keys.map((key) => ( + setKeyId(key.id)} + style={styles.keyChoice} + accessibilityRole="radio" + accessibilityState={{ selected: keyId === key.id }} + > + {keyId === key.id ? : } + {key.description || key.id} + + ))} + + )} + + + + + + + + )} + + + - - - {enabled ? `Active (${type})` : 'Disabled'} - - ); } @@ -565,9 +836,18 @@ export function AccountSecuritySettings() { // null = still probing; false = server lacks the Stalwart extension. const [supported, setSupported] = useState(null); const [auth, setAuth] = useState(null); + const t = useLocaleStore((s) => s.t); const [displayName, setDisplayName] = useState(''); - const [encryption, setEncryption] = useState('Disabled'); + const [crypto, setCrypto] = useState(null); + const [publicKeys, setPublicKeys] = useState([]); const [loadError, setLoadError] = useState(null); + const [offline, setOffline] = useState(false); + + const reloadCrypto = useCallback(async () => { + const [info, keys] = await Promise.allSettled([fetchCryptoInfo(), fetchPublicKeys()]); + if (info.status === 'fulfilled') setCrypto(info.value); + if (keys.status === 'fulfilled') setPublicKeys(keys.value); + }, []); const reloadAuth = useCallback(async () => { try { @@ -580,7 +860,8 @@ export function AccountSecuritySettings() { useEffect(() => { let cancelled = false; const session = jmapClient.currentSession; - if (!session) { setSupported(false); return; } + if (!session) { setOffline(true); setSupported(false); return; } + setOffline(false); if (!isStalwartSupported()) { setSupported(false); return; } setSupported(true); @@ -592,10 +873,9 @@ export function AccountSecuritySettings() { if (!isOAuth) { // Principal + crypto only matter for password accounts; failures are // non-fatal (e.g. a non-admin principal read is forbidden). - const [principal, enc] = await Promise.allSettled([fetchPrincipal(), fetchEncryptionType()]); + const [principal] = await Promise.allSettled([fetchPrincipal(), reloadCrypto()]); if (cancelled) return; if (principal.status === 'fulfilled') setDisplayName(principal.value.displayName); - if (enc.status === 'fulfilled') setEncryption(enc.value); } } catch (err) { if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load security settings.'); @@ -603,7 +883,7 @@ export function AccountSecuritySettings() { })(); return () => { cancelled = true; }; - }, [isOAuth]); + }, [isOAuth, reloadCrypto]); return ( @@ -617,15 +897,23 @@ export function AccountSecuritySettings() { )} {supported === false && ( - + - Account security management requires a Stalwart server. These features are not available for this account. + {offline + ? t('settings.security.offline', 'You are offline. Account security settings need a live connection to the server.') + : t('settings.security.not_supported', 'Account security management requires a Stalwart server. These features are not available for this account.')} )} {supported && ( - + {loadError ? {loadError} : null} @@ -660,7 +948,12 @@ export function AccountSecuritySettings() { /> {isOAuth && } - {!isOAuth && } + {!isOAuth && ( + <> + + {crypto && } + + )} )} @@ -723,6 +1016,7 @@ function makeStyles(c: ThemePalette) { ipPill: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.xs, backgroundColor: c.background, borderWidth: 1, borderColor: c.border }, ipText: { fontSize: 10, fontFamily: 'monospace', color: c.mutedForeground }, iconBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm }, + keyChoice: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: 6 }, emptyText: { ...typography.caption, color: c.mutedForeground, fontStyle: 'italic' }, // TLS client cert