Matrix: split monitor verification event routing

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 04:34:10 -04:00
parent 7688513698
commit bd642ece96
3 changed files with 374 additions and 297 deletions

View File

@@ -14,12 +14,12 @@ function getSentNoticeBody(sendMessage: ReturnType<typeof vi.fn>, 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<string>(),
warnedCryptoMissingRooms: new Set<string>(),
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" },
);
});
});

View File

@@ -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<string>();
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<MatrixVerificationSummaryLike | null> {
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<string>, 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<void> {
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<string>();
const routedVerificationSasFingerprints = new Set<string>();
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)) {

View File

@@ -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<string>();
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<MatrixVerificationSummaryLike | null> {
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<string>, 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<void> {
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<string>();
const routedVerificationSasFingerprints = new Set<string>();
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;
};
}