From 44f4278427612cca0f192948d55db8c66f981bef Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Thu, 10 Sep 2026 14:14:36 +0200 Subject: [PATCH] fix(crypto): recover from a crypto store left behind by another device --- .changeset/fix-crypto-mismatch-reload.md | 5 --- .../fix-crypto-store-device-mismatch.md | 5 +++ .../notifications/PushNotifications.tsx | 2 +- src/app/state/sessions.ts | 10 ++++- src/client/initMatrix.sdk.test.ts | 38 ++++++++++++++++--- src/client/initMatrix.test.ts | 13 ++++++- src/client/initMatrix.ts | 33 ++++++++++++---- src/instrument.ts | 8 ++++ 8 files changed, 92 insertions(+), 22 deletions(-) delete mode 100644 .changeset/fix-crypto-mismatch-reload.md create mode 100644 .changeset/fix-crypto-store-device-mismatch.md diff --git a/.changeset/fix-crypto-mismatch-reload.md b/.changeset/fix-crypto-mismatch-reload.md deleted file mode 100644 index 13db8be7ed..0000000000 --- a/.changeset/fix-crypto-mismatch-reload.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -default: patch ---- - -Fix devices randomly becoming unverified: clear the account's crypto stores on forced logout, and reload instead of retrying in place after a store mismatch. diff --git a/.changeset/fix-crypto-store-device-mismatch.md b/.changeset/fix-crypto-store-device-mismatch.md new file mode 100644 index 0000000000..29f43f8855 --- /dev/null +++ b/.changeset/fix-crypto-store-device-mismatch.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix devices randomly becoming unverified after a forced sign-out: encryption keys left behind by a previous session on the same account no longer block sign-in, and that session now starts its own device scoped store. diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index e0422884e4..94d7f75515 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -139,7 +139,7 @@ export async function enablePushNotifications( ): Promise { if (isTauri()) return; if (!('serviceWorker' in navigator) || !('PushManager' in window)) { - debugLog.error( + debugLog.info( 'notification', 'Push messaging not supported - missing serviceWorker or PushManager' ); diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index 421de3be99..30cabccbb2 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -38,12 +38,14 @@ export type SessionStoreName = { crypto: string; /** Prefix for the Rust crypto IndexedDB: the actual DB is `${rustCryptoPrefix}::matrix-sdk-crypto` */ rustCryptoPrefix: string; + /** Device scoped prefix, used when the shared store holds another device's account. */ + rustCryptoPrefixPerDevice: string; }; /** * Migration code for old session */ -const FALLBACK_STORE_NAME: SessionStoreName = { +const FALLBACK_STORE_NAME = { sync: 'web-sync-store', crypto: 'crypto-store', rustCryptoPrefix: 'matrix-js-sdk', @@ -92,13 +94,17 @@ export const getFallbackSession = (): Session | undefined => { export const getSessionStoreName = (session: Session): SessionStoreName => { if (session.fallbackSdkStores) { - return FALLBACK_STORE_NAME; + return { + ...FALLBACK_STORE_NAME, + rustCryptoPrefixPerDevice: `${FALLBACK_STORE_NAME.rustCryptoPrefix}:${session.deviceId}`, + }; } return { sync: `sync${session.userId}`, crypto: `crypto${session.userId}`, rustCryptoPrefix: `sync${session.userId}`, + rustCryptoPrefixPerDevice: `sync${session.userId}:${session.deviceId}`, }; }; diff --git a/src/client/initMatrix.sdk.test.ts b/src/client/initMatrix.sdk.test.ts index 9f47b3811e..3c13a2337b 100644 --- a/src/client/initMatrix.sdk.test.ts +++ b/src/client/initMatrix.sdk.test.ts @@ -38,7 +38,7 @@ vi.mock('./versionsCache', () => ({ wasUnstableFeatureCached: vi.fn<() => boolean>().mockReturnValue(false), })); -import { initClient } from './initMatrix'; +import { initClient, releaseCryptoStore } from './initMatrix'; const session = (userId: string): Session => ({ baseUrl: 'https://example.org', @@ -127,22 +127,33 @@ describe('initClient SDK crypto initialization', () => { it('preserves local stores when SDK crypto initialization reports an identity mismatch', async () => { const mismatch = new Error("Account in the store doesn't match account in the constructor"); const failedSession = session('@mismatch:example.org'); - const cryptoDatabase = `${getSessionStoreName(failedSession).rustCryptoPrefix}::matrix-sdk-crypto`; + const storeName = getSessionStoreName(failedSession); + const cryptoDatabase = `${storeName.rustCryptoPrefix}::matrix-sdk-crypto`; await storeSentinel(cryptoDatabase); initRustCrypto.mockRejectedValueOnce(mismatch); const deleteDatabase = vi.spyOn(indexedDB, 'deleteDatabase'); vi.stubGlobal('location', { reload: vi.fn<() => void>() }); - await expect(initClient(failedSession)).rejects.toMatchObject({ - message: expect.stringContaining('Stored encryption keys'), - cause: mismatch, - }); + await expect(initClient(failedSession)).resolves.toBeDefined(); + expect(initRustCrypto).toHaveBeenLastCalledWith({ + cryptoDatabasePrefix: storeName.rustCryptoPrefixPerDevice, + }); expect(await readSentinel(cryptoDatabase)).toBe('preserved'); expect(deleteDatabase).not.toHaveBeenCalled(); expect(window.location.reload).not.toHaveBeenCalled(); }); + it('surfaces a non-mismatch failure on the device scoped retry', async () => { + const failedSession = session('@mismatch-then-broken:example.org'); + initRustCrypto + .mockRejectedValueOnce(new Error("account in the store doesn't match")) + .mockRejectedValueOnce(new Error('SDK startup failed')); + + await expect(initClient(failedSession)).rejects.toThrow('SDK startup failed'); + expect(initRustCrypto).toHaveBeenCalledTimes(2); + }); + it('shares an in-flight SDK crypto initialization for the same session', async () => { const cryptoStartup = createDeferred(); initRustCrypto.mockImplementation(() => cryptoStartup.promise); @@ -173,6 +184,21 @@ describe('initClient SDK crypto initialization', () => { await first; }); + it('does not hand a new device the previous device crypto store', async () => { + const onOldDevice: Session = { ...session('@relogin:example.org'), deviceId: 'OLDDEVICE' }; + const onNewDevice: Session = { ...onOldDevice, deviceId: 'NEWDEVICE' }; + + releaseCryptoStore(await initClient(onOldDevice)); + initRustCrypto.mockClear(); + initRustCrypto.mockRejectedValueOnce(new Error("account in the store doesn't match")); + + await expect(initClient(onNewDevice)).resolves.toBeDefined(); + + expect(initRustCrypto).toHaveBeenLastCalledWith({ + cryptoDatabasePrefix: getSessionStoreName(onNewDevice).rustCryptoPrefixPerDevice, + }); + }); + it('allows a retry after an initialization failure', async () => { const failedSession = session('@retry-after-failure:example.org'); initRustCrypto.mockRejectedValueOnce(new Error('SDK startup failed')); diff --git a/src/client/initMatrix.test.ts b/src/client/initMatrix.test.ts index aab1d3fc57..c988020c21 100644 --- a/src/client/initMatrix.test.ts +++ b/src/client/initMatrix.test.ts @@ -428,10 +428,11 @@ describe('resolvePollTimeoutMs', () => { }); }); -const makeKeyBackupMx = (syncState: SyncState | null) => { +const makeKeyBackupMx = (syncState: SyncState | null, clientRunning = true) => { const checkKeyBackupAndEnable = vi.fn<() => Promise>().mockResolvedValue(null); const listeners = new Set<(state: SyncState) => void>(); const mx = { + clientRunning, getSyncState: () => syncState, getCrypto: () => ({ checkKeyBackupAndEnable }), on: (_event: ClientEvent, cb: (state: SyncState) => void) => listeners.add(cb), @@ -465,6 +466,16 @@ describe('recheckKeyBackupAfterInitialSync', () => { expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1); }); + it('skips the re-check once the client has stopped', () => { + const { mx, checkKeyBackupAndEnable, emitSync, listeners } = makeKeyBackupMx(null, false); + + recheckKeyBackupAfterInitialSync(mx); + emitSync(SyncState.Prepared); + + expect(checkKeyBackupAndEnable).not.toHaveBeenCalled(); + expect(listeners.size).toBe(0); + }); + it('re-checks only once and stops listening', () => { const { mx, checkKeyBackupAndEnable, emitSync, listeners } = makeKeyBackupMx(null); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 790bd28f57..770cc57b83 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -81,6 +81,9 @@ export const claimCryptoStore = (mx: MatrixClient, storeKey: string): void => { cryptoStoreByClient.set(mx, storeKey); }; +export const getClientCryptoStore = (mx: MatrixClient): string | undefined => + cryptoStoreByClient.get(mx); + export const releaseCryptoStore = (mx: MatrixClient): void => { const storeKey = cryptoStoreByClient.get(mx); if (storeKey === undefined) return; @@ -176,6 +179,7 @@ const startPresenceAfterInitialSync = ( export const recheckKeyBackupAfterInitialSync = (mx: MatrixClient): void => { const recheck = () => { mx.removeListener(ClientEvent.Sync, onSync); + if (!mx.clientRunning) return; const crypto = mx.getCrypto(); if (!crypto) return; crypto.checkKeyBackupAndEnable().catch((error: unknown) => { @@ -342,6 +346,9 @@ const deleteSessionStores = async (storeName: SessionStoreName): Promise = deleteDatabase(storeName.sync), deleteDatabase(storeName.crypto), deleteDatabase(`${storeName.rustCryptoPrefix}::matrix-sdk-crypto`), + deleteDatabase(`${storeName.rustCryptoPrefix}::matrix-sdk-crypto-meta`), + deleteDatabase(`${storeName.rustCryptoPrefixPerDevice}::matrix-sdk-crypto`), + deleteDatabase(`${storeName.rustCryptoPrefixPerDevice}::matrix-sdk-crypto-meta`), ]); }; @@ -544,17 +551,27 @@ const initializeSession = async (session: Session): Promise => { } log.warn( - `initClient: mismatch during ${result.phase}; preserving local stores`, + `initClient: mismatch during ${result.phase}; retrying on a device scoped crypto store`, result.error ); - debugLog.warn('sync', 'Client initialization mismatch - preserving local stores', { + debugLog.warn('sync', 'Client initialization mismatch - using device scoped crypto store', { phase: result.phase, error: result.error, }); - throw new Error( - 'Stored encryption keys belong to a different session. Local data has been preserved.', - { cause: result.error } - ); + + evictPreviousCryptoStoreOwner(storeName.rustCryptoPrefixPerDevice); + const perDevice = await initializeClient(session, storeName.rustCryptoPrefixPerDevice); + if (!perDevice.ok) { + debugLog.error('sync', 'Failed to initialize client on device scoped crypto store', { + phase: perDevice.phase, + error: perDevice.error, + }); + throw perDevice.error; + } + + perDevice.mx.setMaxListeners(50); + claimCryptoStore(perDevice.mx, storeName.rustCryptoPrefixPerDevice); + return perDevice.mx; } result.mx.setMaxListeners(50); @@ -876,7 +893,9 @@ export const logoutClient = async (mx: MatrixClient, session?: Session) => { destroyLocalNotificationCache(session.userId); clearLocalNotificationCache(session.userId); const storeName: SessionStoreName = getSessionStoreName(session); - await mx.clearStores({ cryptoDatabasePrefix: storeName.rustCryptoPrefix }); + await mx.clearStores({ + cryptoDatabasePrefix: getClientCryptoStore(mx) ?? storeName.rustCryptoPrefix, + }); await deleteSessionStores(storeName); await wipeNativeCryptoStore(session); } else { diff --git a/src/instrument.ts b/src/instrument.ts index 25cde598b2..5539aee377 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -45,6 +45,14 @@ if (dsn && sentryEnabled) { // The default 100 only covered a few seconds of this app's HTTP traffic. maxBreadcrumbs: 200, + // Missing web push support and declined permission prompts are not defects. + ignoreErrors: [ + 'Push messaging is not supported in this browser.', + 'Registration failed - permission denied', + 'User denied push permission', + 'Push notification prompting can only be done from a user gesture', + ], + integrations: [ // React Router v6 browser tracing integration Sentry.reactRouterV6BrowserTracingIntegration({