diff --git a/AGENTS.md b/AGENTS.md index b7f73e3..31486f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -543,8 +543,14 @@ session with **no other memory of this project**. So: `lib/cordn/{client,crypto}.ts` are **kept but unwired** (marked in their headers) — re-wiring is a deliberate decision, not a one-liner, because it split chat history. Group reads widened to **500** wraps (1:1 stays 200). - (Still not done: persisting the decrypt cache across app restarts, so the first - load per launch still decrypts everything.) + **Persisted DM decrypt cache (2026-10-13):** `src/lib/dmDecryptCache.ts` (Dexie + `SkateSpotsDmCacheDB`, keyed `viewerPk:wrapId`, bounded 4000/account, pruned + oldest-first on hydrate) is used by `decryptGiftWrap` — so a message is + decrypted **once, ever**, killing the first-load-per-launch Amber storm. + ⚠️ It stores **plaintext DMs at rest** in IndexedDB (device-local, never + published) — the trade-off for not re-prompting; it is cleared on **logout** + (`AccountSwitcher`) so another account on the device can't read it. + `clearDmDecryptCache()` is exported for a future "clear cache" action. - **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat queries public relays **directly** (`useCVMClient.ts`, 3 fixed relays) and does **not** use the ContextVM server. AI goes through our own **server-side diff --git a/src/components/auth/AccountSwitcher.tsx b/src/components/auth/AccountSwitcher.tsx index f7d0970..956ccb6 100644 --- a/src/components/auth/AccountSwitcher.tsx +++ b/src/components/auth/AccountSwitcher.tsx @@ -11,6 +11,7 @@ import { } from '@/components/ui/dropdown-menu.tsx'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx'; import { WalletModal } from '@/components/WalletModal'; +import { clearDmDecryptCache } from '@/lib/dmDecryptCache'; import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts'; import { genUserName } from '@/lib/genUserName'; import { useTranslation } from '@/contexts/language'; @@ -97,7 +98,12 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) { {t('settingsUi.accountSwitcher.addAccount')} removeLogin(currentUser.id)} + onClick={() => { + // Drop this account's plaintext DM cache (device-local) on logout so + // another account on the same device can't read it from IndexedDB. + void clearDmDecryptCache(currentUser.id).catch(() => {}); + removeLogin(currentUser.id); + }} className='flex items-center gap-2 cursor-pointer p-2 rounded-md text-red-500' > diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index c0a744c..4f9ae6e 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -11,6 +11,12 @@ import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabil import { useRelays } from '@/lib/relays'; import { SKATE_RELAY } from '@/lib/types'; import type { RelayMetadata } from '@/contexts/AppContext'; +import { + loadDmDecryptCache, + saveDmDecryptKey, + pruneDmDecryptCache, + type CachedDm, +} from '@/lib/dmDecryptCache'; /** * Read relays for DMs: the user's NIP-65 read relays plus their own NIP-17 @@ -220,18 +226,7 @@ function createRumor( } /** A decrypted NIP-17 message (the fields every reader needs). */ -interface DecryptedDm { - id: string; - pubkey: string; - content: string; - createdAt: number; - /** The rumor's `p` tags (raw recipients, sender NOT necessarily included). */ - recipients: string[]; - /** Sender + recipients, deduped (the group identity). */ - participants: string[]; - subject?: string; - replyTo?: string; -} +type DecryptedDm = CachedDm; /** * Session cache of decrypted gift wraps, keyed by `viewerPk:wrapId`. @@ -250,6 +245,35 @@ const DM_DECRYPT_CACHE_MAX = 4000; const dmDecryptCache = new Map(); const dmDecryptInFlight = new Map>(); +// Which accounts' persisted caches have been loaded into memory this session, +// and any hydration in flight (so concurrent readers await the same load). +const dmCacheHydrated = new Set(); +const dmCacheHydrating = new Map>(); + +/** Load an account's persisted decrypt cache into memory (once per session). */ +async function ensureDmCacheHydrated(viewerPk: string): Promise { + if (dmCacheHydrated.has(viewerPk)) return; + const flying = dmCacheHydrating.get(viewerPk); + if (flying) return flying; + const task = (async () => { + try { + const rows = await loadDmDecryptCache(viewerPk, DM_DECRYPT_CACHE_MAX); + for (const [k, dm] of rows) { + if (!dmDecryptCache.has(k)) dmDecryptCache.set(k, dm); + } + dmCacheHydrated.add(viewerPk); + // Keep the on-disk set bounded (oldest dropped first). + void pruneDmDecryptCache(viewerPk, DM_DECRYPT_CACHE_MAX).catch(() => {}); + } catch { + // A cache read failure just means we decrypt again — never fatal. + } finally { + dmCacheHydrating.delete(viewerPk); + } + })(); + dmCacheHydrating.set(viewerPk, task); + return task; +} + /** The signer surface DM reading needs (NIP-44 decrypt). */ interface DmSigner { nip44?: { @@ -263,6 +287,9 @@ async function decryptGiftWrap( wrap: NostrEvent, ): Promise { const key = `${viewerPk}:${wrap.id}`; + // Hydrate the persisted cache once per account before the first lookup, so a + // relaunch reuses decryptions that already happened on this device. + await ensureDmCacheHydrated(viewerPk); const cached = dmDecryptCache.get(key); if (cached) return cached; const flying = dmDecryptInFlight.get(key); @@ -298,6 +325,8 @@ async function decryptGiftWrap( if (oldest !== undefined) dmDecryptCache.delete(oldest); } dmDecryptCache.set(key, dm); + // Persist so this wrap is never decrypted again (fire-and-forget). + void saveDmDecryptKey(key, viewerPk, dm).catch(() => {}); return dm; } catch { // Not cached — a transient failure may succeed on the next attempt. diff --git a/src/lib/dmDecryptCache.ts b/src/lib/dmDecryptCache.ts new file mode 100644 index 0000000..61f4ce1 --- /dev/null +++ b/src/lib/dmDecryptCache.ts @@ -0,0 +1,92 @@ +/** + * Persisted DM decrypt cache. + * ───────────────────────────────────────────────────────────────────────────── + * NIP-17 reading is expensive with an external signer: every gift wrap costs two + * signer operations (decrypt the seal, decrypt the rumor). The in-memory cache in + * `useDirectMessages` coalesces this within a session, but a fresh launch had to + * decrypt the whole inbox again — the last remaining Amber "pop-up storm". + * + * This module persists decrypted messages to IndexedDB so a message is decrypted + * **once, ever**. Keyed `viewerPk:wrapId`; bounded per account; pruned oldest-first. + * + * ⚠️ Privacy: this stores the **plaintext** of your DMs (sender, body, recipients) + * at rest in this origin's IndexedDB — the same content you already see on screen. + * It is device-local, never published, and scoped to the logged-in pubkey. Use + * {@link clearDmDecryptCache} to wipe it (e.g. from a future "clear cache" action). + */ + +import Dexie, { type Table } from 'dexie'; + +export interface CachedDm { + id: string; + pubkey: string; + content: string; + createdAt: number; + /** The rumor's `p` tags (raw recipients). */ + recipients: string[]; + /** Sender + recipients, deduped (the group identity). */ + participants: string[]; + subject?: string; + replyTo?: string; +} + +interface DmRow { + /** `${viewerPk}:${wrapId}` — unique per recipient, since decryption is per-key. */ + key: string; + viewerPk: string; + cachedAt: number; + dm: CachedDm; +} + +class SkateSpotsDmCacheDB extends Dexie { + dm!: Table; + + constructor() { + super('SkateSpotsDmCacheDB'); + this.version(1).stores({ + dm: 'key, viewerPk, cachedAt', + }); + } +} + +let db: SkateSpotsDmCacheDB | null = null; +function getDB(): SkateSpotsDmCacheDB { + db ??= new SkateSpotsDmCacheDB(); + return db; +} + +/** Load this account's cached messages, newest first, capped at `limit`. */ +export async function loadDmDecryptCache( + viewerPk: string, + limit: number, +): Promise> { + const rows = await getDB().dm.where('viewerPk').equals(viewerPk).toArray(); + rows.sort((a, b) => b.cachedAt - a.cachedAt); + return rows.slice(0, limit).map((r) => [r.key, r.dm]); +} + +/** Persist one decrypted message (fire-and-forget friendly). */ +export async function saveDmDecryptKey( + key: string, + viewerPk: string, + dm: CachedDm, +): Promise { + await getDB().dm.put({ key, viewerPk, cachedAt: Date.now(), dm }); +} + +/** Keep at most `max` rows for this account (oldest dropped first). */ +export async function pruneDmDecryptCache(viewerPk: string, max: number): Promise { + const rows = await getDB().dm.where('viewerPk').equals(viewerPk).toArray(); + if (rows.length <= max) return; + rows.sort((a, b) => a.cachedAt - b.cachedAt); + await getDB().dm.bulkDelete(rows.slice(0, rows.length - max).map((r) => r.key)); +} + +/** Wipe the cache for one account, or all accounts when omitted. */ +export async function clearDmDecryptCache(viewerPk?: string): Promise { + if (!viewerPk) { + await getDB().dm.clear(); + return; + } + await getDB().dm.where('viewerPk').equals(viewerPk).delete(); +}