Files
cinny/src/client/initMatrix.ts
T
Lotus CIandClaude Opus 5.5 b944456ab5
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m32s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 16s
CI / Playwright smoke (e2e) (pull_request) Successful in 24m32s
chore(types): typed secret storage key cache; drop the cryptoCallbacks cast (#210)
secretStorageKeys was untyped JS, passed to createClient behind `as any`,
which hid that getSecretStorageKey returned undefined where the SDK type
says null. Now TypeScript against the SDK's CryptoCallbacks, returns null,
and storePrivateKey keeps its own copy of the key. Unit test added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 19:38:10 -04:00

203 lines
8.2 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 { installRefreshGuard, OidcIdentity, persistRefreshedTokens } from './oidcTokenRefresher';
import { pushSessionToSW } from '../sw-session';
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
import { watchKeyUploads } from '../app/utils/keyUploadWatch';
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
import { clearSharedPayload } from '../swShare';
// 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<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.
// [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<boolean>((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. Since SDK 43 the client
// refreshes them itself; we persist the rotated tokens and serialise
// refreshes across tabs (oidcTokenRefresher).
const oidcIdentity: OidcIdentity | undefined =
session.refreshToken && session.oidc
? {
userId: session.userId,
deviceId: session.deviceId,
baseUrl: session.baseUrl,
oidc: session.oidc,
}
: undefined;
if (oidcIdentity) installRefreshGuard(oidcIdentity);
const mx = createClient({
baseUrl: session.baseUrl,
accessToken: session.accessToken,
refreshToken: session.refreshToken,
userId: session.userId,
store: indexedDBStore,
cryptoStore: legacyCryptoStore,
deviceId: session.deviceId,
// [Gitea #201] Observe /keys/upload so a diverged OTK store is reported to
// the user (KeyUploadBanner) instead of retrying silently forever.
fetchFn: watchKeyUploads((input, init) => globalThis.fetch(input, init)),
timelineSupport: true,
cryptoCallbacks,
// 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'],
oauthClientId: oidcIdentity?.oidc.clientId,
onTokenRefresh: oidcIdentity
? (tokens) => persistRefreshedTokens(oidcIdentity, tokens)
: 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 the SDK revokes the tokens at the auth server instead of
// calling /logout (matrix-js-sdk 43+); best-effort either way.
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();
// [Gitea #267] Files stashed by the share target stay in Cache Storage until
// /share is opened; a shared photo must not outlive the logout.
await clearSharedPayload().catch(() => 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();
};
// 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();
};