Files
cinny/src/client/initMatrix.ts
T
jaredandClaude Opus 4.8 726cefb5ab fix(privacy): wipe plaintext/PII localStorage caches on logout (SEC-1/2)
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>
2026-07-18 16:56:08 -04:00

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();
};