From bd642ece962a5315e209ed248feebc82cdeff2b2 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 9 Mar 2026 04:34:10 -0400 Subject: [PATCH] Matrix: split monitor verification event routing --- .../matrix/src/matrix/monitor/events.test.ts | 85 ++++- .../matrix/src/matrix/monitor/events.ts | 292 +---------------- .../src/matrix/monitor/verification-events.ts | 294 ++++++++++++++++++ 3 files changed, 374 insertions(+), 297 deletions(-) create mode 100644 extensions/matrix/src/matrix/monitor/verification-events.ts diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 9c22d1aa09b..1287c9fd6e2 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -14,12 +14,12 @@ function getSentNoticeBody(sendMessage: ReturnType, index = 0): st } function createHarness(params?: { + authEncryption?: boolean; + cryptoAvailable?: boolean; verifications?: Array<{ id: string; transactionId?: string; - roomId?: string; otherUserId: string; - phaseName: string; updatedAt?: string; completed?: boolean; sas?: { @@ -32,25 +32,34 @@ function createHarness(params?: { const onRoomMessage = vi.fn(async () => {}); const listVerifications = vi.fn(async () => params?.verifications ?? []); const sendMessage = vi.fn(async () => "$notice"); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const formatNativeDependencyHint = vi.fn(() => "install hint"); const client = { on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { listeners.set(eventName, listener); return client; }), sendMessage, - crypto: { - listVerifications, - }, + ...(params?.cryptoAvailable === false + ? {} + : { + crypto: { + listVerifications, + }, + }), } as unknown as MatrixClient; registerMatrixMonitorEvents({ client, - auth: { accountId: "default", encryption: true } as MatrixAuth, + auth: { + accountId: "default", + encryption: params?.authEncryption ?? true, + } as MatrixAuth, logVerboseMessage: vi.fn(), warnedEncryptedRooms: new Set(), warnedCryptoMissingRooms: new Set(), - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - formatNativeDependencyHint: vi.fn(() => "install hint"), + logger, + formatNativeDependencyHint, onRoomMessage, }); @@ -64,6 +73,8 @@ function createHarness(params?: { sendMessage, roomEventListener, listVerifications, + logger, + formatNativeDependencyHint, roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined, }; } @@ -148,7 +159,6 @@ describe("registerMatrixMonitorEvents verification routing", () => { transactionId: "$different-flow-id", updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), otherUserId: "@alice:example.org", - phaseName: "started", sas: { decimal: [6158, 1986, 3513], emoji: [ @@ -187,7 +197,6 @@ describe("registerMatrixMonitorEvents verification routing", () => { id: "verification-3", transactionId: "$req3", otherUserId: "@alice:example.org", - phaseName: "started", sas: { decimal: [1111, 2222, 3333], emoji: [ @@ -231,4 +240,60 @@ describe("registerMatrixMonitorEvents verification routing", () => { .filter((body) => body.includes("SAS emoji:")); expect(sasBodies).toHaveLength(1); }); + + it("warns once when encrypted events arrive without Matrix encryption enabled", () => { + const { logger, roomEventListener } = createHarness({ + authEncryption: false, + }); + + roomEventListener("!room:example.org", { + event_id: "$enc1", + sender: "@alice:example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }); + roomEventListener("!room:example.org", { + event_id: "$enc2", + sender: "@alice:example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + "matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt", + { roomId: "!room:example.org" }, + ); + }); + + it("warns once when crypto bindings are unavailable for encrypted rooms", () => { + const { formatNativeDependencyHint, logger, roomEventListener } = createHarness({ + authEncryption: true, + cryptoAvailable: false, + }); + + roomEventListener("!room:example.org", { + event_id: "$enc1", + sender: "@alice:example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }); + roomEventListener("!room:example.org", { + event_id: "$enc2", + sender: "@alice:example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }); + + expect(formatNativeDependencyHint).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + "matrix: encryption enabled but crypto is unavailable; install hint", + { roomId: "!room:example.org" }, + ); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index 6ea81cdf7a8..d61916be60d 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -3,236 +3,7 @@ import type { MatrixAuth } from "../client.js"; import type { MatrixClient } from "../sdk.js"; import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; -import { - isMatrixVerificationEventType, - isMatrixVerificationRequestMsgType, - matrixVerificationConstants, -} from "./verification-utils.js"; - -const MAX_TRACKED_VERIFICATION_EVENTS = 1024; - -type MatrixVerificationStage = "request" | "ready" | "start" | "cancel" | "done" | "other"; - -type MatrixVerificationSummaryLike = { - id: string; - transactionId?: string; - roomId?: string; - otherUserId: string; - phaseName: string; - updatedAt?: string; - completed?: boolean; - sas?: { - decimal?: [number, number, number]; - emoji?: Array<[string, string]>; - }; -}; - -function trimMaybeString(input: unknown): string | null { - if (typeof input !== "string") { - return null; - } - const trimmed = input.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function readVerificationSignal(event: MatrixRawEvent): { - stage: MatrixVerificationStage; - flowId: string | null; -} | null { - const type = trimMaybeString(event?.type) ?? ""; - const content = event?.content ?? {}; - const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? ""; - const relatedEventId = trimMaybeString( - (content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"]?.event_id, - ); - const transactionId = trimMaybeString((content as { transaction_id?: unknown }).transaction_id); - if (type === EventType.RoomMessage && isMatrixVerificationRequestMsgType(msgtype)) { - return { - stage: "request", - flowId: trimMaybeString(event.event_id) ?? transactionId ?? relatedEventId, - }; - } - if (!isMatrixVerificationEventType(type)) { - return null; - } - const flowId = transactionId ?? relatedEventId ?? trimMaybeString(event.event_id); - if (type === `${matrixVerificationConstants.eventPrefix}request`) { - return { stage: "request", flowId }; - } - if (type === `${matrixVerificationConstants.eventPrefix}ready`) { - return { stage: "ready", flowId }; - } - if (type === "m.key.verification.start") { - return { stage: "start", flowId }; - } - if (type === "m.key.verification.cancel") { - return { stage: "cancel", flowId }; - } - if (type === "m.key.verification.done") { - return { stage: "done", flowId }; - } - return { stage: "other", flowId }; -} - -function formatVerificationStageNotice(params: { - stage: MatrixVerificationStage; - senderId: string; - event: MatrixRawEvent; -}): string | null { - const { stage, senderId, event } = params; - const content = event.content as { code?: unknown; reason?: unknown }; - switch (stage) { - case "request": - return `Matrix verification request received from ${senderId}. Open "Verify by emoji" in your Matrix client to continue.`; - case "ready": - return `Matrix verification is ready with ${senderId}. Choose "Verify by emoji" to reveal the emoji sequence.`; - case "start": - return `Matrix verification started with ${senderId}.`; - case "done": - return `Matrix verification completed with ${senderId}.`; - case "cancel": { - const code = trimMaybeString(content.code); - const reason = trimMaybeString(content.reason); - if (code && reason) { - return `Matrix verification cancelled by ${senderId} (${code}: ${reason}).`; - } - if (reason) { - return `Matrix verification cancelled by ${senderId} (${reason}).`; - } - return `Matrix verification cancelled by ${senderId}.`; - } - default: - return null; - } -} - -function formatVerificationSasNotice(summary: MatrixVerificationSummaryLike): string | null { - const sas = summary.sas; - if (!sas) { - return null; - } - const emojiLine = - Array.isArray(sas.emoji) && sas.emoji.length > 0 - ? `SAS emoji: ${sas.emoji - .map( - ([emoji, name]) => `${trimMaybeString(emoji) ?? "?"} ${trimMaybeString(name) ?? "?"}`, - ) - .join(" | ")}` - : null; - const decimalLine = - Array.isArray(sas.decimal) && sas.decimal.length === 3 - ? `SAS decimal: ${sas.decimal.join(" ")}` - : null; - if (!emojiLine && !decimalLine) { - return null; - } - const lines = [`Matrix verification SAS with ${summary.otherUserId}:`]; - if (emojiLine) { - lines.push(emojiLine); - } - if (decimalLine) { - lines.push(decimalLine); - } - lines.push("If both sides match, choose 'They match' in your Matrix app."); - return lines.join("\n"); -} - -function resolveVerificationFlowCandidates(params: { - event: MatrixRawEvent; - flowId: string | null; -}): string[] { - const { event, flowId } = params; - const content = event.content as { - transaction_id?: unknown; - "m.relates_to"?: { event_id?: unknown }; - }; - const candidates = new Set(); - const add = (value: unknown) => { - const normalized = trimMaybeString(value); - if (normalized) { - candidates.add(normalized); - } - }; - add(flowId); - add(event.event_id); - add(content.transaction_id); - add(content["m.relates_to"]?.event_id); - return Array.from(candidates); -} - -function resolveSummaryRecency(summary: MatrixVerificationSummaryLike): number { - const ts = Date.parse(summary.updatedAt ?? ""); - return Number.isFinite(ts) ? ts : 0; -} - -async function resolveVerificationSummaryForSignal( - client: MatrixClient, - params: { - event: MatrixRawEvent; - senderId: string; - flowId: string | null; - }, -): Promise { - if (!client.crypto) { - return null; - } - const list = await client.crypto.listVerifications(); - if (list.length === 0) { - return null; - } - const candidates = resolveVerificationFlowCandidates({ - event: params.event, - flowId: params.flowId, - }); - const byTransactionId = list.find((entry) => - candidates.some((candidate) => entry.transactionId === candidate), - ); - if (byTransactionId) { - return byTransactionId; - } - - // Fallback for flows where transaction IDs do not match room event IDs consistently. - const byUser = list - .filter((entry) => entry.otherUserId === params.senderId && entry.completed !== true) - .sort((a, b) => resolveSummaryRecency(b) - resolveSummaryRecency(a))[0]; - return byUser ?? null; -} - -function trackBounded(set: Set, value: string): boolean { - if (!value || set.has(value)) { - return false; - } - set.add(value); - if (set.size > MAX_TRACKED_VERIFICATION_EVENTS) { - const oldest = set.values().next().value; - if (typeof oldest === "string") { - set.delete(oldest); - } - } - return true; -} - -async function sendVerificationNotice(params: { - client: MatrixClient; - roomId: string; - body: string; - logVerboseMessage: (message: string) => void; -}): Promise { - const roomId = trimMaybeString(params.roomId); - if (!roomId) { - return; - } - try { - await params.client.sendMessage(roomId, { - msgtype: "m.notice", - body: params.body, - }); - } catch (err) { - params.logVerboseMessage( - `matrix: failed sending verification notice room=${roomId}: ${String(err)}`, - ); - } -} +import { createMatrixVerificationEventRouter } from "./verification-events.js"; export function registerMatrixMonitorEvents(params: { client: MatrixClient; @@ -254,63 +25,10 @@ export function registerMatrixMonitorEvents(params: { formatNativeDependencyHint, onRoomMessage, } = params; - const routedVerificationEvents = new Set(); - const routedVerificationSasFingerprints = new Set(); - - const routeVerificationEvent = (roomId: string, event: MatrixRawEvent): boolean => { - const senderId = trimMaybeString(event?.sender); - if (!senderId) { - return false; - } - const signal = readVerificationSignal(event); - if (!signal) { - return false; - } - - void (async () => { - const flowId = signal.flowId; - const sourceEventId = trimMaybeString(event?.event_id); - const sourceFingerprint = sourceEventId ?? `${senderId}:${event.type}:${flowId ?? "none"}`; - if (!trackBounded(routedVerificationEvents, sourceFingerprint)) { - return; - } - - const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event }); - const summary = await resolveVerificationSummaryForSignal(client, { - event, - senderId, - flowId, - }).catch(() => null); - const sasNotice = summary ? formatVerificationSasNotice(summary) : null; - - const notices: string[] = []; - if (stageNotice) { - notices.push(stageNotice); - } - if (summary && sasNotice) { - const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`; - if (trackBounded(routedVerificationSasFingerprints, sasFingerprint)) { - notices.push(sasNotice); - } - } - if (notices.length === 0) { - return; - } - - for (const body of notices) { - await sendVerificationNotice({ - client, - roomId, - body, - logVerboseMessage, - }); - } - })().catch((err) => { - logVerboseMessage(`matrix: failed routing verification event: ${String(err)}`); - }); - - return true; - }; + const routeVerificationEvent = createMatrixVerificationEventRouter({ + client, + logVerboseMessage, + }); client.on("room.message", (roomId: string, event: MatrixRawEvent) => { if (routeVerificationEvent(roomId, event)) { diff --git a/extensions/matrix/src/matrix/monitor/verification-events.ts b/extensions/matrix/src/matrix/monitor/verification-events.ts new file mode 100644 index 00000000000..4eace5f11d1 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/verification-events.ts @@ -0,0 +1,294 @@ +import type { MatrixClient } from "../sdk.js"; +import type { MatrixRawEvent } from "./types.js"; +import { EventType } from "./types.js"; +import { + isMatrixVerificationEventType, + isMatrixVerificationRequestMsgType, + matrixVerificationConstants, +} from "./verification-utils.js"; + +const MAX_TRACKED_VERIFICATION_EVENTS = 1024; + +type MatrixVerificationStage = "request" | "ready" | "start" | "cancel" | "done" | "other"; + +type MatrixVerificationSummaryLike = { + id: string; + transactionId?: string; + otherUserId: string; + updatedAt?: string; + completed?: boolean; + sas?: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + }; +}; + +function trimMaybeString(input: unknown): string | null { + if (typeof input !== "string") { + return null; + } + const trimmed = input.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function readVerificationSignal(event: MatrixRawEvent): { + stage: MatrixVerificationStage; + flowId: string | null; +} | null { + const type = trimMaybeString(event?.type) ?? ""; + const content = event?.content ?? {}; + const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? ""; + const relatedEventId = trimMaybeString( + (content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"]?.event_id, + ); + const transactionId = trimMaybeString((content as { transaction_id?: unknown }).transaction_id); + if (type === EventType.RoomMessage && isMatrixVerificationRequestMsgType(msgtype)) { + return { + stage: "request", + flowId: trimMaybeString(event.event_id) ?? transactionId ?? relatedEventId, + }; + } + if (!isMatrixVerificationEventType(type)) { + return null; + } + const flowId = transactionId ?? relatedEventId ?? trimMaybeString(event.event_id); + if (type === `${matrixVerificationConstants.eventPrefix}request`) { + return { stage: "request", flowId }; + } + if (type === `${matrixVerificationConstants.eventPrefix}ready`) { + return { stage: "ready", flowId }; + } + if (type === "m.key.verification.start") { + return { stage: "start", flowId }; + } + if (type === "m.key.verification.cancel") { + return { stage: "cancel", flowId }; + } + if (type === "m.key.verification.done") { + return { stage: "done", flowId }; + } + return { stage: "other", flowId }; +} + +function formatVerificationStageNotice(params: { + stage: MatrixVerificationStage; + senderId: string; + event: MatrixRawEvent; +}): string | null { + const { stage, senderId, event } = params; + const content = event.content as { code?: unknown; reason?: unknown }; + switch (stage) { + case "request": + return `Matrix verification request received from ${senderId}. Open "Verify by emoji" in your Matrix client to continue.`; + case "ready": + return `Matrix verification is ready with ${senderId}. Choose "Verify by emoji" to reveal the emoji sequence.`; + case "start": + return `Matrix verification started with ${senderId}.`; + case "done": + return `Matrix verification completed with ${senderId}.`; + case "cancel": { + const code = trimMaybeString(content.code); + const reason = trimMaybeString(content.reason); + if (code && reason) { + return `Matrix verification cancelled by ${senderId} (${code}: ${reason}).`; + } + if (reason) { + return `Matrix verification cancelled by ${senderId} (${reason}).`; + } + return `Matrix verification cancelled by ${senderId}.`; + } + default: + return null; + } +} + +function formatVerificationSasNotice(summary: MatrixVerificationSummaryLike): string | null { + const sas = summary.sas; + if (!sas) { + return null; + } + const emojiLine = + Array.isArray(sas.emoji) && sas.emoji.length > 0 + ? `SAS emoji: ${sas.emoji + .map( + ([emoji, name]) => `${trimMaybeString(emoji) ?? "?"} ${trimMaybeString(name) ?? "?"}`, + ) + .join(" | ")}` + : null; + const decimalLine = + Array.isArray(sas.decimal) && sas.decimal.length === 3 + ? `SAS decimal: ${sas.decimal.join(" ")}` + : null; + if (!emojiLine && !decimalLine) { + return null; + } + const lines = [`Matrix verification SAS with ${summary.otherUserId}:`]; + if (emojiLine) { + lines.push(emojiLine); + } + if (decimalLine) { + lines.push(decimalLine); + } + lines.push("If both sides match, choose 'They match' in your Matrix app."); + return lines.join("\n"); +} + +function resolveVerificationFlowCandidates(params: { + event: MatrixRawEvent; + flowId: string | null; +}): string[] { + const { event, flowId } = params; + const content = event.content as { + transaction_id?: unknown; + "m.relates_to"?: { event_id?: unknown }; + }; + const candidates = new Set(); + const add = (value: unknown) => { + const normalized = trimMaybeString(value); + if (normalized) { + candidates.add(normalized); + } + }; + add(flowId); + add(event.event_id); + add(content.transaction_id); + add(content["m.relates_to"]?.event_id); + return Array.from(candidates); +} + +function resolveSummaryRecency(summary: MatrixVerificationSummaryLike): number { + const ts = Date.parse(summary.updatedAt ?? ""); + return Number.isFinite(ts) ? ts : 0; +} + +async function resolveVerificationSummaryForSignal( + client: MatrixClient, + params: { + event: MatrixRawEvent; + senderId: string; + flowId: string | null; + }, +): Promise { + if (!client.crypto) { + return null; + } + const list = await client.crypto.listVerifications(); + if (list.length === 0) { + return null; + } + const candidates = resolveVerificationFlowCandidates({ + event: params.event, + flowId: params.flowId, + }); + const byTransactionId = list.find((entry) => + candidates.some((candidate) => entry.transactionId === candidate), + ); + if (byTransactionId) { + return byTransactionId; + } + + // Fallback for flows where transaction IDs do not match room event IDs consistently. + const byUser = list + .filter((entry) => entry.otherUserId === params.senderId && entry.completed !== true) + .sort((a, b) => resolveSummaryRecency(b) - resolveSummaryRecency(a))[0]; + return byUser ?? null; +} + +function trackBounded(set: Set, value: string): boolean { + if (!value || set.has(value)) { + return false; + } + set.add(value); + if (set.size > MAX_TRACKED_VERIFICATION_EVENTS) { + const oldest = set.values().next().value; + if (typeof oldest === "string") { + set.delete(oldest); + } + } + return true; +} + +async function sendVerificationNotice(params: { + client: MatrixClient; + roomId: string; + body: string; + logVerboseMessage: (message: string) => void; +}): Promise { + const roomId = trimMaybeString(params.roomId); + if (!roomId) { + return; + } + try { + await params.client.sendMessage(roomId, { + msgtype: "m.notice", + body: params.body, + }); + } catch (err) { + params.logVerboseMessage( + `matrix: failed sending verification notice room=${roomId}: ${String(err)}`, + ); + } +} + +export function createMatrixVerificationEventRouter(params: { + client: MatrixClient; + logVerboseMessage: (message: string) => void; +}) { + const routedVerificationEvents = new Set(); + const routedVerificationSasFingerprints = new Set(); + + return (roomId: string, event: MatrixRawEvent): boolean => { + const senderId = trimMaybeString(event?.sender); + if (!senderId) { + return false; + } + const signal = readVerificationSignal(event); + if (!signal) { + return false; + } + + void (async () => { + const flowId = signal.flowId; + const sourceEventId = trimMaybeString(event?.event_id); + const sourceFingerprint = sourceEventId ?? `${senderId}:${event.type}:${flowId ?? "none"}`; + if (!trackBounded(routedVerificationEvents, sourceFingerprint)) { + return; + } + + const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event }); + const summary = await resolveVerificationSummaryForSignal(params.client, { + event, + senderId, + flowId, + }).catch(() => null); + const sasNotice = summary ? formatVerificationSasNotice(summary) : null; + + const notices: string[] = []; + if (stageNotice) { + notices.push(stageNotice); + } + if (summary && sasNotice) { + const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`; + if (trackBounded(routedVerificationSasFingerprints, sasFingerprint)) { + notices.push(sasNotice); + } + } + if (notices.length === 0) { + return; + } + + for (const body of notices) { + await sendVerificationNotice({ + client: params.client, + roomId, + body, + logVerboseMessage: params.logVerboseMessage, + }); + } + })().catch((err) => { + params.logVerboseMessage(`matrix: failed routing verification event: ${String(err)}`); + }); + + return true; + }; +}