import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk'; import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { getFallbackSession, removeFallbackSession, Session } from '../app/state/sessions'; import { LotusOidcTokenRefresher } from './oidcTokenRefresher'; import { revokeOidcTokens } from './oidcLogout'; import { pushSessionToSW } from '../sw-session'; import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache'; import { clearPlaintextCaches } from '../app/state/plaintextCaches'; import { clearSoundboardClipCache } from '../app/utils/soundboardClips'; // Thrown when the local IndexedDB has a higher schema version than this SDK expects. // 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). Chromium/WebKit answer `persist()` at once * (engagement / PWA install); Firefox shows a permission prompt and the promise * stays pending until it's answered. Denial/absence is non-fatal. */ const PERSIST_WAIT_MS = 1500; 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. // [Gitea #303] Never block startup on the answer: Firefox prompts and leaves // the promise pending until the user responds, which froze the app on // "Heating up". KE-1 only needs the request made; give it 1.5 s at most. await Promise.race([ requestPersistentStorage(), new Promise((resolve) => { setTimeout(() => resolve(false), PERSIST_WAIT_MS); }), ]); const indexedDBStore = new IndexedDBStore({ indexedDB: globalThis.indexedDB, localStorage: globalThis.localStorage, dbName: 'web-sync-store', }); const legacyCryptoStore = new IndexedDBCryptoStore(globalThis.indexedDB, 'crypto-store'); // OIDC/next-gen-auth sessions carry a refresh token; wire automatic refresh // (the client calls this reactively on a 401) and persist rotated tokens. const oidcRefresher = session.refreshToken && session.oidc ? new LotusOidcTokenRefresher(session.oidc, session.deviceId, session.userId, session.baseUrl) : undefined; const mx = createClient({ baseUrl: session.baseUrl, accessToken: session.accessToken, refreshToken: session.refreshToken, userId: session.userId, store: indexedDBStore, cryptoStore: legacyCryptoStore, deviceId: session.deviceId, timelineSupport: true, cryptoCallbacks: cryptoCallbacks as any, // SAS (emoji) + QR-code verification (show/scan/reciprocate). verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'], tokenRefreshFunction: oidcRefresher ? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken) : undefined, }); try { await indexedDBStore.startup(); } catch (e) { // IDB VersionError = local DB was written by a newer SDK version (schema downgrade). if (e instanceof DOMException && e.name === 'VersionError') { throw new Error(IDB_VERSION_CONFLICT); } throw e; } await mx.initRustCrypto(); mx.setMaxListeners(150); mx.matrixRTC.setMaxListeners(150); return mx; }; export const startClient = async (mx: MatrixClient) => { await mx.startClient({ lazyLoadMembers: true, // P3-8: partition m.thread relations into Thread objects/timelines. Thread // replies leave the main timeline (roots stay + get a summary chip); the // thread panel renders them. markAsRead sends UNTHREADED receipts // (utils/notifications.ts) so room badges keep clearing. threadSupport: true, }); }; export const clearCacheAndReload = async (mx: MatrixClient) => { mx.stopClient(); clearNavToActivePathStore(mx.getSafeUserId()); // [Gitea #57] Soundboard clip blob URLs hold decrypted media reachable for // the whole page session — revoke them alongside the rest of the caches. clearSoundboardClipCache(); await mx.store.deleteAllData(); window.location.reload(); }; export const logoutClient = async (mx: MatrixClient) => { pushSessionToSW(); mx.stopClient(); // For OIDC sessions, revoke the tokens at the issuer too (best-effort). const session = getFallbackSession(); if (session?.oidc) { await revokeOidcTokens(session); } try { await mx.logout(); } catch { // ignore if failed to logout } await mx.clearStores(); // The opt-in local search index stores decrypted plaintext — always wipe it // on logout. (clearLoginData below nukes all IDB databases, covering it too.) await deleteSearchCacheDatabase(); // Other localStorage caches also hold decrypted plaintext / PII (translation, // scheduled messages, recent searches/forwards/gifs/stickers, nav paths) — // wipe them too. clearPlaintextCaches(mx.getUserId() ?? undefined); // [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard // clip bytes stay reachable via live blob: URLs until the reload otherwise. clearSoundboardClipCache(); // Remove only the session credential keys, preserving user preferences and // unsent drafts (N98). The factory-reset path is clearLoginData() below. removeFallbackSession(); window.location.reload(); }; // Gitea #45 — deleteSearchCacheDatabase()'s onblocked handling is a bounded // best-effort wait; if another tab held `lotus-search-cache` open through it // (the pre-broadcast case, or a browser without BroadcastChannel), the delete // stayed queued and plaintext survived on disk despite logout "succeeding". // Complete it on the next boot with no session, once per boot — call this // wherever the app decides there is no session to hydrate (e.g. alongside the // existing getFallbackSession() checks in src/index.tsx). let bootCleanupRan = false; export const cleanupSearchCacheIfSignedOut = async (): Promise => { const hasSession = !!getFallbackSession(); if (!shouldRunBootCleanup(hasSession, bootCleanupRan)) return; bootCleanupRan = true; await deleteSearchCacheDatabase(); }; export const clearLoginData = async () => { const dbs = await window.indexedDB.databases(); dbs.forEach((idbInfo) => { const { name } = idbInfo; if (name) { window.indexedDB.deleteDatabase(name); } }); // Unregister service workers so stale caches don't interfere after a reset if ('serviceWorker' in navigator) { const regs = await navigator.serviceWorker.getRegistrations(); await Promise.all(regs.map((r) => r.unregister())); const cacheNames = await caches.keys(); await Promise.all(cacheNames.map((c) => caches.delete(c))); } window.localStorage.clear(); window.location.reload(); };