Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .changeset/fix-crypto-mismatch-reload.md

This file was deleted.

5 changes: 5 additions & 0 deletions .changeset/fix-crypto-store-device-mismatch.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export async function enablePushNotifications(
): Promise<void> {
if (isTauri()) return;
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
debugLog.error(
debugLog.info(
'notification',
'Push messaging not supported - missing serviceWorker or PushManager'
);
Expand Down
10 changes: 8 additions & 2 deletions src/app/state/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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}`,
};
};

Expand Down
38 changes: 32 additions & 6 deletions src/client/initMatrix.sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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'));
Expand Down
13 changes: 12 additions & 1 deletion src/client/initMatrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,10 +428,11 @@ describe('resolvePollTimeoutMs', () => {
});
});

const makeKeyBackupMx = (syncState: SyncState | null) => {
const makeKeyBackupMx = (syncState: SyncState | null, clientRunning = true) => {
const checkKeyBackupAndEnable = vi.fn<() => Promise<null>>().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),
Expand Down Expand Up @@ -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);

Expand Down
33 changes: 26 additions & 7 deletions src/client/initMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -342,6 +346,9 @@ const deleteSessionStores = async (storeName: SessionStoreName): Promise<void> =
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`),
]);
};

Expand Down Expand Up @@ -544,17 +551,27 @@ const initializeSession = async (session: Session): Promise<MatrixClient> => {
}

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);
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions src/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading