diff --git a/src/app/components/DeviceVerification.tsx b/src/app/components/DeviceVerification.tsx index c26bee3ee..91c8db0fd 100644 --- a/src/app/components/DeviceVerification.tsx +++ b/src/app/components/DeviceVerification.tsx @@ -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 = { @@ -89,6 +91,8 @@ function VerificationWaitStart() { ); } +const PENDING_REQUEST_POLL_MS = 2000; + type VerificationStartProps = { onStart: () => Promise; }; @@ -309,10 +313,27 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps) } export function ReceiveSelfDeviceVerification() { + const mx = useMatrixClient(); const [request, setRequest] = useState(); 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); }, []); diff --git a/src/app/components/ReceiveSelfDeviceVerification.test.tsx b/src/app/components/ReceiveSelfDeviceVerification.test.tsx new file mode 100644 index 000000000..799d122fa --- /dev/null +++ b/src/app/components/ReceiveSelfDeviceVerification.test.tsx @@ -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 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 }) =>
{children}
, +})); + +const pendingRequest = { + isSelfVerification: true, + initiatedByMe: false, + pending: true, + phase: 1, + on: vi.fn<() => void>(), + removeListener: vi.fn<() => void>(), +}; + +const renderReceiver = () => + render( + + + + ); + +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(); + }); +}); diff --git a/src/app/crypto/engineCrypto/EngineCrypto.ts b/src/app/crypto/engineCrypto/EngineCrypto.ts index f49237c4d..d1a909e1e 100644 --- a/src/app/crypto/engineCrypto/EngineCrypto.ts +++ b/src/app/crypto/engineCrypto/EngineCrypto.ts @@ -240,6 +240,20 @@ type EngineDecryptedEvent = { forwarderDevice?: string | null; }; +const countRecipients = (body: string): number => { + try { + const messages = (JSON.parse(body) as { messages?: Record> }) + .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; @@ -604,7 +618,33 @@ export class EngineCrypto 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(); diff --git a/src/app/crypto/engineCrypto/incomingVerificationRequest.test.ts b/src/app/crypto/engineCrypto/incomingVerificationRequest.test.ts index d7976081c..bfc43fd90 100644 --- a/src/app/crypto/engineCrypto/incomingVerificationRequest.test.ts +++ b/src/app/crypto/engineCrypto/incomingVerificationRequest.test.ts @@ -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>(), })); @@ -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()); diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts index 5c0db8289..9c398ce57 100644 --- a/src/app/utils/debugLogger.ts +++ b/src/app/utils/debugLogger.ts @@ -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 = {}; + if (data && typeof data === 'object' && !(data instanceof Error)) { + Object.entries(data as Record).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[] };