From c7c22bd21027879944a7ca30b67b5eba15a64c7b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:32:06 +0100 Subject: [PATCH] fix: account isolation, auto-import signer certs, and no-key error handling #35 --- components/email/email-viewer.tsx | 40 +++++-- components/email/smime-status-banner.tsx | 6 ++ components/settings/smime-settings.tsx | 6 +- lib/account-state-manager.ts | 2 + lib/smime/__tests__/key-storage.test.ts | 46 ++++++++ lib/smime/__tests__/smime-store.test.ts | 4 +- lib/smime/key-storage.ts | 35 ++++-- lib/smime/types.ts | 2 + locales/de/common.json | 2 +- locales/es/common.json | 2 +- locales/fr/common.json | 2 +- locales/it/common.json | 2 +- locales/ja/common.json | 2 +- locales/nl/common.json | 2 +- locales/pt/common.json | 2 +- stores/smime-store.ts | 130 +++++++++++++++++++---- test-certs/leon.p12 | Bin 0 -> 3493 bytes test-certs/root-rbm.p12 | Bin 0 -> 3493 bytes 18 files changed, 240 insertions(+), 45 deletions(-) create mode 100644 test-certs/leon.p12 create mode 100644 test-certs/root-rbm.p12 diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index de779ed4..c7befde0 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -908,7 +908,7 @@ export function EmailViewer({ // Tablet list visibility const { isTablet, isMobile } = useDeviceDetection(); const { tabletListVisible } = useUIStore(); - const { identities, client, isDemoMode } = useAuthStore(); + const { identities, client, isDemoMode, activeAccountId } = useAuthStore(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const { startTour } = useTour(); const [showFullHeaders, setShowFullHeaders] = useState(false); @@ -962,9 +962,9 @@ export function EmailViewer({ // Ensure S/MIME key records are loaded from IndexedDB useLayoutEffect(() => { - smimeStore.load(); + smimeStore.load(activeAccountId ?? undefined); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [activeAccountId]); // Build mailbox tree for move-to dropdown const moveTargetIds = useMemo(() => new Set( @@ -1544,6 +1544,18 @@ export function EmailViewer({ // Encrypted message const { keyRecords, unlockedDecryptionKeys } = smimeStore; smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) }); + + // Short-circuit: no keys imported at all + if (keyRecords.length === 0) { + smimeDebug('[S/MIME] no key records available, skipping decrypt'); + setSmimeStatus({ + isSigned: false, + isEncrypted: true, + decryptionError: 'no-key', + }); + return; + } + try { let result: Awaited> | null = null; let lastError: unknown = null; @@ -1635,8 +1647,15 @@ export function EmailViewer({ if (!existing) { try { await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email'); - } catch { /* ignore import errors */ } + smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint }); + } catch (importErr) { + smimeError('[S/MIME] auto-import signer cert failed:', importErr); + } + } else { + smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint }); } + } else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) { + smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email }); } } catch (error) { smimeError('[S/MIME] nested signature verify failed:', { @@ -1674,10 +1693,12 @@ export function EmailViewer({ decryptionError: 'locked', }); } else { + const errMsg = err instanceof Error ? err.message : 'Decryption failed'; + const isNoKeyError = errMsg.includes('No imported S/MIME key matches'); setSmimeStatus({ isSigned: false, isEncrypted: true, - decryptionError: err instanceof Error ? err.message : 'Decryption failed', + decryptionError: isNoKeyError ? 'no-key' : errMsg, }); } } @@ -1739,8 +1760,15 @@ export function EmailViewer({ if (!existing) { try { await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email'); - } catch { /* ignore import errors */ } + smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint }); + } catch (importErr) { + smimeError('[S/MIME] auto-import signer cert failed:', importErr); + } + } else { + smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint }); } + } else if (result.status.signatureValid && result.status.signerCert) { + smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email }); } } catch (err) { if (cancelled) return; diff --git a/components/email/smime-status-banner.tsx b/components/email/smime-status-banner.tsx index 010c45f4..4ce318bc 100644 --- a/components/email/smime-status-banner.tsx +++ b/components/email/smime-status-banner.tsx @@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu text: t('unlock_key_desc'), variant: 'warning', }); + } else if (status.decryptionError === 'no-key') { + items.push({ + icon: , + text: t('status_encrypted_no_key'), + variant: 'warning', + }); } else { items.push({ icon: , diff --git a/components/settings/smime-settings.tsx b/components/settings/smime-settings.tsx index 760d3c56..56bc9969 100644 --- a/components/settings/smime-settings.tsx +++ b/components/settings/smime-settings.tsx @@ -19,6 +19,7 @@ import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-di import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal"; import { useSmimeStore } from "@/stores/smime-store"; import { useIdentityStore } from "@/stores/identity-store"; +import { useAuthStore } from "@/stores/auth-store"; import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export"; import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types"; @@ -50,6 +51,7 @@ export function SmimeSettings() { } = useSmimeStore(); const { identities } = useIdentityStore(); + const activeAccountId = useAuthStore((s) => s.activeAccountId); // Local UI state const [importDialogOpen, setImportDialogOpen] = useState(false); @@ -75,8 +77,8 @@ export function SmimeSettings() { const [exportError, setExportError] = useState(null); useEffect(() => { - load(); - }, [load]); + load(activeAccountId ?? undefined); + }, [load, activeAccountId]); // ── PKCS#12 import flow ──────────────────────────────────────── diff --git a/lib/account-state-manager.ts b/lib/account-state-manager.ts index b7661023..86d89b26 100644 --- a/lib/account-state-manager.ts +++ b/lib/account-state-manager.ts @@ -11,6 +11,7 @@ import { useFilterStore } from '@/stores/filter-store'; import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; import { useIdentityStore } from '@/stores/identity-store'; import { useVacationStore } from '@/stores/vacation-store'; +import { useSmimeStore } from '@/stores/smime-store'; // Minimal snapshot shapes — we only capture what we need // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -117,6 +118,7 @@ export function clearAllStores(): void { useVacationStore.getState().clearState(); useCalendarStore.getState().clearState(); useFilterStore.getState().clearState(); + useSmimeStore.getState().clearState(); } /** Evict cached state for one account */ diff --git a/lib/smime/__tests__/key-storage.test.ts b/lib/smime/__tests__/key-storage.test.ts index 66cdfc0b..bd194330 100644 --- a/lib/smime/__tests__/key-storage.test.ts +++ b/lib/smime/__tests__/key-storage.test.ts @@ -148,4 +148,50 @@ describe('key-storage', () => { expect(certs.find(c => c.id === id)).toBeUndefined(); }); }); + + describe('accountId filtering', () => { + it('listKeyRecords filters by accountId', async () => { + const id1 = uid(); + const id2 = uid(); + await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' })); + await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' })); + + const acct1Records = await listKeyRecords('acct-1'); + expect(acct1Records.find(r => r.id === id1)).toBeDefined(); + expect(acct1Records.find(r => r.id === id2)).toBeUndefined(); + }); + + it('listKeyRecords includes records without accountId when filtering', async () => { + const id1 = uid(); + const id2 = uid(); + await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com` })); + await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' })); + + const acct1Records = await listKeyRecords('acct-1'); + expect(acct1Records.find(r => r.id === id1)).toBeDefined(); + expect(acct1Records.find(r => r.id === id2)).toBeDefined(); + }); + + it('listPublicCerts filters by accountId', async () => { + const id1 = uid(); + const id2 = uid(); + await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' })); + await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' })); + + const acct1Certs = await listPublicCerts('acct-1'); + expect(acct1Certs.find(c => c.id === id1)).toBeDefined(); + expect(acct1Certs.find(c => c.id === id2)).toBeUndefined(); + }); + + it('listPublicCerts includes certs without accountId when filtering', async () => { + const id1 = uid(); + const id2 = uid(); + await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com` })); + await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' })); + + const acct1Certs = await listPublicCerts('acct-1'); + expect(acct1Certs.find(c => c.id === id1)).toBeDefined(); + expect(acct1Certs.find(c => c.id === id2)).toBeDefined(); + }); + }); }); diff --git a/lib/smime/__tests__/smime-store.test.ts b/lib/smime/__tests__/smime-store.test.ts index 676ebb54..6765a14e 100644 --- a/lib/smime/__tests__/smime-store.test.ts +++ b/lib/smime/__tests__/smime-store.test.ts @@ -57,7 +57,9 @@ beforeEach(() => { defaultSignIdentity: {}, defaultEncrypt: false, rememberUnlockedKeys: false, - autoImportSignerCerts: false, + autoImportSignerCerts: true, + accountPreferences: {}, + currentAccountId: null, isLoading: false, error: null, }); diff --git a/lib/smime/key-storage.ts b/lib/smime/key-storage.ts index 795e0d9f..30a1a33e 100644 --- a/lib/smime/key-storage.ts +++ b/lib/smime/key-storage.ts @@ -1,22 +1,35 @@ import type { SmimeKeyRecord, SmimePublicCert } from './types'; const DB_NAME = 'smime-store'; -const DB_VERSION = 1; +const DB_VERSION = 2; const KEY_RECORDS_STORE = 'key-records'; const PUBLIC_CERTS_STORE = 'public-certs'; function openDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = () => { + request.onupgradeneeded = (event) => { const db = request.result; - if (!db.objectStoreNames.contains(KEY_RECORDS_STORE)) { + const oldVersion = event.oldVersion; + if (oldVersion < 1) { const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' }); keyStore.createIndex('email', 'email', { unique: false }); - } - if (!db.objectStoreNames.contains(PUBLIC_CERTS_STORE)) { + keyStore.createIndex('accountId', 'accountId', { unique: false }); const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' }); certStore.createIndex('email', 'email', { unique: false }); + certStore.createIndex('accountId', 'accountId', { unique: false }); + } + if (oldVersion >= 1 && oldVersion < 2) { + // Add accountId index to existing stores + const tx = request.transaction!; + const keyStore = tx.objectStore(KEY_RECORDS_STORE); + if (!keyStore.indexNames.contains('accountId')) { + keyStore.createIndex('accountId', 'accountId', { unique: false }); + } + const certStore = tx.objectStore(PUBLIC_CERTS_STORE); + if (!certStore.indexNames.contains('accountId')) { + certStore.createIndex('accountId', 'accountId', { unique: false }); + } } }; request.onsuccess = () => resolve(request.result); @@ -62,9 +75,11 @@ export async function getKeyRecordForEmail(email: string): Promise { +export async function listKeyRecords(accountId?: string): Promise { const db = await openDB(); - return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll()); + const all = await txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll()); + if (!accountId) return all; + return all.filter((r) => r.accountId === accountId || !r.accountId); } export async function deleteKeyRecord(id: string): Promise { @@ -90,9 +105,11 @@ export async function getPublicCertForEmail(email: string): Promise { +export async function listPublicCerts(accountId?: string): Promise { const db = await openDB(); - return txPromise(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll()); + const all = await txPromise(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll()); + if (!accountId) return all; + return all.filter((c) => c.accountId === accountId || !c.accountId); } export async function deletePublicCert(id: string): Promise { diff --git a/lib/smime/types.ts b/lib/smime/types.ts index 2f674f70..0282a196 100644 --- a/lib/smime/types.ts +++ b/lib/smime/types.ts @@ -1,6 +1,7 @@ /** Stored record for an imported S/MIME private key + certificate. */ export interface SmimeKeyRecord { id: string; + accountId?: string; email: string; certificate: ArrayBuffer; // DER-encoded X.509 leaf cert certificateChain: ArrayBuffer[]; // DER-encoded intermediates @@ -34,6 +35,7 @@ export interface SmimeUnlockedKey { /** A recipient or contact public certificate. */ export interface SmimePublicCert { id: string; + accountId?: string; email: string; certificate: ArrayBuffer; // DER-encoded X.509 issuer: string; diff --git a/locales/de/common.json b/locales/de/common.json index e49ac17f..ccbb6e0e 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "S/MIME-Signatur deaktiviert", "smime_encrypt_on": "S/MIME-Verschlüsselung aktiviert", "smime_encrypt_off": "S/MIME-Verschlüsselung deaktiviert", - "smime_encrypt_unavailable": "S/MIME-Verschlüsselung nicht möglich: {reason}", + "smime_encrypt_unavailable": "S/MIME-Verschlüsselung nicht möglich – fehlende Empfängerzertifikate", "smime_unlock_title": "S/MIME-Schlüssel entsperren", "smime_unlock_message": "Geben Sie die Passphrase ein, um Ihren S/MIME-Schlüssel zu entsperren.", "smime_unlock_button": "Entsperren", diff --git a/locales/es/common.json b/locales/es/common.json index 37f1057d..0955e2c0 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "Firma S/MIME desactivada", "smime_encrypt_on": "Cifrado S/MIME activado", "smime_encrypt_off": "Cifrado S/MIME desactivado", - "smime_encrypt_unavailable": "El cifrado S/MIME no está disponible: {reason}", + "smime_encrypt_unavailable": "Cifrado S/MIME no disponible – faltan certificados de destinatario", "smime_unlock_title": "Desbloquear clave S/MIME", "smime_unlock_message": "Introduce la contraseña para desbloquear tu clave S/MIME.", "smime_unlock_button": "Desbloquear", diff --git a/locales/fr/common.json b/locales/fr/common.json index 3848f571..dd35ee44 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "Signature S/MIME désactivée", "smime_encrypt_on": "Chiffrement S/MIME activé", "smime_encrypt_off": "Chiffrement S/MIME désactivé", - "smime_encrypt_unavailable": "Le chiffrement S/MIME n'est pas disponible : {reason}", + "smime_encrypt_unavailable": "Chiffrement S/MIME indisponible – certificats des destinataires manquants", "smime_unlock_title": "Déverrouiller la clé S/MIME", "smime_unlock_message": "Entrez la phrase secrète pour déverrouiller votre clé S/MIME.", "smime_unlock_button": "Déverrouiller", diff --git a/locales/it/common.json b/locales/it/common.json index 6228ee6a..5fbdd3d4 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "Firma S/MIME disattivata", "smime_encrypt_on": "Cifratura S/MIME attivata", "smime_encrypt_off": "Cifratura S/MIME disattivata", - "smime_encrypt_unavailable": "La cifratura S/MIME non è disponibile: {reason}", + "smime_encrypt_unavailable": "Cifratura S/MIME non disponibile – certificati destinatario mancanti", "smime_unlock_title": "Sblocca chiave S/MIME", "smime_unlock_message": "Inserisci la passphrase per sbloccare la tua chiave S/MIME.", "smime_unlock_button": "Sblocca", diff --git a/locales/ja/common.json b/locales/ja/common.json index 59ded38f..69eb5283 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "S/MIME 署名を無効化しました", "smime_encrypt_on": "S/MIME 暗号化を有効化しました", "smime_encrypt_off": "S/MIME 暗号化を無効化しました", - "smime_encrypt_unavailable": "S/MIME 暗号化は利用できません: {reason}", + "smime_encrypt_unavailable": "S/MIME 暗号化は利用できません – 受信者の証明書がありません", "smime_unlock_title": "S/MIME 鍵のロックを解除", "smime_unlock_message": "S/MIME 鍵を解除するためのパスフレーズを入力してください。", "smime_unlock_button": "ロック解除", diff --git a/locales/nl/common.json b/locales/nl/common.json index a158c11e..05d6585a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "S/MIME-ondertekening uitgeschakeld", "smime_encrypt_on": "S/MIME-versleuteling ingeschakeld", "smime_encrypt_off": "S/MIME-versleuteling uitgeschakeld", - "smime_encrypt_unavailable": "S/MIME-versleuteling is niet beschikbaar: {reason}", + "smime_encrypt_unavailable": "S/MIME-versleuteling niet beschikbaar – ontbrekende ontvangercertificaten", "smime_unlock_title": "S/MIME-sleutel ontgrendelen", "smime_unlock_message": "Voer de wachtwoordzin in om uw S/MIME-sleutel te ontgrendelen.", "smime_unlock_button": "Ontgrendelen", diff --git a/locales/pt/common.json b/locales/pt/common.json index a96a67f4..dc728c90 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "Assinatura S/MIME desativada", "smime_encrypt_on": "Criptografia S/MIME ativada", "smime_encrypt_off": "Criptografia S/MIME desativada", - "smime_encrypt_unavailable": "A criptografia S/MIME não está disponível: {reason}", + "smime_encrypt_unavailable": "Criptografia S/MIME indisponível – certificados de destinatário ausentes", "smime_unlock_title": "Desbloquear chave S/MIME", "smime_unlock_message": "Insira a frase secreta para desbloquear sua chave S/MIME.", "smime_unlock_button": "Desbloquear", diff --git a/stores/smime-store.ts b/stores/smime-store.ts index 5488f8a0..3301dcdc 100644 --- a/stores/smime-store.ts +++ b/stores/smime-store.ts @@ -123,14 +123,23 @@ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{ } interface SmimePersistedState { - identityKeyBindings: Record; // identityId → keyRecordId - defaultSignIdentity: Record; // identityId → sign by default - defaultEncrypt: boolean; + /** Account-scoped preferences: accountId → { identityKeyBindings, defaultSignIdentity, defaultEncrypt } */ + accountPreferences: Record; + defaultSignIdentity: Record; + defaultEncrypt: boolean; + }>; rememberUnlockedKeys: boolean; autoImportSignerCerts: boolean; } interface SmimeStore extends SmimePersistedState { + // Current account scope + currentAccountId: string | null; + // Account-scoped convenience accessors (derived from accountPreferences + currentAccountId) + identityKeyBindings: Record; + defaultSignIdentity: Record; + defaultEncrypt: boolean; // Loaded from IndexedDB keyRecords: SmimeKeyRecord[]; publicCerts: SmimePublicCert[]; @@ -141,7 +150,8 @@ interface SmimeStore extends SmimePersistedState { error: string | null; // Actions - load: () => Promise; + load: (accountId?: string) => Promise; + clearState: () => void; importPKCS12: (file: ArrayBuffer, p12Passphrase: string, storagePassphrase: string) => Promise; importPublicCert: (data: ArrayBuffer | string, source: SmimePublicCert['source'], contactId?: string) => Promise; bindIdentityToKey: (identityId: string, keyRecordId: string | null) => void; @@ -166,13 +176,15 @@ export const useSmimeStore = create()( persist( (set, get) => ({ // Persisted preferences + accountPreferences: {}, + rememberUnlockedKeys: false, + autoImportSignerCerts: true, + + // Runtime state + currentAccountId: null, identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false, - rememberUnlockedKeys: false, - autoImportSignerCerts: false, - - // Runtime state keyRecords: [], publicCerts: [], unlockedKeys: new Map(), @@ -180,12 +192,30 @@ export const useSmimeStore = create()( isLoading: false, error: null, - load: async () => { - set({ isLoading: true, error: null }); + load: async (accountId) => { + const acctId = accountId ?? get().currentAccountId; + set({ isLoading: true, error: null, currentAccountId: acctId }); + + // Restore account-scoped preferences + const prefs = acctId ? get().accountPreferences[acctId] : undefined; + if (prefs) { + set({ + identityKeyBindings: prefs.identityKeyBindings, + defaultSignIdentity: prefs.defaultSignIdentity, + defaultEncrypt: prefs.defaultEncrypt, + }); + } else { + set({ + identityKeyBindings: {}, + defaultSignIdentity: {}, + defaultEncrypt: false, + }); + } + try { const [keyRecords, publicCerts] = await Promise.all([ - listKeyRecords(), - listPublicCerts(), + listKeyRecords(acctId ?? undefined), + listPublicCerts(acctId ?? undefined), ]); if (get().rememberUnlockedKeys) { @@ -219,6 +249,8 @@ export const useSmimeStore = create()( set({ isLoading: true, error: null }); try { const { keyRecord } = await importPkcs12(file, p12Passphrase, storagePassphrase); + const acctId = get().currentAccountId; + if (acctId) keyRecord.accountId = acctId; await saveKeyRecord(keyRecord); set((state) => ({ keyRecords: [...state.keyRecords, keyRecord], @@ -245,6 +277,7 @@ export const useSmimeStore = create()( const publicCert: SmimePublicCert = { id: crypto.randomUUID(), + accountId: get().currentAccountId ?? undefined, email: email.toLowerCase(), certificate: der, issuer: info.issuer, @@ -279,7 +312,15 @@ export const useSmimeStore = create()( } else { bindings[identityId] = keyRecordId; } - return { identityKeyBindings: bindings }; + const accountPreferences = { ...state.accountPreferences }; + const acctId = state.currentAccountId; + if (acctId) { + accountPreferences[acctId] = { + ...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }), + identityKeyBindings: bindings, + }; + } + return { identityKeyBindings: bindings, accountPreferences }; }); }, @@ -296,11 +337,17 @@ export const useSmimeStore = create()( for (const [identityId, keyId] of Object.entries(bindings)) { if (keyId === id) delete bindings[identityId]; } + const accountPreferences = { ...state.accountPreferences }; + const acctId = state.currentAccountId; + if (acctId && accountPreferences[acctId]) { + accountPreferences[acctId] = { ...accountPreferences[acctId], identityKeyBindings: bindings }; + } return { keyRecords: state.keyRecords.filter((k) => k.id !== id), unlockedKeys, unlockedDecryptionKeys, identityKeyBindings: bindings, + accountPreferences, }; }); }, @@ -378,13 +425,32 @@ export const useSmimeStore = create()( }, setSignDefault: (identityId, value) => { - set((state) => ({ - defaultSignIdentity: { ...state.defaultSignIdentity, [identityId]: value }, - })); + set((state) => { + const defaultSignIdentity = { ...state.defaultSignIdentity, [identityId]: value }; + const accountPreferences = { ...state.accountPreferences }; + const acctId = state.currentAccountId; + if (acctId) { + accountPreferences[acctId] = { + ...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }), + defaultSignIdentity, + }; + } + return { defaultSignIdentity, accountPreferences }; + }); }, setEncryptDefault: (value) => { - set({ defaultEncrypt: value }); + set((state) => { + const accountPreferences = { ...state.accountPreferences }; + const acctId = state.currentAccountId; + if (acctId) { + accountPreferences[acctId] = { + ...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }), + defaultEncrypt: value, + }; + } + return { defaultEncrypt: value, accountPreferences }; + }); }, setRememberUnlockedKeys: (value) => { @@ -403,17 +469,41 @@ export const useSmimeStore = create()( getUnlockedKey: (id) => get().unlockedKeys.get(id), + clearState: () => { + clearRememberedUnlocks(); + set({ + keyRecords: [], + publicCerts: [], + unlockedKeys: new Map(), + unlockedDecryptionKeys: new Map(), + identityKeyBindings: {}, + defaultSignIdentity: {}, + defaultEncrypt: false, + currentAccountId: null, + isLoading: false, + error: null, + }); + }, + setError: (error) => set({ error }), }), { name: 'smime-preferences', partialize: (state): SmimePersistedState => ({ - identityKeyBindings: state.identityKeyBindings, - defaultSignIdentity: state.defaultSignIdentity, - defaultEncrypt: state.defaultEncrypt, + accountPreferences: state.accountPreferences, rememberUnlockedKeys: state.rememberUnlockedKeys, autoImportSignerCerts: state.autoImportSignerCerts, }), + merge: (persisted, current) => { + const p = persisted as Partial; defaultSignIdentity?: Record; defaultEncrypt?: boolean }>; + return { + ...current, + // Migrate legacy flat preferences into accountPreferences + accountPreferences: p?.accountPreferences ?? {}, + rememberUnlockedKeys: p?.rememberUnlockedKeys ?? false, + autoImportSignerCerts: p?.autoImportSignerCerts ?? true, + }; + }, }, ), ); diff --git a/test-certs/leon.p12 b/test-certs/leon.p12 new file mode 100644 index 0000000000000000000000000000000000000000..475804216d552ee3b600a3e7af129f7c67600b66 GIT binary patch literal 3493 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 Rl;yiSsph-%-v4UJ{{hDys+9l$ literal 0 HcmV?d00001 diff --git a/test-certs/root-rbm.p12 b/test-certs/root-rbm.p12 new file mode 100644 index 0000000000000000000000000000000000000000..9bbd17badd8c8028a329ea811687db3c5b7df276 GIT binary patch literal 3493 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 R0Hy^Cez0i(ZvLw!{|D~PdXE4A literal 0 HcmV?d00001