import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { SmimeKeyRecord, SmimePublicCert } from '@/lib/smime/types'; import { generateUUID } from '@/lib/utils'; import { saveKeyRecord, listKeyRecords, deleteKeyRecord as deleteKeyRecordDB, savePublicCert, listPublicCerts, deletePublicCert as deletePublicCertDB, } from '@/lib/smime/key-storage'; import { importPkcs12, unlockPrivateKey } from '@/lib/smime/pkcs12-import'; import { parseCertificatePemOrDer, extractCertificateInfo, } from '@/lib/smime/certificate-utils'; // Legacy storage key used by an earlier build that persisted unlock passphrases // in sessionStorage. Wipe on module load so any in-flight tab upgrading to this // version doesn't leave plaintext key material sitting around. New code never // writes here — unlocked CryptoKey handles live only in the in-memory Map below. const LEGACY_REMEMBERED_UNLOCKS_KEY = 'smime-unlocked-session'; if (typeof window !== 'undefined') { try { window.sessionStorage.removeItem(LEGACY_REMEMBERED_UNLOCKS_KEY); } catch { /* ignore */ } } interface SmimePersistedState { /** Account-scoped preferences: accountId → { identityKeyBindings, defaultSignIdentity, defaultEncrypt } */ accountPreferences: Record; defaultSignIdentity: Record; defaultEncrypt: 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[]; // Runtime only - never persisted unlockedKeys: Map; unlockedDecryptionKeys: Map; unlockedLegacyDecryptionKeys: Map; isLoading: boolean; error: string | null; // Actions 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; removeKeyRecord: (id: string) => Promise; removePublicCert: (id: string) => Promise; unlockKey: (id: string, passphrase: string) => Promise; lockKey: (id: string) => void; lockAllKeys: () => void; getKeyRecordForIdentity: (identityId: string) => SmimeKeyRecord | undefined; getPublicCertForEmail: (email: string) => SmimePublicCert | undefined; getRecipientCerts: (emails: string[]) => { found: SmimePublicCert[]; missing: string[] }; setSignDefault: (identityId: string, value: boolean) => void; setEncryptDefault: (value: boolean) => void; setAutoImportSignerCerts: (value: boolean) => void; isKeyUnlocked: (id: string) => boolean; getUnlockedKey: (id: string) => CryptoKey | undefined; setError: (error: string | null) => void; } export const useSmimeStore = create()( persist( (set, get) => ({ // Persisted preferences accountPreferences: {}, autoImportSignerCerts: true, // Runtime state currentAccountId: null, identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false, keyRecords: [], publicCerts: [], unlockedKeys: new Map(), unlockedDecryptionKeys: new Map(), unlockedLegacyDecryptionKeys: new Map(), isLoading: false, 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(acctId ?? undefined), listPublicCerts(acctId ?? undefined), ]); set({ keyRecords, publicCerts, isLoading: false }); } catch (err) { set({ error: err instanceof Error ? err.message : 'Failed to load S/MIME data', isLoading: false, }); } }, importPKCS12: async (file, p12Passphrase, storagePassphrase) => { 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], isLoading: false, })); return keyRecord; } catch (err) { set({ error: err instanceof Error ? err.message : 'Failed to import PKCS#12', isLoading: false, }); throw err; } }, importPublicCert: async (data, source, contactId) => { set({ isLoading: true, error: null }); try { const cert = parseCertificatePemOrDer(data); // Always re-encode to DER - input might be PEM text (string or ArrayBuffer) const der = cert.toSchema(true).toBER(false); const info = await extractCertificateInfo(cert, der); const email = info.emailAddresses[0] ?? ''; const publicCert: SmimePublicCert = { id: generateUUID(), accountId: get().currentAccountId ?? undefined, email: email.toLowerCase(), certificate: der, issuer: info.issuer, subject: info.subject, notBefore: info.notBefore, notAfter: info.notAfter, fingerprint: info.fingerprint, source, contactId, }; await savePublicCert(publicCert); set((state) => ({ publicCerts: [...state.publicCerts, publicCert], isLoading: false, })); return publicCert; } catch (err) { set({ error: err instanceof Error ? err.message : 'Failed to import certificate', isLoading: false, }); throw err; } }, bindIdentityToKey: (identityId, keyRecordId) => { set((state) => { const bindings = { ...state.identityKeyBindings }; if (keyRecordId === null) { delete bindings[identityId]; } else { bindings[identityId] = keyRecordId; } const accountPreferences = { ...state.accountPreferences }; const acctId = state.currentAccountId; if (acctId) { accountPreferences[acctId] = { ...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }), identityKeyBindings: bindings, }; } return { identityKeyBindings: bindings, accountPreferences }; }); }, removeKeyRecord: async (id) => { await deleteKeyRecordDB(id); set((state) => { const unlockedKeys = new Map(state.unlockedKeys); unlockedKeys.delete(id); const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys); unlockedDecryptionKeys.delete(id); const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys); unlockedLegacyDecryptionKeys.delete(id); // Remove any identity bindings pointing to this key const bindings = { ...state.identityKeyBindings }; 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, unlockedLegacyDecryptionKeys, identityKeyBindings: bindings, accountPreferences, }; }); }, removePublicCert: async (id) => { await deletePublicCertDB(id); set((state) => ({ publicCerts: state.publicCerts.filter((c) => c.id !== id), })); }, unlockKey: async (id, passphrase) => { const record = get().keyRecords.find((k) => k.id === id); if (!record) throw new Error('Key record not found'); const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(record, passphrase); set((state) => { const unlockedKeys = new Map(state.unlockedKeys); unlockedKeys.set(id, signingKey); const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys); if (decryptionKey) { unlockedDecryptionKeys.set(id, decryptionKey); } const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys); if (legacyDecryptionKey) { unlockedLegacyDecryptionKeys.set(id, legacyDecryptionKey); } return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys }; }); }, lockKey: (id) => { set((state) => { const unlockedKeys = new Map(state.unlockedKeys); unlockedKeys.delete(id); const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys); unlockedDecryptionKeys.delete(id); const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys); unlockedLegacyDecryptionKeys.delete(id); return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys }; }); }, lockAllKeys: () => { set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map(), unlockedLegacyDecryptionKeys: new Map() }); }, getKeyRecordForIdentity: (identityId) => { const { identityKeyBindings, keyRecords } = get(); const keyId = identityKeyBindings[identityId]; if (!keyId) return undefined; return keyRecords.find((k) => k.id === keyId); }, getPublicCertForEmail: (email) => { return get().publicCerts.find( (c) => c.email.toLowerCase() === email.toLowerCase(), ); }, getRecipientCerts: (emails) => { const { publicCerts } = get(); const found: SmimePublicCert[] = []; const missing: string[] = []; for (const email of emails) { const cert = publicCerts.find( (c) => c.email.toLowerCase() === email.toLowerCase(), ); if (cert) { found.push(cert); } else { missing.push(email); } } return { found, missing }; }, setSignDefault: (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((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 }; }); }, setAutoImportSignerCerts: (value) => { set({ autoImportSignerCerts: value }); }, isKeyUnlocked: (id) => get().unlockedKeys.has(id), getUnlockedKey: (id) => get().unlockedKeys.get(id), clearState: () => { set({ keyRecords: [], publicCerts: [], unlockedKeys: new Map(), unlockedDecryptionKeys: new Map(), unlockedLegacyDecryptionKeys: new Map(), identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false, currentAccountId: null, isLoading: false, error: null, }); }, setError: (error) => set({ error }), }), { name: 'smime-preferences', partialize: (state): SmimePersistedState => ({ accountPreferences: state.accountPreferences, 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 ?? {}, autoImportSignerCerts: p?.autoImportSignerCerts ?? true, }; }, }, ), );