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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
"linkifyjs": "^4.3.3",
"livekit-client": "2.22.0",
"marked": "^18.0.9",
"matrix-js-sdk": "42.1.0",
"matrix-js-sdk": "42.3.0",
"matrix-widget-api": "^1.18.0",
"nanoid": "^6.0.1",
"pdfjs-dist": "^6.2.108",
Expand Down
16 changes: 8 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 10 additions & 15 deletions src/app/components/DeviceVerification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { ContainerColor } from '$styles/ContainerColor.css';
import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
import { useMatrixClient } from '$hooks/useMatrixClient';
import type { CryptoBackend } from '$types/matrix-sdk';
import { Button } from '$components/button';

const DialogHeaderStyles: CSSProperties = {
Expand Down Expand Up @@ -95,8 +94,6 @@ function VerificationWaitStart() {
);
}

const PENDING_REQUEST_POLL_MS = 2000;

type VerificationStartProps = {
onStart: () => Promise<void>;
};
Expand Down Expand Up @@ -329,20 +326,18 @@ export function ReceiveSelfDeviceVerification() {
);

useEffect(() => {
if (request) return undefined;
const crypto = mx.getCrypto() as CryptoBackend | undefined;
if (!mx.clientRunning) return undefined;
const crypto = mx.getCrypto();
if (!crypto?.getVerificationRequestsToDeviceInProgress) return undefined;

const adopt = () => {
const pending = crypto
.getVerificationRequestsToDeviceInProgress(mx.getSafeUserId())
.find((candidate) => candidate.isSelfVerification && !candidate.initiatedByMe);
if (pending) setRequest(pending);
};
adopt();
const timer = setInterval(adopt, PENDING_REQUEST_POLL_MS);
return () => clearInterval(timer);
}, [mx, request]);
const pending = crypto
.getVerificationRequestsToDeviceInProgress(mx.getSafeUserId())
.find(
(candidate) => candidate.isSelfVerification && !candidate.initiatedByMe && candidate.pending
);
if (pending) setRequest(pending);
return undefined;
}, [mx]);

const handleExit = useCallback(() => {
setRequest(undefined);
Expand Down
75 changes: 62 additions & 13 deletions src/app/components/ReceiveSelfDeviceVerification.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ const getVerificationRequestsToDeviceInProgress = vi.hoisted(() =>
vi.fn<(userId: string) => unknown[]>()
);
const listeners = vi.hoisted(() => new Map<string, (request: unknown) => void>());

const matrixClient = vi.hoisted(() => ({
clientRunning: true,
getSafeUserId: () => '@me:example.org',
getCrypto: () => ({ getVerificationRequestsToDeviceInProgress }),
on: (event: string, handler: (request: unknown) => void) => listeners.set(event, handler),
removeListener: (event: string) => listeners.delete(event),
}));
vi.mock('$hooks/useMatrixClient', () => ({
useMatrixClient: () => ({
getSafeUserId: () => '@me:example.org',
getCrypto: () => ({ getVerificationRequestsToDeviceInProgress }),
on: (event: string, handler: (request: unknown) => void) => listeners.set(event, handler),
removeListener: (event: string) => listeners.delete(event),
}),
useMatrixClient: () => matrixClient,
}));

vi.mock('$components/modal-overlay/ModalOverlay', () => ({
Expand Down Expand Up @@ -46,22 +47,32 @@ const renderReceiver = () =>
describe('ReceiveSelfDeviceVerification', () => {
beforeEach(() => {
vi.clearAllMocks();
getVerificationRequestsToDeviceInProgress.mockReset();
getVerificationRequestsToDeviceInProgress.mockReturnValue([]);
matrixClient.clientRunning = true;
listeners.clear();
});

it('shows a request that arrived before it was mounted', async () => {
it('shows a pending self-verification request that arrived before mount', async () => {
getVerificationRequestsToDeviceInProgress.mockReturnValue([pendingRequest]);

renderReceiver();

await waitFor(() => expect(screen.getByText('Device Verification')).toBeInTheDocument());
});

it('shows an incoming self-verification request from the SDK event', async () => {
renderReceiver();
listeners.get('crypto.verificationRequestReceived')?.(pendingRequest);

await waitFor(() => expect(screen.getByText('Device Verification')).toBeInTheDocument());
});

it('does not treat unmounting as the user cancelling', async () => {
const cancel = vi.fn<() => Promise<void>>(async () => undefined);
getVerificationRequestsToDeviceInProgress.mockReturnValue([{ ...pendingRequest, cancel }]);

const { unmount } = renderReceiver();
listeners.get('crypto.verificationRequestReceived')?.({ ...pendingRequest, cancel });
await waitFor(() => expect(screen.getByText('Device Verification')).toBeInTheDocument());
expect(
screen.getByText('Device Verification').closest('[data-deactivate-closes]')
Expand All @@ -72,15 +83,53 @@ describe('ReceiveSelfDeviceVerification', () => {
});

it('ignores a request this device started', async () => {
getVerificationRequestsToDeviceInProgress.mockReturnValue([
{ ...pendingRequest, initiatedByMe: true },
]);

renderReceiver();
listeners.get('crypto.verificationRequestReceived')?.({
...pendingRequest,
initiatedByMe: true,
});

await new Promise((resolve) => {
setTimeout(resolve, 20);
});
expect(screen.queryByText('Device Verification')).toBeNull();
});

it('does not query a disposed crypto engine', () => {
matrixClient.clientRunning = false;
getVerificationRequestsToDeviceInProgress.mockImplementation(() => {
throw new Error('null pointer passed to rust');
});

renderReceiver();

expect(getVerificationRequestsToDeviceInProgress).not.toHaveBeenCalled();
});

it('does not poll after the client stops', () => {
vi.useFakeTimers();

renderReceiver();
expect(getVerificationRequestsToDeviceInProgress).toHaveBeenCalledTimes(1);

matrixClient.clientRunning = false;
getVerificationRequestsToDeviceInProgress.mockImplementation(() => {
throw new Error('null pointer passed to rust');
});
vi.advanceTimersByTime(6000);

expect(getVerificationRequestsToDeviceInProgress).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});

it('ignores a completed request found in progress', async () => {
getVerificationRequestsToDeviceInProgress.mockReturnValue([
{ ...pendingRequest, pending: false },
]);

renderReceiver();

await new Promise((resolve) => setTimeout(resolve, 20));
expect(screen.queryByText('Device Verification')).toBeNull();
});
});
103 changes: 103 additions & 0 deletions src/client/initMatrix.sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'fake-indexeddb/auto';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { MatrixClient } from '$types/matrix-sdk';
import type { Session } from '$state/sessions';
import { getSessionStoreName } from '$state/sessions';
import type * as MatrixSdkModule from 'matrix-js-sdk/lib/matrix';

const { isTauri, invoke, initRustCrypto } = vi.hoisted(() => ({
Expand Down Expand Up @@ -46,6 +47,50 @@ const session = (userId: string): Session => ({
accessToken: 'access-token',
});

const storeSentinel = async (name: string): Promise<void> =>
new Promise((resolve, reject) => {
const request = indexedDB.open(name, 1);
request.addEventListener('error', () => reject(request.error));
request.addEventListener('upgradeneeded', () => {
request.result.createObjectStore('sentinels');
});
request.addEventListener('success', () => {
const database = request.result;
const transaction = database.transaction('sentinels', 'readwrite');
transaction.objectStore('sentinels').put('preserved', 'crypto');
transaction.addEventListener('complete', () => {
database.close();
resolve();
});
transaction.addEventListener('error', () => {
database.close();
reject(transaction.error);
});
});
});

const readSentinel = async (name: string): Promise<unknown> =>
new Promise((resolve, reject) => {
const request = indexedDB.open(name);
request.addEventListener('error', () => reject(request.error));
request.addEventListener('success', () => {
const database = request.result;
const transaction = database.transaction('sentinels');
const get = transaction.objectStore('sentinels').get('crypto');
get.addEventListener('success', () => resolve(get.result));
get.addEventListener('error', () => reject(get.error));
transaction.addEventListener('complete', () => database.close());
});
});

const createDeferred = (): { promise: Promise<void>; resolve: () => void } => {
let resolve!: () => void;
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
};

describe('initClient SDK crypto initialization', () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -78,4 +123,62 @@ describe('initClient SDK crypto initialization', () => {
expect(invoke.mock.calls.some(([command]) => command === 'engine_open')).toBe(false);
expect(invoke.mock.calls.some(([command]) => command === 'engine_wipe')).toBe(false);
});

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`;
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,
});

expect(await readSentinel(cryptoDatabase)).toBe('preserved');
expect(deleteDatabase).not.toHaveBeenCalled();
expect(window.location.reload).not.toHaveBeenCalled();
});

it('shares an in-flight SDK crypto initialization for the same session', async () => {
const cryptoStartup = createDeferred();
initRustCrypto.mockImplementation(() => cryptoStartup.promise);
const pendingSession = session('@in-flight:example.org');

const clients = Promise.all([initClient(pendingSession), initClient({ ...pendingSession })]);

await vi.waitFor(() => expect(initRustCrypto).toHaveBeenCalledTimes(1));
cryptoStartup.resolve();

const [first, second] = await clients;
expect(first).toBe(second);
});

it('rejects a conflicting session while encrypted storage is initializing', async () => {
const cryptoStartup = createDeferred();
initRustCrypto.mockImplementation(() => cryptoStartup.promise);
const pendingSession = session('@in-flight-conflict:example.org');
const first = initClient(pendingSession);

await vi.waitFor(() => expect(initRustCrypto).toHaveBeenCalledTimes(1));
await expect(
initClient({ ...pendingSession, accessToken: 'different-access-token' })
).rejects.toThrow('A different session is already initializing encrypted storage');
expect(initRustCrypto).toHaveBeenCalledTimes(1);

cryptoStartup.resolve();
await first;
});

it('allows a retry after an initialization failure', async () => {
const failedSession = session('@retry-after-failure:example.org');
initRustCrypto.mockRejectedValueOnce(new Error('SDK startup failed'));

await expect(initClient(failedSession)).rejects.toThrow('SDK startup failed');
await expect(initClient({ ...failedSession })).resolves.toBeDefined();
expect(initRustCrypto).toHaveBeenCalledTimes(2);
});
});
Loading
Loading