From 45afc9ba7e6615f2f388add29765c59736037945 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 7 Jul 2026 20:18:32 -0400 Subject: [PATCH] fix(crypto): request persistent storage on client init (KE-1 mitigation) The IndexedDB crypto store is evictable while the localStorage session survives, so the browser can drop it out from under a live login -> the device resurrects with a blank key store and re-uploads a one-time key at an id Synapse already holds -> a permanent '400 M_UNKNOWN: One time key ... already exists' upload storm (and undecryptable to-device/media keys downstream). initClient now calls navigator.storage.persist() before creating the crypto store, so the origin's storage is marked persistent and won't be evicted. Best-effort (granted by engagement/PWA-install, no prompt; denial is non-fatal). Preventive only -- an already-diverged device still needs a clean re-login. Co-Authored-By: Claude Opus 4.8 --- src/client/initMatrix.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 9a8517a30..6db8358bd 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -12,7 +12,33 @@ import { deleteSearchCacheDatabase } from '../app/utils/searchCache'; // This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted). export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT'; +/** + * KE-1 mitigation. Ask the browser to make this origin's storage persistent so the + * IndexedDB **crypto store** isn't evicted from under a surviving `localStorage` + * session. When that happens the device "resurrects" with a blank key store and the + * client re-uploads a one-time key at an id Synapse already holds → a permanent + * `400 M_UNKNOWN: … already exists` upload-conflict storm (and, downstream, + * undecryptable to-device/media keys). `persist()` grants based on engagement / PWA + * install and shows no prompt; denial/absence is non-fatal. + */ +export const requestPersistentStorage = async (): Promise => { + try { + if (!navigator.storage?.persist) return false; + if (await navigator.storage.persisted()) return true; + const granted = await navigator.storage.persist(); + if (!granted) { + console.warn('Persistent storage not granted — the crypto store remains evictable (KE-1).'); + } + return granted; + } catch { + return false; + } +}; + export const initClient = async (session: Session): Promise => { + // Protect the crypto store from eviction before we create and write to it. + await requestPersistentStorage(); + const indexedDBStore = new IndexedDBStore({ indexedDB: globalThis.indexedDB, localStorage: globalThis.localStorage,