Several localStorage caches held decrypted message content or user PII and survived a normal logout, leaving residue on a shared device (the search index was already wiped; these were not): - cinny_scheduled_messages_v1 - decrypted IContent.body of pending sends - cinny_recent_searches_v1 - search query text - cinny_recent_forward_targets_v1 - recent forward contact/room graph - cinny_recent_gifs_v1 / cinny_recent_stickers_v1 - media the user sent - navToActivePath<userId> - per-space last-visited room paths - (plus the translation cache added earlier) Add a clear function per module and a single auditable clearPlaintextCaches() aggregator, called from both logout paths (logoutClient + the server-forced SessionLoggedOut handler) alongside the existing session/search-index wipes. Unit-tested. Deliberately NOT cleared (documented in the aggregator): unsent composer drafts and the presence status message (preserved by product decision N98); SDK sync/crypto store + io.lotus.* account data (reminders/bookmarks/notes), already wiped by mx.clearStores(); low-sensitivity UI/metadata residue. The forward-targets/gifs/stickers/nav-path additions and the accurate "not covered" documentation address findings from two review passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
5.9 KiB
TypeScript
158 lines
5.9 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 } from '../app/utils/searchCache';
|
|
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
|
|
|
// 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());
|
|
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);
|
|
// Remove only the session credential keys, preserving user preferences and
|
|
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
|
removeFallbackSession();
|
|
window.location.reload();
|
|
};
|
|
|
|
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();
|
|
};
|