Matrix: emit SAS notices from verification state

This commit is contained in:
Gustavo Madeira Santana
2026-03-13 10:51:13 +00:00
parent bef620babe
commit cdbe7380c9
7 changed files with 170 additions and 4 deletions

View File

@@ -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<void>;
type VerificationSummaryListener = (summary: MatrixVerificationSummary) => void;
function getSentNoticeBody(sendMessage: ReturnType<typeof vi.fn>, 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<{

View File

@@ -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";

View File

@@ -308,7 +308,40 @@ export function createMatrixVerificationEventRouter(params: {
const routedVerificationEvents = new Set<string>();
const routedVerificationSasFingerprints = new Set<string>();
return (roomId: string, event: MatrixRawEvent): boolean => {
async function routeVerificationSummary(summary: MatrixVerificationSummaryLike): Promise<void> {
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,
};
}

View File

@@ -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({

View File

@@ -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 = {

View File

@@ -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<typeof manager.listVerifications> = [];
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(

View File

@@ -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<object>();
private readonly trackedVerificationVerifiers = new WeakSet<object>();
private readonly summaryListeners = new Set<MatrixVerificationSummaryListener>();
private readRequestValue<T>(
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);
}