mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
Matrix: harden verification session lifecycle and retry coverage
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
170
extensions/matrix/src/matrix/sdk/verification-manager.test.ts
Normal file
170
extensions/matrix/src/matrix/sdk/verification-manager.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user