From a7fb08e6bdff86226711b0272b7185d37ab3d8b8 Mon Sep 17 00:00:00 2001 From: gustavo Date: Sun, 8 Feb 2026 15:43:55 -0500 Subject: [PATCH] Matrix: harden verification session lifecycle and retry coverage --- extensions/matrix/src/matrix/sdk.test.ts | 105 +++++++++++ .../matrix/sdk/verification-manager.test.ts | 170 ++++++++++++++++++ .../src/matrix/sdk/verification-manager.ts | 30 ++++ 3 files changed, 305 insertions(+) create mode 100644 extensions/matrix/src/matrix/sdk/verification-manager.test.ts diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 4d920e3834d..fcf7c874e97 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -448,6 +448,111 @@ describe("MatrixClient event bridge", () => { expect(delivered).toEqual(["m.room.message"]); }); + it("stops decryption retries after hitting retry cap", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token"); + const failed: string[] = []; + + client.on("room.failed_decryption", (_roomId, _event, error) => { + failed.push(error.message); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + + matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { + throw new Error("still missing key"); + }); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + expect(failed).toEqual(["missing room key"]); + + await vi.advanceTimersByTimeAsync(200_000); + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8); + + await vi.advanceTimersByTimeAsync(200_000); + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8); + }); + + it("does not start duplicate retries when crypto signals fire while retry is in-flight", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + const delivered: string[] = []; + const cryptoListeners = new Map void>(); + + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + cryptoListeners.set(eventName, listener); + }), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + })); + + client.on("room.message", (_roomId, event) => { + delivered.push(event.type); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + + let releaseRetry: (() => void) | null = null; + matrixJsClient.decryptEventIfNeeded = vi.fn( + async () => + await new Promise((resolve) => { + releaseRetry = () => { + encrypted.emit("decrypted", decrypted); + resolve(); + }; + }), + ); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached"); + expect(trigger).toBeTypeOf("function"); + trigger?.(); + trigger?.(); + await Promise.resolve(); + + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + releaseRetry?.(); + await Promise.resolve(); + expect(delivered).toEqual(["m.room.message"]); + }); + it("emits room.invite when a membership invite targets the current user", async () => { const client = new MatrixClient("https://matrix.example.org", "token"); const invites: string[] = []; diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts new file mode 100644 index 00000000000..1efd5dc2389 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts @@ -0,0 +1,170 @@ +import { VerificationPhase } from "matrix-js-sdk/lib/crypto-api/verification.js"; +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { + MatrixVerificationManager, + type MatrixShowQrCodeCallbacks, + type MatrixShowSasCallbacks, + type MatrixVerificationRequestLike, + type MatrixVerifierLike, +} from "./verification-manager.js"; + +class MockVerifier extends EventEmitter implements MatrixVerifierLike { + constructor( + private readonly sasCallbacks: MatrixShowSasCallbacks | null, + private readonly qrCallbacks: MatrixShowQrCodeCallbacks | null, + private readonly verifyImpl: () => Promise = async () => {}, + ) { + super(); + } + + verify(): Promise { + return this.verifyImpl(); + } + + cancel(_e: Error): void { + void _e; + } + + getShowSasCallbacks(): MatrixShowSasCallbacks | null { + return this.sasCallbacks; + } + + getReciprocateQrCodeCallbacks(): MatrixShowQrCodeCallbacks | null { + return this.qrCallbacks; + } +} + +class MockVerificationRequest extends EventEmitter implements MatrixVerificationRequestLike { + transactionId?: string; + roomId?: string; + initiatedByMe = false; + otherUserId = "@alice:example.org"; + otherDeviceId?: string; + isSelfVerification = false; + phase = VerificationPhase.Requested; + pending = true; + accepting = false; + declining = false; + methods: string[] = ["m.sas.v1"]; + chosenMethod?: string | null; + cancellationCode?: string | null; + verifier?: MatrixVerifierLike; + + constructor(init?: Partial) { + super(); + Object.assign(this, init); + } + + accept = vi.fn(async () => { + this.phase = VerificationPhase.Ready; + }); + + cancel = vi.fn(async () => { + this.phase = VerificationPhase.Cancelled; + }); + + startVerification = vi.fn(async (_method: string) => { + if (!this.verifier) { + throw new Error("verifier not configured"); + } + this.phase = VerificationPhase.Started; + return this.verifier; + }); + + scanQRCode = vi.fn(async (_qrCodeData: Uint8ClampedArray) => { + if (!this.verifier) { + throw new Error("verifier not configured"); + } + this.phase = VerificationPhase.Started; + return this.verifier; + }); + + generateQRCode = vi.fn(async () => new Uint8ClampedArray([1, 2, 3])); +} + +describe("MatrixVerificationManager", () => { + it("reuses the same tracked id for repeated transaction IDs", () => { + const manager = new MatrixVerificationManager(); + const first = new MockVerificationRequest({ + transactionId: "txn-1", + phase: VerificationPhase.Requested, + }); + const second = new MockVerificationRequest({ + transactionId: "txn-1", + phase: VerificationPhase.Ready, + pending: false, + chosenMethod: "m.sas.v1", + }); + + const firstSummary = manager.trackVerificationRequest(first); + const secondSummary = manager.trackVerificationRequest(second); + + expect(secondSummary.id).toBe(firstSummary.id); + expect(secondSummary.phase).toBe(VerificationPhase.Ready); + expect(secondSummary.pending).toBe(false); + expect(secondSummary.chosenMethod).toBe("m.sas.v1"); + }); + + it("starts SAS verification and exposes SAS payload/callback flow", async () => { + const confirm = vi.fn(async () => {}); + const mismatch = vi.fn(); + const verifier = new MockVerifier( + { + sas: { + decimal: [111, 222, 333], + emoji: [ + ["cat", "cat"], + ["dog", "dog"], + ["fox", "fox"], + ], + }, + confirm, + mismatch, + cancel: vi.fn(), + }, + null, + async () => {}, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-2", + verifier, + }); + const manager = new MatrixVerificationManager(); + const tracked = manager.trackVerificationRequest(request); + + const started = await manager.startVerification(tracked.id, "sas"); + expect(started.hasSas).toBe(true); + + const sas = manager.getVerificationSas(tracked.id); + expect(sas.decimal).toEqual([111, 222, 333]); + expect(sas.emoji?.length).toBe(3); + + await manager.confirmVerificationSas(tracked.id); + expect(confirm).toHaveBeenCalledTimes(1); + + manager.mismatchVerificationSas(tracked.id); + expect(mismatch).toHaveBeenCalledTimes(1); + }); + + it("prunes stale terminal sessions during list operations", () => { + const now = new Date("2026-02-08T15:00:00.000Z").getTime(); + const nowSpy = vi.spyOn(Date, "now"); + nowSpy.mockReturnValue(now); + + const manager = new MatrixVerificationManager(); + manager.trackVerificationRequest( + new MockVerificationRequest({ + transactionId: "txn-old-done", + phase: VerificationPhase.Done, + pending: false, + }), + ); + + nowSpy.mockReturnValue(now + 24 * 60 * 60 * 1000 + 1); + const summaries = manager.listVerifications(); + + expect(summaries).toHaveLength(0); + nowSpy.mockRestore(); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.ts b/extensions/matrix/src/matrix/sdk/verification-manager.ts index 517e1db2f95..a9a378aa0b1 100644 --- a/extensions/matrix/src/matrix/sdk/verification-manager.ts +++ b/extensions/matrix/src/matrix/sdk/verification-manager.ts @@ -100,12 +100,40 @@ type MatrixVerificationSession = { reciprocateQrCallbacks?: MatrixShowQrCodeCallbacks; }; +const MAX_TRACKED_VERIFICATION_SESSIONS = 256; +const TERMINAL_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000; + export class MatrixVerificationManager { private readonly verificationSessions = new Map(); private verificationSessionCounter = 0; private readonly trackedVerificationRequests = new WeakSet(); private readonly trackedVerificationVerifiers = new WeakSet(); + private pruneVerificationSessions(nowMs: number): void { + for (const [id, session] of this.verificationSessions) { + const phase = session.request.phase; + const isTerminal = phase === VerificationPhase.Done || phase === VerificationPhase.Cancelled; + if (isTerminal && nowMs - session.updatedAtMs > TERMINAL_SESSION_RETENTION_MS) { + this.verificationSessions.delete(id); + } + } + + if (this.verificationSessions.size <= MAX_TRACKED_VERIFICATION_SESSIONS) { + return; + } + + const sortedByAge = Array.from(this.verificationSessions.entries()).sort( + (a, b) => a[1].updatedAtMs - b[1].updatedAtMs, + ); + const overflow = this.verificationSessions.size - MAX_TRACKED_VERIFICATION_SESSIONS; + for (let i = 0; i < overflow; i += 1) { + const entry = sortedByAge[i]; + if (entry) { + this.verificationSessions.delete(entry[0]); + } + } + } + private getVerificationPhaseName(phase: number): string { switch (phase) { case VerificationPhase.Unsent: @@ -237,6 +265,7 @@ export class MatrixVerificationManager { } trackVerificationRequest(request: MatrixVerificationRequestLike): MatrixVerificationSummary { + this.pruneVerificationSessions(Date.now()); const txId = request.transactionId?.trim(); if (txId) { for (const existing of this.verificationSessions.values()) { @@ -284,6 +313,7 @@ export class MatrixVerificationManager { } listVerifications(): MatrixVerificationSummary[] { + this.pruneVerificationSessions(Date.now()); const summaries = Array.from(this.verificationSessions.values()).map((session) => this.buildVerificationSummary(session), );