Matrix: harden verification session lifecycle and retry coverage

This commit is contained in:
gustavo
2026-02-08 15:43:55 -05:00
parent ba8e08186d
commit a7fb08e6bd
3 changed files with 305 additions and 0 deletions

View File

@@ -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<string, (...args: unknown[]) => 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<void>((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[] = [];

View File

@@ -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<void> = async () => {},
) {
super();
}
verify(): Promise<void> {
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<MockVerificationRequest>) {
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();
});
});

View File

@@ -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<string, MatrixVerificationSession>();
private verificationSessionCounter = 0;
private readonly trackedVerificationRequests = new WeakSet<object>();
private readonly trackedVerificationVerifiers = new WeakSet<object>();
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),
);