CI / Build & Quality Checks (push) Successful in 1m40s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 2m7s
deleteSearchCacheDatabase() resolved after a 3 s "blocked" timeout while another tab still held the DB, so decrypted rows could survive logout. It now broadcasts lotus-logout first; every tab closes its handle and refuses to reopen, then the delete proceeds. A boot with no session re-runs the wipe once in case a race was still lost. Unit-tested. Fixes #45 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
180 lines
7.1 KiB
TypeScript
180 lines
7.1 KiB
TypeScript
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). `persist()` grants based on engagement / PWA
|
|
* install and shows no prompt; denial/absence is non-fatal.
|
|
*/
|
|
export const requestPersistentStorage = async (): Promise<boolean> => {
|
|
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<MatrixClient> => {
|
|
// 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,
|
|
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<void> => {
|
|
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();
|
|
};
|