Matrix: avoid stale verification SAS reuse

This commit is contained in:
Gustavo Madeira Santana
2026-03-13 00:16:54 +00:00
parent 2b7c013918
commit ada8d0eea2
2 changed files with 138 additions and 5 deletions

View File

@@ -28,6 +28,9 @@ function createHarness(params?: {
otherUserId: string;
updatedAt?: string;
completed?: boolean;
pending?: boolean;
phase?: number;
phaseName?: string;
sas?: {
decimal?: [number, number, number];
emoji?: Array<[string, string]>;
@@ -319,6 +322,117 @@ describe("registerMatrixMonitorEvents verification routing", () => {
expect(sasBodies).toHaveLength(1);
});
it("ignores cancelled verification flows when DM fallback resolves SAS notices", async () => {
const { sendMessage, roomEventListener } = createHarness({
joinedMembersByRoom: {
"!dm:example.org": ["@alice:example.org", "@bot:example.org"],
},
verifications: [
{
id: "verification-old-cancelled",
transactionId: "$old-flow",
otherUserId: "@alice:example.org",
updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(),
phaseName: "cancelled",
phase: 4,
pending: false,
sas: {
decimal: [1111, 2222, 3333],
emoji: [
["🚀", "Rocket"],
["🦋", "Butterfly"],
["📕", "Book"],
],
},
},
{
id: "verification-new-active",
transactionId: "$different-flow-id",
otherUserId: "@alice:example.org",
updatedAt: new Date("2026-02-25T21:43:54.000Z").toISOString(),
phaseName: "started",
phase: 3,
pending: true,
sas: {
decimal: [6158, 1986, 3513],
emoji: [
["🎁", "Gift"],
["🌍", "Globe"],
["🐴", "Horse"],
],
},
},
],
});
roomEventListener("!dm:example.org", {
event_id: "$start-active",
sender: "@alice:example.org",
type: "m.key.verification.start",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req-active" },
},
});
await vi.waitFor(() => {
const bodies = (sendMessage.mock.calls as unknown[][]).map((call) =>
String((call[1] as { body?: string } | undefined)?.body ?? ""),
);
expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true);
});
const bodies = (sendMessage.mock.calls as unknown[][]).map((call) =>
String((call[1] as { body?: string } | undefined)?.body ?? ""),
);
expect(bodies.some((body) => body.includes("SAS decimal: 1111 2222 3333"))).toBe(false);
});
it("does not emit SAS notices for cancelled verification events", async () => {
const { sendMessage, roomEventListener } = createHarness({
joinedMembersByRoom: {
"!dm:example.org": ["@alice:example.org", "@bot:example.org"],
},
verifications: [
{
id: "verification-cancelled",
transactionId: "$req-cancelled",
otherUserId: "@alice:example.org",
updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(),
phaseName: "cancelled",
phase: 4,
pending: false,
sas: {
decimal: [1111, 2222, 3333],
emoji: [
["🚀", "Rocket"],
["🦋", "Butterfly"],
["📕", "Book"],
],
},
},
],
});
roomEventListener("!dm:example.org", {
event_id: "$cancelled-1",
sender: "@alice:example.org",
type: "m.key.verification.cancel",
origin_server_ts: Date.now(),
content: {
code: "m.mismatched_sas",
reason: "The SAS did not match.",
"m.relates_to": { event_id: "$req-cancelled" },
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
const body = getSentNoticeBody(sendMessage, 0);
expect(body).toContain("Matrix verification cancelled by @alice:example.org");
expect(body).not.toContain("SAS decimal:");
});
it("warns once when encrypted events arrive without Matrix encryption enabled", () => {
const { logger, roomEventListener } = createHarness({
authEncryption: false,

View File

@@ -18,6 +18,9 @@ type MatrixVerificationSummaryLike = {
otherUserId: string;
updatedAt?: string;
completed?: boolean;
pending?: boolean;
phase?: number;
phaseName?: string;
sas?: {
decimal?: [number, number, number];
emoji?: Array<[string, string]>;
@@ -162,6 +165,19 @@ function resolveSummaryRecency(summary: MatrixVerificationSummaryLike): number {
return Number.isFinite(ts) ? ts : 0;
}
function isActiveVerificationSummary(summary: MatrixVerificationSummaryLike): boolean {
if (summary.completed === true) {
return false;
}
if (summary.phaseName === "cancelled" || summary.phaseName === "done") {
return false;
}
if (typeof summary.phase === "number" && summary.phase >= 4) {
return false;
}
return true;
}
async function resolveVerificationSummaryForSignal(
client: MatrixClient,
params: {
@@ -203,10 +219,10 @@ async function resolveVerificationSummaryForSignal(
}
// Fallback for DM 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;
const activeByUser = list
.filter((entry) => entry.otherUserId === params.senderId && isActiveVerificationSummary(entry))
.sort((a, b) => resolveSummaryRecency(b) - resolveSummaryRecency(a));
return activeByUser.length === 1 ? (activeByUser[0] ?? null) : null;
}
function trackBounded(set: Set<string>, value: string): boolean {
@@ -288,7 +304,10 @@ export function createMatrixVerificationEventRouter(params: {
senderId,
flowId,
}).catch(() => null);
const sasNotice = summary ? formatVerificationSasNotice(summary) : null;
const sasNotice =
summary && isActiveVerificationSummary(summary)
? formatVerificationSasNotice(summary)
: null;
const notices: string[] = [];
if (stageNotice) {