diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 47a89e17426..13dc0369276 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it, vi } from "vitest"; import type { CoreConfig } from "../../types.js"; import type { MatrixAuth } from "../client.js"; import type { MatrixClient } from "../sdk.js"; +import type { MatrixVerificationSummary } from "../sdk/verification-manager.js"; import { registerMatrixMonitorEvents } from "./events.js"; import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; type RoomEventListener = (roomId: string, event: MatrixRawEvent) => void; type FailedDecryptListener = (roomId: string, event: MatrixRawEvent, error: Error) => Promise; +type VerificationSummaryListener = (summary: MatrixVerificationSummary) => void; function getSentNoticeBody(sendMessage: ReturnType, index = 0): string { const calls = sendMessage.mock.calls as unknown[][]; @@ -108,6 +110,9 @@ function createHarness(params?: { failedDecryptListener: listeners.get("room.failed_decryption") as | FailedDecryptListener | undefined, + verificationSummaryListener: listeners.get("verification.summary") as + | VerificationSummaryListener + | undefined, }; } @@ -242,6 +247,50 @@ describe("registerMatrixMonitorEvents verification routing", () => { }); }); + it("posts SAS notices directly from verification summary updates", async () => { + const { sendMessage, verificationSummaryListener } = createHarness({ + joinedMembersByRoom: { + "!dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + }); + if (!verificationSummaryListener) { + throw new Error("verification.summary listener was not registered"); + } + + verificationSummaryListener({ + id: "verification-direct", + roomId: "!dm:example.org", + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: false, + phase: 3, + phaseName: "started", + pending: true, + methods: ["m.sas.v1"], + canAccept: false, + hasSas: true, + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["🎁", "Gift"], + ["🌍", "Globe"], + ["🐴", "Horse"], + ], + }, + hasReciprocateQr: false, + completed: false, + createdAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + updatedAt: new Date("2026-02-25T21:42:55.000Z").toISOString(), + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + const body = getSentNoticeBody(sendMessage, 0); + expect(body).toContain("Matrix verification SAS with @alice:example.org:"); + expect(body).toContain("SAS decimal: 6158 1986 3513"); + }); + it("retries SAS notice lookup when start arrives before SAS payload is available", async () => { vi.useFakeTimers(); const verifications: Array<{ diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index c767034036f..42b3167ad6a 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -56,7 +56,7 @@ export function registerMatrixMonitorEvents(params: { formatNativeDependencyHint, onRoomMessage, } = params; - const routeVerificationEvent = createMatrixVerificationEventRouter({ + const { routeVerificationEvent, routeVerificationSummary } = createMatrixVerificationEventRouter({ client, logVerboseMessage, }); @@ -106,6 +106,10 @@ export function registerMatrixMonitorEvents(params: { }, ); + client.on("verification.summary", (summary) => { + void routeVerificationSummary(summary); + }); + client.on("room.invite", (roomId: string, event: MatrixRawEvent) => { directTracker?.invalidateRoom(roomId); const eventId = event?.event_id ?? "unknown"; diff --git a/extensions/matrix/src/matrix/monitor/verification-events.ts b/extensions/matrix/src/matrix/monitor/verification-events.ts index 2c5eabcdaed..92ef6acf155 100644 --- a/extensions/matrix/src/matrix/monitor/verification-events.ts +++ b/extensions/matrix/src/matrix/monitor/verification-events.ts @@ -308,7 +308,40 @@ export function createMatrixVerificationEventRouter(params: { const routedVerificationEvents = new Set(); const routedVerificationSasFingerprints = new Set(); - return (roomId: string, event: MatrixRawEvent): boolean => { + async function routeVerificationSummary(summary: MatrixVerificationSummaryLike): Promise { + const roomId = trimMaybeString(summary.roomId); + if (!roomId || !isActiveVerificationSummary(summary)) { + return; + } + if ( + !(await isStrictDirectRoom({ + client: params.client, + roomId, + remoteUserId: summary.otherUserId, + })) + ) { + params.logVerboseMessage( + `matrix: ignoring verification summary outside strict DM room=${roomId} sender=${summary.otherUserId}`, + ); + return; + } + const sasNotice = formatVerificationSasNotice(summary); + if (!sasNotice) { + return; + } + const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`; + if (!trackBounded(routedVerificationSasFingerprints, sasFingerprint)) { + return; + } + await sendVerificationNotice({ + client: params.client, + roomId, + body: sasNotice, + logVerboseMessage: params.logVerboseMessage, + }); + } + + function routeVerificationEvent(roomId: string, event: MatrixRawEvent): boolean { const senderId = trimMaybeString(event?.sender); if (!senderId) { return false; @@ -373,5 +406,10 @@ export function createMatrixVerificationEventRouter(params: { }); return true; + } + + return { + routeVerificationEvent, + routeVerificationSummary, }; } diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index 0152e7999e5..b1fe8683b2b 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -29,7 +29,10 @@ import type { MatrixRawEvent, MessageEventContent, } from "./sdk/types.js"; -import { MatrixVerificationManager } from "./sdk/verification-manager.js"; +import { + MatrixVerificationManager, + type MatrixVerificationSummary, +} from "./sdk/verification-manager.js"; import { isMatrixDeviceOwnerVerified } from "./sdk/verification-status.js"; export { ConsoleLogger, LogService }; @@ -247,6 +250,9 @@ export class MatrixClient { recoveryKeyStore: this.recoveryKeyStore, decryptBridge: this.decryptBridge, }); + this.verificationManager.onSummaryChanged((summary: MatrixVerificationSummary) => { + this.emitter.emit("verification.summary", summary); + }); if (this.encryptionEnabled) { this.crypto = createMatrixCryptoFacade({ diff --git a/extensions/matrix/src/matrix/sdk/types.ts b/extensions/matrix/src/matrix/sdk/types.ts index b625308b356..037e954a2fc 100644 --- a/extensions/matrix/src/matrix/sdk/types.ts +++ b/extensions/matrix/src/matrix/sdk/types.ts @@ -1,4 +1,7 @@ -import type { MatrixVerificationRequestLike } from "./verification-manager.js"; +import type { + MatrixVerificationRequestLike, + MatrixVerificationSummary, +} from "./verification-manager.js"; export type MatrixRawEvent = { event_id: string; @@ -28,6 +31,7 @@ export type MatrixClientEventMap = { "room.failed_decryption": [roomId: string, event: MatrixRawEvent, error: Error]; "room.invite": [roomId: string, event: MatrixRawEvent]; "room.join": [roomId: string, event: MatrixRawEvent]; + "verification.summary": [summary: MatrixVerificationSummary]; }; export type EncryptedFile = { diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts index b4d583ba60b..16062b1b81e 100644 --- a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts +++ b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts @@ -209,6 +209,52 @@ describe("MatrixVerificationManager", () => { expect(manager.getVerificationSas(tracked.id).decimal).toEqual([6158, 1986, 3513]); }); + it("emits summary updates when SAS becomes available", async () => { + const verify = vi.fn(async () => {}); + const verifier = new MockVerifier( + { + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["gift", "Gift"], + ["globe", "Globe"], + ["horse", "Horse"], + ], + }, + confirm: vi.fn(async () => {}), + mismatch: vi.fn(), + cancel: vi.fn(), + }, + null, + verify, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-summary-listener", + roomId: "!dm:example.org", + verifier: undefined, + }); + const manager = new MatrixVerificationManager(); + const summaries: ReturnType = []; + manager.onSummaryChanged((summary) => { + summaries.push(summary); + }); + + manager.trackVerificationRequest(request); + request.verifier = verifier; + request.emit(VerificationRequestEvent.Change); + + await vi.waitFor(() => { + expect( + summaries.some( + (summary) => + summary.transactionId === "txn-summary-listener" && + summary.roomId === "!dm:example.org" && + summary.hasSas, + ), + ).toBe(true); + }); + }); + it("auto-starts inbound SAS when request becomes ready without a verifier", async () => { const verify = vi.fn(async () => {}); const verifier = new MockVerifier( diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.ts b/extensions/matrix/src/matrix/sdk/verification-manager.ts index 5010e44bbeb..8a90bcfee17 100644 --- a/extensions/matrix/src/matrix/sdk/verification-manager.ts +++ b/extensions/matrix/src/matrix/sdk/verification-manager.ts @@ -33,6 +33,8 @@ export type MatrixVerificationSummary = { updatedAt: string; }; +type MatrixVerificationSummaryListener = (summary: MatrixVerificationSummary) => void; + export type MatrixShowSasCallbacks = { sas: { decimal?: [number, number, number]; @@ -116,6 +118,7 @@ export class MatrixVerificationManager { private verificationSessionCounter = 0; private readonly trackedVerificationRequests = new WeakSet(); private readonly trackedVerificationVerifiers = new WeakSet(); + private readonly summaryListeners = new Set(); private readRequestValue( request: MatrixVerificationRequestLike, @@ -173,8 +176,16 @@ export class MatrixVerificationManager { } } + private emitVerificationSummary(session: MatrixVerificationSession): void { + const summary = this.buildVerificationSummary(session); + for (const listener of this.summaryListeners) { + listener(summary); + } + } + private touchVerificationSession(session: MatrixVerificationSession): void { session.updatedAtMs = Date.now(); + this.emitVerificationSummary(session); } private clearSasAutoConfirmTimer(session: MatrixVerificationSession): void { @@ -400,6 +411,13 @@ export class MatrixVerificationManager { }); } + onSummaryChanged(listener: MatrixVerificationSummaryListener): () => void { + this.summaryListeners.add(listener); + return () => { + this.summaryListeners.delete(listener); + }; + } + trackVerificationRequest(request: MatrixVerificationRequestLike): MatrixVerificationSummary { this.pruneVerificationSessions(Date.now()); const txId = this.readRequestValue(request, () => request.transactionId?.trim(), ""); @@ -441,6 +459,7 @@ export class MatrixVerificationManager { this.attachVerifierToVerificationSession(session, verifier); } this.maybeAutoStartInboundSas(session); + this.emitVerificationSummary(session); return this.buildVerificationSummary(session); }