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
21 changes: 21 additions & 0 deletions src/app/components/DeviceVerification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { useRefreshDeviceVerificationStatus } from '$hooks/useDeviceVerification
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 @@ -89,6 +91,8 @@ function VerificationWaitStart() {
);
}

const PENDING_REQUEST_POLL_MS = 2000;

type VerificationStartProps = {
onStart: () => Promise<void>;
};
Expand Down Expand Up @@ -309,10 +313,27 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
}

export function ReceiveSelfDeviceVerification() {
const mx = useMatrixClient();
const [request, setRequest] = useState<VerificationRequest>();

useVerificationRequestReceived(setRequest);

useEffect(() => {
if (request) return undefined;
const crypto = mx.getCrypto() as CryptoBackend | undefined;
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 handleExit = useCallback(() => {
setRequest(undefined);
}, []);
Expand Down
66 changes: 66 additions & 0 deletions src/app/components/ReceiveSelfDeviceVerification.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ReceiveSelfDeviceVerification } from './DeviceVerification';

const getVerificationRequestsToDeviceInProgress = vi.hoisted(() =>
vi.fn<(userId: string) => unknown[]>()
);
const listeners = vi.hoisted(() => new Map<string, (request: unknown) => void>());

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

vi.mock('$components/modal-overlay/ModalOverlay', () => ({
ModalOverlay: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));

const pendingRequest = {
isSelfVerification: true,
initiatedByMe: false,
pending: true,
phase: 1,
on: vi.fn<() => void>(),
removeListener: vi.fn<() => void>(),
};

const renderReceiver = () =>
render(
<QueryClientProvider client={new QueryClient()}>
<ReceiveSelfDeviceVerification />
</QueryClientProvider>
);

describe('ReceiveSelfDeviceVerification', () => {
beforeEach(() => {
vi.clearAllMocks();
listeners.clear();
});

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

renderReceiver();

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

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

renderReceiver();

await new Promise((resolve) => {
setTimeout(resolve, 20);
});
expect(screen.queryByText('Device Verification')).toBeNull();
});
});
42 changes: 41 additions & 1 deletion src/app/crypto/engineCrypto/EngineCrypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@

const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([a], [b]) => (a < b ? -1 : 1));

Check warning on line 136 in src/app/crypto/engineCrypto/EngineCrypto.ts

View workflow job for this annotation

GitHub Actions / Lint

unicorn(no-array-sort)

src/app/crypto/engineCrypto/EngineCrypto.ts:136:6: Use `Array#toSorted()` instead of `Array#sort()`.
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
};

Expand Down Expand Up @@ -240,6 +240,20 @@
forwarderDevice?: string | null;
};

const countRecipients = (body: string): number => {
try {
const messages = (JSON.parse(body) as { messages?: Record<string, Record<string, unknown>> })
.messages;
if (!messages) return 0;
return Object.values(messages).reduce(
(total, devices) => total + Object.keys(devices).length,
0
);
} catch {
return -1;
}
};

const isOutgoingRequest = (value: unknown): value is OutgoingRequest => {
if (!value || typeof value !== 'object') return false;
const candidate = value as Partial<OutgoingRequest>;
Expand Down Expand Up @@ -604,7 +618,33 @@
outgoingRequest?: unknown;
};
if (isOutgoingRequest(started.outgoingRequest)) {
await sendOutgoingRequest(this.#mx, started.outgoingRequest);
const recipients = countRecipients(started.outgoingRequest.body);
traceVerification('Sending a verification request', {
method,
flowId: started.request.flowId,
recipientDevices: recipients,
});
if (recipients === 0) {
warnVerification('The verification request reaches no device', {
method,
flowId: started.request.flowId,
});
}
try {
await sendOutgoingRequest(this.#mx, started.outgoingRequest);
} catch (error) {
warnVerification('The verification request could not be sent', {
method,
flowId: started.request.flowId,
reason: error instanceof Error ? error.message : String(error),
});
throw error;
}
} else {
warnVerification('The engine returned no verification request to send', {
method,
flowId: started.request.flowId,
});
}
await this.#flushOutgoingRequests();

Expand Down
75 changes: 75 additions & 0 deletions src/app/crypto/engineCrypto/incomingVerificationRequest.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CryptoEvent, EventType, type MatrixClient } from '$types/matrix-sdk';
import { engineInvoke } from '../olmMachine/engineInvoke';
import { traceVerification, warnVerification } from '$utils/verificationTrace';
import { EngineCrypto } from './EngineCrypto';

vi.mock('$utils/verificationTrace', () => ({
traceVerification: vi.fn<(message: string, data?: unknown) => void>(),
warnVerification: vi.fn<(message: string, data?: unknown) => void>(),
}));

vi.mock('../olmMachine/engineInvoke', () => ({
engineInvoke: vi.fn<(...args: never[]) => Promise<unknown>>(),
}));
Expand All @@ -27,6 +33,75 @@ const requestState = {
isSelfVerification: true,
};

describe('sending a verification request', () => {
beforeEach(() => {
mockInvoke.mockReset();
vi.mocked(traceVerification).mockClear();
vi.mocked(warnVerification).mockClear();
});

it('reports when the request reaches no device', async () => {
const { mx } = clientSpy();
mockInvoke.mockImplementation(async (_identity, method) => {
if (method === 'queryKeysForUsers') {
return { id: 'q1', type: 1, className: 'KeysQueryRequest', body: '{}' };
}
if (method === 'device.requestVerification') {
return {
request: requestState,
outgoingRequest: {
id: 'txn',
type: 3,
body: JSON.stringify({ messages: {} }),
event_type: 'm.key.verification.request',
txn_id: 'txn',
},
};
}
return [];
});

const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' });
await crypto.requestDeviceVerification('@me:e.org', 'OTHER');

expect(warnVerification).toHaveBeenCalledWith(
'The verification request reaches no device',
expect.objectContaining({ flowId: '$f' })
);
});

it('stays quiet when the request has a recipient', async () => {
const { mx } = clientSpy();
mockInvoke.mockImplementation(async (_identity, method) => {
if (method === 'queryKeysForUsers') {
return { id: 'q1', type: 1, className: 'KeysQueryRequest', body: '{}' };
}
if (method === 'device.requestVerification') {
return {
request: requestState,
outgoingRequest: {
id: 'txn',
type: 3,
body: JSON.stringify({ messages: { '@me:e.org': { OTHER: {} } } }),
event_type: 'm.key.verification.request',
txn_id: 'txn',
},
};
}
return [];
});

const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' });
await crypto.requestDeviceVerification('@me:e.org', 'OTHER');

expect(warnVerification).not.toHaveBeenCalled();
expect(traceVerification).toHaveBeenCalledWith(
'Sending a verification request',
expect.objectContaining({ recipientDevices: 1 })
);
});
});

describe('pending verification request sweep', () => {
beforeEach(() => mockInvoke.mockReset());

Expand Down
17 changes: 15 additions & 2 deletions src/app/utils/debugLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,22 @@ class DebugLoggerService {
message,
data,
};
// Omit arbitrary data before serialization; it may be circular or contain BigInts.
// Arbitrary data may be circular or contain BigInts, so only primitives survive, and
// they go through the sanitizer with the rest of the entry.
const primitives: Record<string, string | number | boolean> = {};
if (data && typeof data === 'object' && !(data instanceof Error)) {
Object.entries(data as Record<string, unknown>).forEach(([key, value]) => {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
primitives[key] = value;
} else if (value instanceof Error) {
primitives[key] = value.message;
}
});
}
const sanitized = sanitizeDiagnosticsLogs(
JSON.stringify({ logs: [{ ...rawEntry, data: undefined }] })
JSON.stringify({
logs: [{ ...rawEntry, data: Object.keys(primitives).length > 0 ? primitives : undefined }],
})
);
if (!sanitized) return;
const parsed = JSON.parse(sanitized) as { logs?: LogEntry[] };
Expand Down
Loading