diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx index 6cedf56..1eb1a43 100644 --- a/frontend/src/components/ChatThread.tsx +++ b/frontend/src/components/ChatThread.tsx @@ -49,11 +49,15 @@ export default function ChatThread({ let cancelled = false; (async () => { const out: Record = {}; - let pub = await partnerPub(partnerId); + // Fetch the partner key lazily inside the try (only when there's a ciphertext message) and + // once — so an empty thread never fetches, and a fetch failure (e.g. a partner who reset and + // hasn't re-set-up, so has no key) degrades to "can't decrypt" instead of rejecting the pass. + let pub: string | null = null; let refreshed = false; for (const m of q.data?.items ?? []) { if (!m.ciphertext || !m.iv) continue; try { + if (!pub) pub = await partnerPub(partnerId); out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv); } catch { // A decrypt failure can mean the partner rotated their keypair (a passphrase reset), so diff --git a/frontend/src/lib/messaging.ts b/frontend/src/lib/messaging.ts index 755e0b0..8bdcb05 100644 --- a/frontend/src/lib/messaging.ts +++ b/frontend/src/lib/messaging.ts @@ -24,6 +24,13 @@ export async function partnerPub(partnerId: number): Promise { // Drop a partner's cached public key + derived conversation key and refetch. Called after a decrypt // fails, which can mean the partner reset their passphrase and published a NEW keypair; refetching // lets this device both read their new messages and encrypt future ones under the current key. +// +// KNOWN LIMITATION: this refresh is wired only to the DECRYPT path. If you send a partner a message +// AFTER they rotated their key but BEFORE any of their messages arrived to trigger a refresh, that +// one send is encrypted under the stale cached key and is undecryptable for them (subsequent sends +// self-heal once an incoming message refreshes the cache). Accepted for now — same cross-user +// staleness envelope as the multi-device note in useKeyState; a fuller fix needs a key TTL / a +// send-time freshness check, which is out of the current no-forward-secrecy scope. export async function refreshPartnerPub(partnerId: number): Promise { pubCache.delete(partnerId); forgetConv(partnerId);