Require full Matrix identity trust (#70401)

Merged via squash.

Prepared head SHA: d13a729681
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-24 17:58:57 -04:00
committed by GitHub
parent 0cce4cf8f6
commit 72731a37d2
26 changed files with 4917 additions and 218 deletions

View File

@@ -4,10 +4,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerMatrixCli, resetMatrixCliStateForTests } from "./cli.js";
const bootstrapMatrixVerificationMock = vi.fn();
const acceptMatrixVerificationMock = vi.fn();
const cancelMatrixVerificationMock = vi.fn();
const confirmMatrixVerificationSasMock = vi.fn();
const getMatrixRoomKeyBackupStatusMock = vi.fn();
const getMatrixVerificationSasMock = vi.fn();
const getMatrixVerificationStatusMock = vi.fn();
const listMatrixOwnDevicesMock = vi.fn();
const listMatrixVerificationsMock = vi.fn();
const mismatchMatrixVerificationSasMock = vi.fn();
const pruneMatrixStaleGatewayDevicesMock = vi.fn();
const requestMatrixVerificationMock = vi.fn();
const resolveMatrixAccountConfigMock = vi.fn();
const resolveMatrixAccountMock = vi.fn();
const resolveMatrixAuthContextMock = vi.fn();
@@ -17,19 +24,31 @@ const matrixRuntimeLoadConfigMock = vi.fn();
const matrixRuntimeWriteConfigFileMock = vi.fn();
const resetMatrixRoomKeyBackupMock = vi.fn();
const restoreMatrixRoomKeyBackupMock = vi.fn();
const runMatrixSelfVerificationMock = vi.fn();
const setMatrixSdkConsoleLoggingMock = vi.fn();
const setMatrixSdkLogModeMock = vi.fn();
const startMatrixVerificationMock = vi.fn();
const updateMatrixOwnProfileMock = vi.fn();
const verifyMatrixRecoveryKeyMock = vi.fn();
const consoleLogMock = vi.fn();
const consoleErrorMock = vi.fn();
const stdoutWriteMock = vi.fn();
vi.mock("./matrix/actions/verification.js", () => ({
acceptMatrixVerification: (...args: unknown[]) => acceptMatrixVerificationMock(...args),
bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args),
cancelMatrixVerification: (...args: unknown[]) => cancelMatrixVerificationMock(...args),
confirmMatrixVerificationSas: (...args: unknown[]) => confirmMatrixVerificationSasMock(...args),
getMatrixRoomKeyBackupStatus: (...args: unknown[]) => getMatrixRoomKeyBackupStatusMock(...args),
getMatrixVerificationSas: (...args: unknown[]) => getMatrixVerificationSasMock(...args),
getMatrixVerificationStatus: (...args: unknown[]) => getMatrixVerificationStatusMock(...args),
listMatrixVerifications: (...args: unknown[]) => listMatrixVerificationsMock(...args),
mismatchMatrixVerificationSas: (...args: unknown[]) => mismatchMatrixVerificationSasMock(...args),
requestMatrixVerification: (...args: unknown[]) => requestMatrixVerificationMock(...args),
resetMatrixRoomKeyBackup: (...args: unknown[]) => resetMatrixRoomKeyBackupMock(...args),
restoreMatrixRoomKeyBackup: (...args: unknown[]) => restoreMatrixRoomKeyBackupMock(...args),
runMatrixSelfVerification: (...args: unknown[]) => runMatrixSelfVerificationMock(...args),
startMatrixVerification: (...args: unknown[]) => startMatrixVerificationMock(...args),
verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args),
}));
@@ -110,6 +129,27 @@ function mockMatrixVerificationStatus(params: {
});
}
function mockMatrixVerificationSummary(overrides: Record<string, unknown> = {}) {
return {
id: "self-1",
transactionId: "txn-1",
otherUserId: "@bot:example.org",
otherDeviceId: "PHONE123",
isSelfVerification: true,
initiatedByMe: true,
phaseName: "started",
pending: true,
methods: ["m.sas.v1"],
chosenMethod: "m.sas.v1",
hasSas: true,
sas: {
decimal: [1234, 5678, 9012],
},
completed: false,
...overrides,
};
}
describe("matrix CLI verification commands", () => {
beforeEach(() => {
resetMatrixCliStateForTests();
@@ -119,8 +159,13 @@ describe("matrix CLI verification commands", () => {
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) =>
consoleErrorMock(...args),
);
vi.spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => {
stdoutWriteMock(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
return true;
}) as typeof process.stdout.write);
consoleLogMock.mockReset();
consoleErrorMock.mockReset();
stdoutWriteMock.mockReset();
matrixSetupValidateInputMock.mockReturnValue(null);
matrixSetupApplyAccountConfigMock.mockImplementation(({ cfg }: { cfg: unknown }) => cfg);
matrixRuntimeLoadConfigMock.mockReturnValue({});
@@ -200,6 +245,466 @@ describe("matrix CLI verification commands", () => {
expect(process.exitCode).toBe(1);
});
it("prints recovery-key and identity-trust diagnostics for device verification failures", async () => {
verifyMatrixRecoveryKeyMock.mockResolvedValue({
success: false,
error:
"Matrix recovery key was applied, but this device still lacks full Matrix identity trust.",
encryptionEnabled: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "7",
backup: {
serverVersion: "7",
activeVersion: "7",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
keyLoadAttempted: true,
keyLoadError: null,
},
verified: false,
localVerified: true,
crossSigningVerified: false,
signedByOwner: false,
recoveryKeyAccepted: true,
backupUsable: true,
deviceOwnerVerified: false,
recoveryKeyStored: true,
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
});
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "device", "valid-key"], {
from: "user",
});
expect(process.exitCode).toBe(1);
expect(consoleErrorMock).toHaveBeenCalledWith(
"Verification failed: Matrix recovery key was applied, but this device still lacks full Matrix identity trust.",
);
expect(consoleLogMock).toHaveBeenCalledWith("Recovery key accepted: yes");
expect(consoleLogMock).toHaveBeenCalledWith("Backup usable: yes");
expect(consoleLogMock).toHaveBeenCalledWith("Device verified by owner: no");
expect(consoleLogMock).toHaveBeenCalledWith("Backup: active and trusted on this device");
expect(consoleLogMock).toHaveBeenCalledWith(
"- Recovery key can unlock the room-key backup, but full Matrix identity trust is still incomplete. Run openclaw matrix verify self and follow the prompts from another Matrix client.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- If you intend to replace the current cross-signing identity, run openclaw matrix verify bootstrap --recovery-key '<key>' --force-reset-cross-signing.",
);
});
it("runs interactive Matrix self-verification in one CLI flow", async () => {
runMatrixSelfVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
completed: true,
deviceOwnerVerified: true,
ownerVerification: {
backup: {
activeVersion: "1",
decryptionKeyCached: true,
keyLoadAttempted: false,
keyLoadError: null,
matchesDecryptionKey: true,
serverVersion: "1",
trusted: true,
},
backupVersion: "1",
crossSigningVerified: true,
deviceId: "DEVICE123",
localVerified: true,
recoveryKeyCreatedAt: null,
recoveryKeyId: null,
recoveryKeyStored: true,
signedByOwner: true,
userId: "@bot:example.org",
verified: true,
},
pending: false,
phaseName: "done",
}),
);
const program = buildProgram();
await program.parseAsync(
["matrix", "verify", "self", "--account", "ops", "--timeout-ms", "5000"],
{
from: "user",
},
);
expect(runMatrixSelfVerificationMock).toHaveBeenCalledWith({
accountId: "ops",
cfg: {},
timeoutMs: 5000,
onRequested: expect.any(Function),
onReady: expect.any(Function),
onSas: expect.any(Function),
confirmSas: expect.any(Function),
});
expect(consoleLogMock).toHaveBeenCalledWith("Self-verification complete.");
expect(consoleLogMock).toHaveBeenCalledWith("Device verified by owner: yes");
expect(consoleLogMock).toHaveBeenCalledWith("Cross-signing verified: yes");
expect(consoleLogMock).toHaveBeenCalledWith("Signed by owner: yes");
expect(consoleLogMock).toHaveBeenCalledWith("Backup: active and trusted on this device");
});
it("requests Matrix self-verification and prints the follow-up SAS commands", async () => {
requestMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
id: "self-verify-1",
hasSas: false,
sas: undefined,
}),
);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "request", "--own-user", "--account", "ops"], {
from: "user",
});
expect(requestMatrixVerificationMock).toHaveBeenCalledWith({
accountId: "ops",
cfg: {},
ownUser: true,
userId: undefined,
deviceId: undefined,
roomId: undefined,
});
expect(consoleLogMock).toHaveBeenCalledWith("Verification id: self-verify-1");
expect(consoleLogMock).toHaveBeenCalledWith("Transaction id: txn-1");
expect(consoleLogMock).toHaveBeenCalledWith(
"- Accept the verification request in another Matrix client for this account.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Then run openclaw matrix verify start --account ops -- txn-1 to start SAS verification.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify sas --account ops -- txn-1 to display the SAS emoji or decimals.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- When the SAS matches, run openclaw matrix verify confirm-sas --account ops -- txn-1.",
);
});
it("prints DM lookup details in Matrix verification follow-up commands", async () => {
requestMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
id: "dm-verify-1",
transactionId: "txn-dm",
roomId: "!room-'$(x):example.org",
otherUserId: "@alice:example.org",
isSelfVerification: false,
hasSas: false,
sas: undefined,
}),
);
const program = buildProgram();
await program.parseAsync(
[
"matrix",
"verify",
"request",
"--user-id",
"@alice:example.org",
"--room-id",
"!room-'$(x):example.org",
],
{ from: "user" },
);
expect(requestMatrixVerificationMock).toHaveBeenCalledWith({
accountId: "default",
cfg: {},
ownUser: undefined,
userId: "@alice:example.org",
deviceId: undefined,
roomId: "!room-'$(x):example.org",
});
expect(consoleLogMock).toHaveBeenCalledWith("Room id: !room-'$(x):example.org");
expect(consoleLogMock).toHaveBeenCalledWith(
"- Then run openclaw matrix verify start --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm to start SAS verification.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify sas --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm to display the SAS emoji or decimals.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- When the SAS matches, run openclaw matrix verify confirm-sas --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm.",
);
});
it("terminates options before remote Matrix verification ids in follow-up commands", async () => {
requestMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
id: "local-id",
transactionId: "--account=evil",
hasSas: false,
sas: undefined,
}),
);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "request", "--own-user", "--account", "ops"], {
from: "user",
});
expect(consoleLogMock).toHaveBeenCalledWith(
"- Then run openclaw matrix verify start --account ops -- --account=evil to start SAS verification.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify sas --account ops -- --account=evil to display the SAS emoji or decimals.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- When the SAS matches, run openclaw matrix verify confirm-sas --account ops -- --account=evil.",
);
});
it("rejects ambiguous Matrix verification request targets", async () => {
const program = buildProgram();
await program.parseAsync(
["matrix", "verify", "request", "--own-user", "--user-id", "@other:example.org"],
{ from: "user" },
);
expect(process.exitCode).toBe(1);
expect(requestMatrixVerificationMock).not.toHaveBeenCalled();
expect(consoleErrorMock).toHaveBeenCalledWith(
"Verification request failed: --own-user cannot be combined with --user-id, --device-id, or --room-id",
);
});
it("lists Matrix verification requests", async () => {
listMatrixVerificationsMock.mockResolvedValue([
mockMatrixVerificationSummary({ id: "incoming-1", initiatedByMe: false }),
]);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "list"], { from: "user" });
expect(listMatrixVerificationsMock).toHaveBeenCalledWith({ accountId: "default", cfg: {} });
expect(consoleLogMock).toHaveBeenCalledWith("Verification id: incoming-1");
expect(consoleLogMock).toHaveBeenCalledWith("Initiated by OpenClaw: no");
});
it("sanitizes remote Matrix verification metadata before printing it", async () => {
listMatrixVerificationsMock.mockResolvedValue([
mockMatrixVerificationSummary({
id: "self-\u001B[31m1",
transactionId: "txn-\n\u009B31m1",
otherUserId: "@bot\u001B[2J\u009Dspoof\u0007:example.org",
otherDeviceId: "PHONE\r\u009B2J123",
phaseName: "started\u001B[0m",
methods: ["m.sas.v1\n\u009B31mspoof"],
chosenMethod: "m.sas.v1\u001B[1m",
sas: {
emoji: [
["🐶", "Dog\u001B[31m\u009B2J"],
["🐱", "Cat\n\u009B31mspoof"],
],
},
error: "Remote\u001B[31m cancelled\n\u009B31mforged",
}),
]);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "list"], { from: "user" });
expect(consoleLogMock).toHaveBeenCalledWith("Verification id: self-1");
expect(consoleLogMock).toHaveBeenCalledWith("Transaction id: txn-1");
expect(consoleLogMock).toHaveBeenCalledWith("Other user: @bot:example.org");
expect(consoleLogMock).toHaveBeenCalledWith("Other device: PHONE123");
expect(consoleLogMock).toHaveBeenCalledWith("Phase: started");
expect(consoleLogMock).toHaveBeenCalledWith("Methods: m.sas.v1spoof");
expect(consoleLogMock).toHaveBeenCalledWith("Chosen method: m.sas.v1");
expect(consoleLogMock).toHaveBeenCalledWith("SAS emoji: 🐶 Dog | 🐱 Catspoof");
expect(consoleLogMock).toHaveBeenCalledWith("Verification error: Remote cancelledforged");
});
it("sanitizes remote Matrix status metadata before printing diagnostics", async () => {
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: false,
userId: "@bot\u001B[2J:example.org",
deviceId: "PHONE\r\u009B2J123",
backupVersion: "1\u001B[31m",
backup: {
serverVersion: "2\u001B[31m",
activeVersion: "1\u009B2J",
trusted: false,
matchesDecryptionKey: false,
decryptionKeyCached: false,
keyLoadAttempted: true,
keyLoadError: "Remote\n\u009B31mforged",
},
recoveryKeyStored: false,
recoveryKeyCreatedAt: null,
pendingVerifications: 0,
});
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "status", "--verbose"], { from: "user" });
expect(consoleLogMock).toHaveBeenCalledWith("User: @bot:example.org");
expect(consoleLogMock).toHaveBeenCalledWith("Device: PHONE123");
expect(consoleLogMock).toHaveBeenCalledWith("Backup server version: 2");
expect(consoleLogMock).toHaveBeenCalledWith("Backup active on this device: 1");
expect(consoleLogMock).toHaveBeenCalledWith("Backup key load error: Remoteforged");
});
it("shell-quotes Matrix verification ids in follow-up command guidance", async () => {
requestMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
id: "self-verify-1",
transactionId: "txn-'$(touch /tmp/pwn)",
}),
);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "request", "--own-user"], {
from: "user",
});
expect(consoleLogMock).toHaveBeenCalledWith(
"- Then run openclaw matrix verify start -- 'txn-'\\''$(touch /tmp/pwn)' to start SAS verification.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify sas -- 'txn-'\\''$(touch /tmp/pwn)' to display the SAS emoji or decimals.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- When the SAS matches, run openclaw matrix verify confirm-sas -- 'txn-'\\''$(touch /tmp/pwn)'.",
);
});
it("shows Matrix SAS diagnostics and confirm/mismatch guidance", async () => {
getMatrixVerificationSasMock.mockResolvedValue({
decimal: [1234, 5678, 9012],
});
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "sas", "self-1"], { from: "user" });
expect(getMatrixVerificationSasMock).toHaveBeenCalledWith("self-1", {
accountId: "default",
cfg: {},
});
expect(consoleLogMock).toHaveBeenCalledWith("SAS decimals: 1234 5678 9012");
expect(consoleLogMock).toHaveBeenCalledWith(
"- If they match, run openclaw matrix verify confirm-sas -- self-1.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- If they do not match, run openclaw matrix verify mismatch-sas -- self-1.",
);
});
it("passes DM lookup details through Matrix verification follow-up commands", async () => {
startMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
id: "dm-verify-1",
transactionId: "txn-dm",
roomId: "!dm:example.org",
otherUserId: "@alice:example.org",
}),
);
const program = buildProgram();
await program.parseAsync(
[
"matrix",
"verify",
"start",
"txn-dm",
"--user-id",
"@alice:example.org",
"--room-id",
"!dm:example.org",
"--account",
"ops",
],
{ from: "user" },
);
expect(startMatrixVerificationMock).toHaveBeenCalledWith("txn-dm", {
accountId: "ops",
cfg: {},
method: "sas",
verificationDmUserId: "@alice:example.org",
verificationDmRoomId: "!dm:example.org",
});
expect(consoleLogMock).toHaveBeenCalledWith(
"- If they match, run openclaw matrix verify confirm-sas --user-id @alice:example.org --room-id '!dm:example.org' --account ops -- txn-dm.",
);
});
it("prints stable transaction ids in follow-up commands after accepting verification", async () => {
acceptMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
id: "verification-1",
transactionId: "txn-stable",
}),
);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "accept", "verification-1"], { from: "user" });
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify start -- txn-stable to start SAS verification.",
);
});
it("confirms, rejects, accepts, starts, and cancels Matrix verification requests", async () => {
acceptMatrixVerificationMock.mockResolvedValue(mockMatrixVerificationSummary({ id: "in-1" }));
startMatrixVerificationMock.mockResolvedValue(mockMatrixVerificationSummary({ id: "in-1" }));
confirmMatrixVerificationSasMock.mockResolvedValue(
mockMatrixVerificationSummary({ id: "in-1", completed: true, pending: false }),
);
mismatchMatrixVerificationSasMock.mockResolvedValue(
mockMatrixVerificationSummary({ id: "in-1", phaseName: "cancelled", pending: false }),
);
cancelMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({ id: "in-1", phaseName: "cancelled", pending: false }),
);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "accept", "in-1"], { from: "user" });
await program.parseAsync(["matrix", "verify", "start", "in-1"], { from: "user" });
await program.parseAsync(["matrix", "verify", "confirm-sas", "in-1"], { from: "user" });
await program.parseAsync(["matrix", "verify", "mismatch-sas", "in-1"], { from: "user" });
await program.parseAsync(
["matrix", "verify", "cancel", "in-1", "--reason", "changed my mind"],
{ from: "user" },
);
expect(acceptMatrixVerificationMock).toHaveBeenCalledWith("in-1", {
accountId: "default",
cfg: {},
});
expect(startMatrixVerificationMock).toHaveBeenCalledWith("in-1", {
accountId: "default",
cfg: {},
method: "sas",
});
expect(confirmMatrixVerificationSasMock).toHaveBeenCalledWith("in-1", {
accountId: "default",
cfg: {},
});
expect(mismatchMatrixVerificationSasMock).toHaveBeenCalledWith("in-1", {
accountId: "default",
cfg: {},
});
expect(cancelMatrixVerificationMock).toHaveBeenCalledWith("in-1", {
accountId: "default",
cfg: {},
reason: "changed my mind",
code: undefined,
});
});
it("sets non-zero exit code for bootstrap failures in JSON mode", async () => {
bootstrapMatrixVerificationMock.mockResolvedValue({
success: false,
@@ -342,9 +847,9 @@ describe("matrix CLI verification commands", () => {
it("lists matrix devices", async () => {
listMatrixOwnDevicesMock.mockResolvedValue([
{
deviceId: "A7hWrQ70ea",
displayName: "OpenClaw Gateway",
lastSeenIp: "127.0.0.1",
deviceId: "A7hWr\u001B[31mQ70ea",
displayName: "OpenClaw\u001B[2J Gateway",
lastSeenIp: "127.0.0.1\u009B2J",
lastSeenTs: 1_741_507_200_000,
current: true,
},
@@ -360,7 +865,7 @@ describe("matrix CLI verification commands", () => {
await program.parseAsync(["matrix", "devices", "list", "--account", "poe"], { from: "user" });
expect(listMatrixOwnDevicesMock).toHaveBeenCalledWith({ accountId: "poe" });
expect(listMatrixOwnDevicesMock).toHaveBeenCalledWith({ accountId: "poe", cfg: {} });
expect(console.log).toHaveBeenCalledWith("Account: poe");
expect(console.log).toHaveBeenCalledWith("- A7hWrQ70ea (current, OpenClaw Gateway)");
expect(console.log).toHaveBeenCalledWith(" Last IP: 127.0.0.1");
@@ -535,7 +1040,7 @@ describe("matrix CLI verification commands", () => {
);
expect(console.log).toHaveBeenCalledWith("Backup version: 7");
expect(console.log).toHaveBeenCalledWith(
"Matrix device hygiene warning: stale OpenClaw devices detected (BritdXC6iL). Run 'openclaw matrix devices prune-stale --account ops'.",
"Matrix device hygiene warning: stale OpenClaw devices detected (BritdXC6iL). Run openclaw matrix devices prune-stale --account ops.",
);
});
@@ -630,7 +1135,7 @@ describe("matrix CLI verification commands", () => {
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
expect(process.exitCode).toBeUndefined();
const jsonOutput = consoleLogMock.mock.calls.at(-1)?.[0];
const jsonOutput = stdoutWriteMock.mock.calls.at(-1)?.[0];
expect(typeof jsonOutput).toBe("string");
expect(JSON.parse(String(jsonOutput))).toEqual(
expect.objectContaining({
@@ -765,7 +1270,7 @@ describe("matrix CLI verification commands", () => {
});
expect(process.exitCode).toBe(1);
expect(console.log).toHaveBeenCalledWith(
expect(stdoutWriteMock).toHaveBeenCalledWith(
expect.stringContaining('"error": "Matrix requires --homeserver"'),
);
});
@@ -926,7 +1431,7 @@ describe("matrix CLI verification commands", () => {
"Backup issue: backup decryption key is not loaded on this device (secret storage did not return a key)",
);
expect(console.log).toHaveBeenCalledWith(
"- Backup key is not loaded on this device. Run 'openclaw matrix verify backup restore' to load it and restore old room keys.",
"- Backup key is not loaded on this device. Run openclaw matrix verify backup restore to load it and restore old room keys.",
);
expect(console.log).not.toHaveBeenCalledWith(
"- Backup is present but not trusted for this device. Re-run 'openclaw matrix verify device <key>'.",
@@ -993,7 +1498,7 @@ describe("matrix CLI verification commands", () => {
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
expect(console.log).toHaveBeenCalledWith(
"- If you want a fresh backup baseline and accept losing unrecoverable history, run 'openclaw matrix verify backup reset --yes'. This may also repair secret storage so the new backup key can be loaded after restart.",
"- If you want a fresh backup baseline and accept losing unrecoverable history, run openclaw matrix verify backup reset --yes. This may also repair secret storage so the new backup key can be loaded after restart.",
);
});
@@ -1071,10 +1576,10 @@ describe("matrix CLI verification commands", () => {
});
expect(console.log).toHaveBeenCalledWith("Account: assistant");
expect(console.log).toHaveBeenCalledWith(
"- Run 'openclaw matrix verify device <key> --account assistant' to verify this device.",
"- Run openclaw matrix verify device '<key>' --account assistant to verify this device.",
);
expect(console.log).toHaveBeenCalledWith(
"- Run 'openclaw matrix verify bootstrap --account assistant' to create a room key backup.",
"- Run openclaw matrix verify bootstrap --account assistant to create a room key backup.",
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,8 @@ let listMatrixVerifications: typeof import("./verification.js").listMatrixVerifi
let getMatrixEncryptionStatus: typeof import("./verification.js").getMatrixEncryptionStatus;
let getMatrixRoomKeyBackupStatus: typeof import("./verification.js").getMatrixRoomKeyBackupStatus;
let getMatrixVerificationStatus: typeof import("./verification.js").getMatrixVerificationStatus;
let runMatrixSelfVerification: typeof import("./verification.js").runMatrixSelfVerification;
let startMatrixVerification: typeof import("./verification.js").startMatrixVerification;
describe("matrix verification actions", () => {
beforeAll(async () => {
@@ -43,6 +45,8 @@ describe("matrix verification actions", () => {
getMatrixRoomKeyBackupStatus,
getMatrixVerificationStatus,
listMatrixVerifications,
runMatrixSelfVerification,
startMatrixVerification,
} = await import("./verification.js"));
});
@@ -55,6 +59,50 @@ describe("matrix verification actions", () => {
});
});
function mockVerifiedOwnerStatus() {
return {
backup: {
activeVersion: "1",
decryptionKeyCached: true,
keyLoadAttempted: false,
keyLoadError: null,
matchesDecryptionKey: true,
serverVersion: "1",
trusted: true,
},
backupVersion: "1",
crossSigningVerified: true,
deviceId: "DEVICE123",
localVerified: true,
recoveryKeyCreatedAt: null,
recoveryKeyId: null,
recoveryKeyStored: false,
signedByOwner: true,
userId: "@bot:example.org",
verified: true,
};
}
function mockUnverifiedOwnerStatus() {
return {
...mockVerifiedOwnerStatus(),
crossSigningVerified: false,
localVerified: false,
signedByOwner: false,
verified: false,
};
}
function mockCrossSigningPublicationStatus(published = true) {
return {
masterKeyPublished: published,
published,
selfSigningKeyPublished: published,
userId: "@bot:example.org",
userSigningKeyPublished: published,
};
}
it("points encryption guidance at the selected Matrix account", async () => {
loadConfigMock.mockReturnValue({
channels: {
@@ -213,4 +261,609 @@ describe("matrix verification actions", () => {
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(2);
expect(withStartedActionClientMock).not.toHaveBeenCalled();
});
it("rehydrates DM verification requests before follow-up actions", async () => {
const tracked = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "txn-dm",
};
const started = {
...tracked,
chosenMethod: "m.sas.v1",
phaseName: "started",
};
const crypto = {
ensureVerificationDmTracked: vi.fn(async () => tracked),
startVerification: vi.fn(async () => started),
};
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto });
});
await expect(
startMatrixVerification("txn-dm", {
verificationDmRoomId: "!dm:example.org",
verificationDmUserId: "@alice:example.org",
}),
).resolves.toMatchObject({
id: "verification-1",
phaseName: "started",
});
expect(crypto.ensureVerificationDmTracked).toHaveBeenCalledWith({
roomId: "!dm:example.org",
userId: "@alice:example.org",
});
expect(crypto.startVerification).toHaveBeenCalledWith("txn-dm", "sas");
});
it("requires complete DM lookup details for verification follow-up actions", async () => {
const crypto = {
ensureVerificationDmTracked: vi.fn(),
startVerification: vi.fn(),
};
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto });
});
await expect(
startMatrixVerification("txn-dm", {
verificationDmRoomId: "!dm:example.org",
}),
).rejects.toThrow("--user-id and --room-id must be provided together");
expect(crypto.ensureVerificationDmTracked).not.toHaveBeenCalled();
expect(crypto.startVerification).not.toHaveBeenCalled();
});
it("keeps self-verification in one started Matrix client session", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const ready = {
...requested,
phaseName: "ready",
};
const sas = {
...requested,
hasSas: true,
phaseName: "started",
sas: {
emoji: [["🐶", "Dog"]],
},
};
const completed = {
...sas,
completed: true,
phaseName: "done",
};
const listVerifications = vi
.fn()
.mockResolvedValueOnce([ready])
.mockResolvedValueOnce([completed]);
const crypto = {
confirmVerificationSas: vi.fn(async () => sas),
listVerifications,
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const confirmSas = vi.fn(async () => true);
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
mockCrossSigningPublicationStatus(),
);
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(),
success: true,
verification: mockVerifiedOwnerStatus(),
}));
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus,
getOwnDeviceVerificationStatus,
});
});
await expect(runMatrixSelfVerification({ confirmSas, timeoutMs: 500 })).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
id: "verification-1",
ownerVerification: {
verified: true,
},
});
expect(withStartedActionClientMock).toHaveBeenCalledTimes(1);
expect(crypto.requestVerification).toHaveBeenCalledWith({ ownUser: true });
expect(crypto.startVerification).toHaveBeenCalledWith("verification-1", "sas");
expect(confirmSas).toHaveBeenCalledWith(sas.sas, sas);
expect(crypto.confirmVerificationSas).toHaveBeenCalledWith("verification-1");
expect(bootstrapOwnDeviceVerification).toHaveBeenCalledWith({
allowAutomaticCrossSigningReset: false,
strict: false,
});
expect(getOwnCrossSigningPublicationStatus).not.toHaveBeenCalled();
expect(getOwnDeviceVerificationStatus).not.toHaveBeenCalled();
});
it("does not complete self-verification until the OpenClaw device has full Matrix identity trust", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const sas = {
...requested,
hasSas: true,
phaseName: "started",
sas: {
decimal: [1, 2, 3],
},
};
const completed = {
...sas,
completed: true,
phaseName: "done",
};
const crypto = {
confirmVerificationSas: vi.fn(async () => completed),
listVerifications: vi.fn(async () => [sas]),
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const getOwnDeviceIdentityVerificationStatus = vi
.fn()
.mockResolvedValueOnce(mockUnverifiedOwnerStatus())
.mockResolvedValueOnce(mockVerifiedOwnerStatus());
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
mockCrossSigningPublicationStatus(),
);
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(),
success: true,
verification: mockUnverifiedOwnerStatus(),
}));
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus,
getOwnDeviceIdentityVerificationStatus,
getOwnDeviceVerificationStatus,
trustOwnIdentityAfterSelfVerification,
});
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
ownerVerification: {
verified: true,
},
});
expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2);
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
expect(trustOwnIdentityAfterSelfVerification).toHaveBeenCalledTimes(1);
});
it("does not complete self-verification until cross-signing keys are published", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const sas = {
...requested,
hasSas: true,
phaseName: "started",
sas: {
decimal: [1, 2, 3],
},
};
const completed = {
...sas,
completed: true,
phaseName: "done",
};
const crypto = {
confirmVerificationSas: vi.fn(async () => completed),
listVerifications: vi.fn(async () => [sas]),
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const getOwnDeviceIdentityVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnCrossSigningPublicationStatus = vi
.fn()
.mockResolvedValueOnce(mockCrossSigningPublicationStatus(false))
.mockResolvedValueOnce(mockCrossSigningPublicationStatus(true));
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(false),
success: false,
verification: mockVerifiedOwnerStatus(),
}));
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus,
getOwnDeviceIdentityVerificationStatus,
getOwnDeviceVerificationStatus,
trustOwnIdentityAfterSelfVerification,
});
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
ownerVerification: {
verified: true,
},
});
expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2);
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
expect(trustOwnIdentityAfterSelfVerification).not.toHaveBeenCalled();
});
it("waits for SAS data without restarting an already-started self-verification", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const started = {
...requested,
phaseName: "started",
};
const sas = {
...started,
hasSas: true,
sas: {
decimal: [1, 2, 3],
},
};
const completed = {
...sas,
completed: true,
phaseName: "done",
};
const crypto = {
confirmVerificationSas: vi.fn(async () => completed),
listVerifications: vi.fn().mockResolvedValueOnce([started]).mockResolvedValueOnce([sas]),
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(),
};
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(),
success: true,
verification: mockVerifiedOwnerStatus(),
}));
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus: vi.fn(async () => mockCrossSigningPublicationStatus()),
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
});
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
});
expect(crypto.startVerification).not.toHaveBeenCalled();
});
it("fails immediately when an already-started self-verification uses a non-SAS method", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const started = {
...requested,
chosenMethod: "m.reciprocate.v1",
phaseName: "started",
};
const cancelled = {
...started,
phaseName: "cancelled",
};
const crypto = {
cancelVerification: vi.fn(async () => cancelled),
listVerifications: vi.fn(async () => [started]),
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(),
};
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto });
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
).rejects.toThrow(
"Matrix self-verification started without SAS while waiting to show SAS emoji or decimals (method: m.reciprocate.v1)",
);
expect(crypto.listVerifications).toHaveBeenCalledTimes(1);
expect(crypto.startVerification).not.toHaveBeenCalled();
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
code: "m.user",
reason: "OpenClaw self-verification did not complete",
});
});
it("finalizes completed non-SAS self-verification without waiting for SAS", async () => {
const completed = {
completed: true,
hasSas: false,
id: "verification-1",
phaseName: "done",
transactionId: "tx-self",
};
const crypto = {
confirmVerificationSas: vi.fn(),
listVerifications: vi.fn(async () => []),
requestVerification: vi.fn(async () => completed),
startVerification: vi.fn(),
};
const confirmSas = vi.fn(async () => true);
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(),
success: true,
verification: mockVerifiedOwnerStatus(),
}));
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus: vi.fn(async () => mockCrossSigningPublicationStatus()),
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
});
});
await expect(runMatrixSelfVerification({ confirmSas, timeoutMs: 500 })).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
id: "verification-1",
});
expect(crypto.listVerifications).not.toHaveBeenCalled();
expect(crypto.startVerification).not.toHaveBeenCalled();
expect(crypto.confirmVerificationSas).not.toHaveBeenCalled();
expect(confirmSas).not.toHaveBeenCalled();
});
it("allows completed self-verification when only backup health remains degraded", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const sas = {
...requested,
hasSas: true,
phaseName: "started",
sas: {
decimal: [1, 2, 3],
},
};
const completed = {
...sas,
completed: true,
phaseName: "done",
};
const crypto = {
confirmVerificationSas: vi.fn(async () => completed),
listVerifications: vi.fn(async () => [sas]),
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(),
success: false,
error: "Matrix room key backup is not trusted by this device",
verification: mockVerifiedOwnerStatus(),
}));
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
});
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
});
});
it("fails self-verification if SAS completes but full identity trust cannot be established", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const sas = {
...requested,
hasSas: true,
phaseName: "started",
sas: {
decimal: [1, 2, 3],
},
};
const completed = {
...sas,
completed: true,
phaseName: "done",
};
const crypto = {
cancelVerification: vi.fn(),
confirmVerificationSas: vi.fn(async () => completed),
listVerifications: vi.fn(async () => [sas]),
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(false),
success: false,
error: "cross-signing identity is still not trusted",
verification: mockUnverifiedOwnerStatus(),
}));
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus: vi.fn(async () =>
mockCrossSigningPublicationStatus(false),
),
getOwnDeviceIdentityVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
getOwnDeviceVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
});
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 30 }),
).rejects.toThrow(
"Timed out waiting for Matrix self-verification to establish full Matrix identity trust",
);
expect(crypto.cancelVerification).not.toHaveBeenCalled();
expect(bootstrapOwnDeviceVerification).toHaveBeenCalledWith({
allowAutomaticCrossSigningReset: false,
strict: false,
});
});
it("cancels the pending self-verification request when acceptance times out", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const crypto = {
cancelVerification: vi.fn(async () => requested),
listVerifications: vi.fn(async () => []),
requestVerification: vi.fn(async () => requested),
};
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto });
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 30 }),
).rejects.toThrow("Timed out waiting for Matrix self-verification to be accepted");
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
code: "m.user",
reason: "OpenClaw self-verification did not complete",
});
});
it("fails immediately when the self-verification request is cancelled while waiting", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const cancelled = {
...requested,
error: "Remote cancelled",
pending: false,
phaseName: "cancelled",
};
const crypto = {
cancelVerification: vi.fn(async () => cancelled),
listVerifications: vi.fn(async () => [cancelled]),
requestVerification: vi.fn(async () => requested),
};
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto });
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
).rejects.toThrow("Matrix self-verification was cancelled: Remote cancelled");
expect(crypto.listVerifications).toHaveBeenCalledTimes(1);
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
code: "m.user",
reason: "OpenClaw self-verification did not complete",
});
});
it("cancels the request when SAS mismatch submission fails", async () => {
const sas = {
completed: false,
hasSas: true,
id: "verification-1",
phaseName: "started",
sas: {
decimal: [1, 2, 3],
},
transactionId: "tx-self",
};
const crypto = {
cancelVerification: vi.fn(async () => sas),
listVerifications: vi.fn(async () => [sas]),
mismatchVerificationSas: vi.fn(async () => {
throw new Error("failed to send SAS mismatch");
}),
requestVerification: vi.fn(async () => sas),
};
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto });
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => false), timeoutMs: 500 }),
).rejects.toThrow("failed to send SAS mismatch");
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
code: "m.user",
reason: "OpenClaw self-verification did not complete",
});
});
});

View File

@@ -1,10 +1,27 @@
import { setTimeout as sleep } from "node:timers/promises";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { CoreConfig } from "../../types.js";
import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js";
import type { MatrixDeviceVerificationStatus, MatrixOwnDeviceVerificationStatus } from "../sdk.js";
import type { MatrixVerificationSummary } from "../sdk/verification-manager.js";
import { withResolvedActionClient, withStartedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
const DEFAULT_MATRIX_SELF_VERIFICATION_TIMEOUT_MS = 180_000;
type MatrixCryptoActionFacade = NonNullable<import("../sdk.js").MatrixClient["crypto"]>;
type MatrixActionClient = import("../sdk.js").MatrixClient;
type MatrixVerificationDmLookupOpts = {
verificationDmRoomId?: string;
verificationDmUserId?: string;
};
export type MatrixSelfVerificationResult = MatrixVerificationSummary & {
deviceOwnerVerified: boolean;
ownerVerification: MatrixOwnDeviceVerificationStatus;
};
function requireCrypto(
client: import("../sdk.js").MatrixClient,
opts: MatrixActionClientOpts,
@@ -29,6 +46,195 @@ function resolveVerificationId(input: string): string {
return normalized;
}
async function ensureMatrixVerificationDmTracked(
crypto: MatrixCryptoActionFacade,
opts: MatrixVerificationDmLookupOpts,
): Promise<void> {
const roomId = normalizeOptionalString(opts.verificationDmRoomId);
const userId = normalizeOptionalString(opts.verificationDmUserId);
if (Boolean(roomId) !== Boolean(userId)) {
throw new Error("--user-id and --room-id must be provided together for Matrix DM verification");
}
if (!roomId || !userId) {
return;
}
const tracked = await crypto.ensureVerificationDmTracked({ roomId, userId });
if (!tracked) {
throw new Error(
`Matrix DM verification request not found for room ${roomId} and user ${userId}`,
);
}
}
function isSameMatrixVerification(
left: MatrixVerificationSummary,
right: MatrixVerificationSummary,
): boolean {
return (
left.id === right.id ||
Boolean(left.transactionId && left.transactionId === right.transactionId)
);
}
function isMatrixVerificationReadyForSas(summary: MatrixVerificationSummary): boolean {
return (
summary.completed ||
summary.hasSas ||
summary.phaseName === "ready" ||
summary.phaseName === "started"
);
}
function shouldStartMatrixSasVerification(summary: MatrixVerificationSummary): boolean {
return !summary.hasSas && summary.phaseName !== "started" && !summary.completed;
}
function isMatrixVerificationCancelled(summary: MatrixVerificationSummary): boolean {
return summary.phaseName === "cancelled";
}
function isMatrixSasMethod(method: string | null | undefined): boolean {
return method === "m.sas.v1" || method === "sas";
}
function getMatrixVerificationSasWaitFailure(
summary: MatrixVerificationSummary,
label: string,
): string | null {
if (summary.hasSas || summary.phaseName === "cancelled") {
return null;
}
const method = summary.chosenMethod ? ` (method: ${summary.chosenMethod})` : "";
if (summary.completed) {
return `Matrix self-verification completed without SAS while waiting to ${label}${method}`;
}
if (
summary.phaseName === "started" &&
summary.chosenMethod &&
!isMatrixSasMethod(summary.chosenMethod)
) {
return `Matrix self-verification started without SAS while waiting to ${label}${method}`;
}
return null;
}
async function waitForMatrixVerificationSummary(params: {
crypto: MatrixCryptoActionFacade;
label: string;
request: MatrixVerificationSummary;
timeoutMs: number;
predicate: (summary: MatrixVerificationSummary) => boolean;
reject?: (summary: MatrixVerificationSummary) => string | null;
}): Promise<MatrixVerificationSummary> {
const startedAt = Date.now();
let last: MatrixVerificationSummary | undefined;
while (Date.now() - startedAt < params.timeoutMs) {
const summaries = await params.crypto.listVerifications();
const found = summaries.find((summary) => isSameMatrixVerification(summary, params.request));
if (found) {
last = found;
if (params.predicate(found)) {
return found;
}
if (isMatrixVerificationCancelled(found)) {
throw new Error(
`Matrix self-verification was cancelled${
found.error ? `: ${found.error}` : ` while waiting to ${params.label}`
}`,
);
}
const rejection = params.reject?.(found);
if (rejection) {
throw new Error(rejection);
}
}
await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
}
throw new Error(
`Timed out waiting for Matrix self-verification to ${params.label}${
last ? ` (last phase: ${last.phaseName})` : ""
}`,
);
}
function formatMatrixOwnerVerificationDiagnostics(
status: MatrixDeviceVerificationStatus | MatrixOwnDeviceVerificationStatus | undefined,
): string {
if (!status) {
return "Matrix identity trust status was unavailable";
}
return `cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}, signed by owner: ${
status.signedByOwner ? "yes" : "no"
}, locally trusted: ${status.localVerified ? "yes" : "no"}`;
}
async function waitForMatrixSelfVerificationTrustStatus(params: {
client: MatrixActionClient;
timeoutMs: number;
}): Promise<MatrixOwnDeviceVerificationStatus> {
const startedAt = Date.now();
let last: MatrixDeviceVerificationStatus | undefined;
let crossSigningPublished = false;
while (Date.now() - startedAt < params.timeoutMs) {
const [status, crossSigning] = await Promise.all([
params.client.getOwnDeviceIdentityVerificationStatus(),
params.client.getOwnCrossSigningPublicationStatus(),
]);
last = status;
crossSigningPublished = crossSigning.published;
if (last.verified && crossSigningPublished) {
return await params.client.getOwnDeviceVerificationStatus();
}
await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
}
throw new Error(
`Timed out waiting for Matrix self-verification to establish full Matrix identity trust for this device (${formatMatrixOwnerVerificationDiagnostics(
last,
)}, cross-signing keys published: ${crossSigningPublished ? "yes" : "no"}). Complete self-verification from another Matrix client, then check Matrix verification status for details.`,
);
}
async function cancelMatrixSelfVerificationOnFailure(params: {
crypto: MatrixCryptoActionFacade;
request: MatrixVerificationSummary | undefined;
}): Promise<void> {
if (!params.request || typeof params.crypto.cancelVerification !== "function") {
return;
}
await params.crypto
.cancelVerification(params.request.id, {
reason: "OpenClaw self-verification did not complete",
code: "m.user",
})
.catch(() => undefined);
}
async function completeMatrixSelfVerification(params: {
client: MatrixActionClient;
completed: MatrixVerificationSummary;
timeoutMs: number;
}): Promise<MatrixSelfVerificationResult> {
const bootstrap = await params.client.bootstrapOwnDeviceVerification({
allowAutomaticCrossSigningReset: false,
strict: false,
});
if (!bootstrap.verification.verified) {
await params.client.trustOwnIdentityAfterSelfVerification?.();
}
const ownerVerification =
bootstrap.verification.verified && bootstrap.crossSigning.published
? bootstrap.verification
: await waitForMatrixSelfVerificationTrustStatus({
client: params.client,
timeoutMs: params.timeoutMs,
});
return {
...params.completed,
deviceOwnerVerified: ownerVerification.verified,
ownerVerification,
};
}
export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
@@ -56,22 +262,118 @@ export async function requestMatrixVerification(
});
}
export async function runMatrixSelfVerification(
params: MatrixActionClientOpts & {
confirmSas: (
sas: NonNullable<MatrixVerificationSummary["sas"]>,
summary: MatrixVerificationSummary,
) => Promise<boolean>;
onReady?: (summary: MatrixVerificationSummary) => void | Promise<void>;
onRequested?: (summary: MatrixVerificationSummary) => void | Promise<void>;
onSas?: (summary: MatrixVerificationSummary) => void | Promise<void>;
timeoutMs?: number;
},
): Promise<MatrixSelfVerificationResult> {
return await withStartedActionClient(params, async (client) => {
const crypto = requireCrypto(client, params);
const timeoutMs = params.timeoutMs ?? DEFAULT_MATRIX_SELF_VERIFICATION_TIMEOUT_MS;
let requested: MatrixVerificationSummary | undefined;
let requestCompleted = false;
let handledByMismatch = false;
try {
requested = await crypto.requestVerification({ ownUser: true });
await params.onRequested?.(requested);
const ready = isMatrixVerificationReadyForSas(requested)
? requested
: await waitForMatrixVerificationSummary({
crypto,
label: "be accepted in another Matrix client",
request: requested,
timeoutMs,
predicate: isMatrixVerificationReadyForSas,
});
await params.onReady?.(ready);
if (ready.completed) {
requestCompleted = true;
return await completeMatrixSelfVerification({ client, completed: ready, timeoutMs });
}
const started = shouldStartMatrixSasVerification(ready)
? await crypto.startVerification(ready.id, "sas")
: ready;
let sasSummary = started;
if (!sasSummary.hasSas) {
const sasFailure = getMatrixVerificationSasWaitFailure(
sasSummary,
"show SAS emoji or decimals",
);
if (sasFailure) {
throw new Error(sasFailure);
}
sasSummary = await waitForMatrixVerificationSummary({
crypto,
label: "show SAS emoji or decimals",
request: started,
timeoutMs,
predicate: (summary) => summary.hasSas,
reject: (summary) =>
getMatrixVerificationSasWaitFailure(summary, "show SAS emoji or decimals"),
});
}
if (!sasSummary.sas) {
throw new Error("Matrix SAS data is not available for this verification request");
}
await params.onSas?.(sasSummary);
const matched = await params.confirmSas(sasSummary.sas, sasSummary);
if (!matched) {
await crypto.mismatchVerificationSas(sasSummary.id);
handledByMismatch = true;
throw new Error("Matrix SAS verification was not confirmed.");
}
const confirmed = await crypto.confirmVerificationSas(sasSummary.id);
const completed = confirmed.completed
? confirmed
: await waitForMatrixVerificationSummary({
crypto,
label: "complete",
request: confirmed,
timeoutMs,
predicate: (summary) => summary.completed,
});
requestCompleted = true;
return await completeMatrixSelfVerification({ client, completed, timeoutMs });
} catch (error) {
if (!requestCompleted && !handledByMismatch) {
await cancelMatrixSelfVerificationOnFailure({ crypto, request: requested });
}
throw error;
}
});
}
export async function acceptMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.acceptVerification(resolveVerificationId(requestId));
});
}
export async function cancelMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts & { reason?: string; code?: string } = {},
opts: MatrixActionClientOpts &
MatrixVerificationDmLookupOpts & { reason?: string; code?: string } = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.cancelVerification(resolveVerificationId(requestId), {
reason: normalizeOptionalString(opts.reason),
code: normalizeOptionalString(opts.code),
@@ -81,20 +383,22 @@ export async function cancelMatrixVerification(
export async function startMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts & { method?: "sas" } = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts & { method?: "sas" } = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas");
});
}
export async function generateMatrixVerificationQr(
requestId: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.generateVerificationQr(resolveVerificationId(requestId));
});
}
@@ -102,10 +406,11 @@ export async function generateMatrixVerificationQr(
export async function scanMatrixVerificationQr(
requestId: string,
qrDataBase64: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
const payload = qrDataBase64.trim();
if (!payload) {
throw new Error("Matrix QR data is required");
@@ -116,40 +421,44 @@ export async function scanMatrixVerificationQr(
export async function getMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.getVerificationSas(resolveVerificationId(requestId));
});
}
export async function confirmMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.confirmVerificationSas(resolveVerificationId(requestId));
});
}
export async function mismatchMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.mismatchVerificationSas(resolveVerificationId(requestId));
});
}
export async function confirmMatrixVerificationReciprocateQr(
requestId: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId));
});
}

View File

@@ -33,6 +33,20 @@ function stubRuntimeFetch(fetchImpl: typeof fetch): void {
};
}
async function consumeMatrixSecretStorageKey(keyId = "SSSSKEY"): Promise<boolean> {
const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as {
getSecretStorageKey?: (
params: { keys: Record<string, unknown> },
name: string,
) => Promise<[string, Uint8Array] | null>;
} | null;
const result = await callbacks?.getSecretStorageKey?.(
{ keys: { [keyId]: { algorithm: "m.secret_storage.v1.aes-hmac-sha2" } } },
"m.cross_signing.master",
);
return Boolean(result);
}
class FakeMatrixEvent extends EventEmitter {
private readonly roomId: string;
private readonly eventId: string;
@@ -793,7 +807,7 @@ describe("MatrixClient event bridge", () => {
cryptoListeners.set(eventName, listener);
}),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
}));
@@ -893,7 +907,7 @@ describe("MatrixClient event bridge", () => {
cryptoListeners.set(eventName, listener);
}),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
}));
@@ -1209,6 +1223,48 @@ describe("MatrixClient crypto bootstrapping", () => {
);
});
it("trusts the own Matrix identity after completed self-verification", async () => {
const verifyOwnIdentity = vi.fn(async () => ({}));
const freeOwnIdentity = vi.fn();
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
getOwnIdentity: vi.fn(async () => ({
free: freeOwnIdentity,
isVerified: () => false,
verify: verifyOwnIdentity,
})),
requestOwnUserVerification: vi.fn(async () => null),
}));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
});
await client.trustOwnIdentityAfterSelfVerification();
expect(verifyOwnIdentity).toHaveBeenCalledTimes(1);
expect(freeOwnIdentity).toHaveBeenCalledTimes(1);
});
it("does not fail self-verification cleanup when own identity verify is unavailable", async () => {
const freeOwnIdentity = vi.fn();
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
getOwnIdentity: vi.fn(async () => ({
free: freeOwnIdentity,
isVerified: () => false,
})),
requestOwnUserVerification: vi.fn(async () => null),
}));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
});
await expect(client.trustOwnIdentityAfterSelfVerification()).resolves.toBeUndefined();
expect(freeOwnIdentity).toHaveBeenCalledTimes(1);
});
it("retries bootstrap with forced reset when initial publish/verification is incomplete", async () => {
matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() }));
const client = new MatrixClient("https://matrix.example.org", "token", {
@@ -1248,7 +1304,7 @@ describe("MatrixClient crypto bootstrapping", () => {
});
});
it("does not force-reset bootstrap when the device is already signed by its owner", async () => {
it("does not force-reset bootstrap automatically when the device has an owner signature but not full trust", async () => {
matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() }));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
@@ -1273,7 +1329,7 @@ describe("MatrixClient crypto bootstrapping", () => {
encryptionEnabled: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
verified: true,
verified: false,
localVerified: true,
crossSigningVerified: false,
signedByOwner: true,
@@ -1493,7 +1549,7 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(status.deviceId).toBe("DEVICE123");
});
it("does not treat local-only trust as owner verification", async () => {
it("does not treat local-only trust as Matrix identity trust", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
@@ -1559,7 +1615,7 @@ describe("MatrixClient crypto bootstrapping", () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
const bootstrapSecretStorage = vi.fn(async () => {});
const bootstrapSecretStorage = vi.fn(consumeMatrixSecretStorageKey);
const bootstrapCrossSigning = vi.fn(async () => {});
const checkKeyBackupAndEnable = vi.fn(async () => {});
const getSecretStorageStatus = vi.fn(async () => ({
@@ -1591,6 +1647,9 @@ describe("MatrixClient crypto bootstrapping", () => {
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(true);
expect(result.recoveryKeyAccepted).toBe(true);
expect(result.backupUsable).toBe(false);
expect(result.deviceOwnerVerified).toBe(true);
expect(result.verified).toBe(true);
expect(result.recoveryKeyStored).toBe(true);
expect(result.deviceId).toBe("DEVICE123");
@@ -1600,9 +1659,167 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1);
});
it("fails recovery-key verification when the device is only locally trusted", async () => {
it("accepts a staged recovery key when it establishes identity trust and backup usability", async () => {
const privateKey = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1));
const encoded = encodeRecoveryKey(privateKey);
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
let backupKeyLoaded = false;
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: {},
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
})),
checkKeyBackupAndEnable: vi.fn(async () => {}),
loadSessionBackupPrivateKeyFromSecretStorage: vi.fn(async () => {
backupKeyLoaded = await consumeMatrixSecretStorageKey();
}),
getActiveSessionBackupVersion: vi.fn(async () => (backupKeyLoaded ? "11" : null)),
getSessionBackupPrivateKey: vi.fn(async () => (backupKeyLoaded ? privateKey : null)),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "11",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-used-key-"));
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath,
});
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(true);
expect(result.recoveryKeyAccepted).toBe(true);
expect(result.backupUsable).toBe(true);
expect(result.deviceOwnerVerified).toBe(true);
expect(result.recoveryKeyStored).toBe(true);
expect(fs.existsSync(recoveryKeyPath)).toBe(true);
});
it("fails recovery-key verification when the device lacks full cross-signing identity trust", async () => {
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: { SSSSKEY: true },
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: false,
signedByOwner: true,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-local-only-"));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"),
});
await client.start();
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(false);
expect(result.recoveryKeyAccepted).toBe(false);
expect(result.backupUsable).toBe(false);
expect(result.deviceOwnerVerified).toBe(false);
expect(result.verified).toBe(false);
expect(result.error).toContain("full Matrix identity trust");
});
it("keeps a usable recovery key distinct from owner device verification", async () => {
const privateKey = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1));
const encoded = encodeRecoveryKey(privateKey);
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
let backupKeyLoaded = false;
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: { SSSSKEY: true },
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: false,
signedByOwner: false,
})),
checkKeyBackupAndEnable: vi.fn(async () => {}),
loadSessionBackupPrivateKeyFromSecretStorage: vi.fn(async () => {
backupKeyLoaded = await consumeMatrixSecretStorageKey();
}),
getActiveSessionBackupVersion: vi.fn(async () => (backupKeyLoaded ? "11" : null)),
getSessionBackupPrivateKey: vi.fn(async () => (backupKeyLoaded ? privateKey : null)),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "11",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-usable-"));
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath,
});
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(false);
expect(result.recoveryKeyAccepted).toBe(true);
expect(result.backupUsable).toBe(true);
expect(result.deviceOwnerVerified).toBe(false);
expect(result.verified).toBe(false);
expect(result.recoveryKeyStored).toBe(true);
expect(fs.existsSync(recoveryKeyPath)).toBe(true);
});
it("does not persist a staged recovery key when backup usability came from existing material", async () => {
const previousEncoded = encodeRecoveryKey(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
);
const attemptedEncoded = encodeRecoveryKey(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 55)),
);
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
@@ -1621,19 +1838,122 @@ describe("MatrixClient crypto bootstrapping", () => {
crossSigningVerified: false,
signedByOwner: false,
})),
checkKeyBackupAndEnable: vi.fn(async () => {}),
getActiveSessionBackupVersion: vi.fn(async () => "11"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "11",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-local-only-"));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-cached-"));
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "SSSSKEY",
encodedPrivateKey: previousEncoded,
privateKeyBase64: Buffer.from(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
).toString("base64"),
}),
"utf8",
);
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"),
recoveryKeyPath,
});
await client.start();
const result = await client.verifyWithRecoveryKey(encoded as string);
const result = await client.verifyWithRecoveryKey(attemptedEncoded as string);
expect(result.success).toBe(false);
expect(result.verified).toBe(false);
expect(result.error).toContain("not verified by its owner");
expect(result.recoveryKeyAccepted).toBe(false);
expect(result.backupUsable).toBe(true);
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
encodedPrivateKey?: string;
};
expect(persisted.encodedPrivateKey).toBe(previousEncoded);
});
it("does not persist a staged recovery key that secret storage did not validate", async () => {
const previousEncoded = encodeRecoveryKey(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
);
const attemptedEncoded = encodeRecoveryKey(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 55)),
);
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: { SSSSKEY: false },
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: false,
signedByOwner: false,
})),
checkKeyBackupAndEnable: vi.fn(async () => {}),
getActiveSessionBackupVersion: vi.fn(async () => "11"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "11",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-invalid-"));
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "SSSSKEY",
encodedPrivateKey: previousEncoded,
privateKeyBase64: Buffer.from(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
).toString("base64"),
}),
"utf8",
);
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath,
});
const result = await client.verifyWithRecoveryKey(attemptedEncoded as string);
expect(result.success).toBe(false);
expect(result.recoveryKeyAccepted).toBe(false);
expect(result.backupUsable).toBe(true);
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
encodedPrivateKey?: string;
};
expect(persisted.encodedPrivateKey).toBe(previousEncoded);
});
it("fails recovery-key verification when backup remains untrusted after device verification", async () => {
@@ -1644,7 +1964,7 @@ describe("MatrixClient crypto bootstrapping", () => {
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
@@ -1680,6 +2000,9 @@ describe("MatrixClient crypto bootstrapping", () => {
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(false);
expect(result.recoveryKeyAccepted).toBe(true);
expect(result.backupUsable).toBe(false);
expect(result.deviceOwnerVerified).toBe(true);
expect(result.verified).toBe(true);
expect(result.error).toContain("backup signature chain is not trusted");
expect(result.recoveryKeyStored).toBe(false);
@@ -1739,7 +2062,7 @@ describe("MatrixClient crypto bootstrapping", () => {
const result = await client.verifyWithRecoveryKey(attemptedEncoded as string);
expect(result.success).toBe(false);
expect(result.error).toContain("not verified by its owner");
expect(result.error).toContain("full Matrix identity trust");
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
encodedPrivateKey?: string;
};
@@ -2538,7 +2861,7 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(result.success).toBe(false);
expect(result.verification.localVerified).toBe(true);
expect(result.verification.signedByOwner).toBe(false);
expect(result.error).toContain("not verified by its owner after bootstrap");
expect(result.error).toContain("full Matrix identity trust after bootstrap");
});
it("creates a key backup during bootstrap when none exists on the server", async () => {

View File

@@ -72,8 +72,8 @@ export type MatrixOwnDeviceVerificationStatus = {
encryptionEnabled: boolean;
userId: string | null;
deviceId: string | null;
// "verified" is intentionally strict: other Matrix clients should trust messages
// from this device without showing "not verified by its owner" warnings.
// "verified" is intentionally strict: this device must be trusted through the
// Matrix cross-signing identity chain, not merely signed by the owner key.
verified: boolean;
localVerified: boolean;
crossSigningVerified: boolean;
@@ -128,6 +128,9 @@ export type MatrixRoomKeyBackupResetResult = {
export type MatrixRecoveryKeyVerificationResult = MatrixOwnDeviceVerificationStatus & {
success: boolean;
recoveryKeyAccepted: boolean;
backupUsable: boolean;
deviceOwnerVerified: boolean;
verifiedAt?: string;
error?: string;
};
@@ -160,12 +163,15 @@ const MATRIX_AUTOMATIC_REPAIR_BOOTSTRAP_OPTIONS = {
} satisfies MatrixCryptoBootstrapOptions;
function createMatrixExplicitBootstrapOptions(params?: {
allowAutomaticCrossSigningReset?: boolean;
forceResetCrossSigning?: boolean;
strict?: boolean;
}): MatrixCryptoBootstrapOptions {
return {
forceResetCrossSigning: params?.forceResetCrossSigning === true,
allowAutomaticCrossSigningReset: params?.allowAutomaticCrossSigningReset !== false,
allowSecretStorageRecreateWithoutRecoveryKey: true,
strict: true,
strict: params?.strict !== false,
};
}
@@ -362,7 +368,15 @@ export class MatrixClient {
return;
}
this.verificationManager ??= new runtime.MatrixVerificationManager();
this.verificationManager ??= new runtime.MatrixVerificationManager({
trustOwnDeviceAfterSas: async (deviceId: string) => {
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (typeof crypto?.crossSignDevice !== "function") {
return;
}
await crypto.crossSignDevice(deviceId);
},
});
this.cryptoBootstrapper ??= new runtime.MatrixCryptoBootstrapper<MatrixRawEvent>({
getUserId: () => this.getUserId(),
getPassword: () => this.password,
@@ -1110,12 +1124,10 @@ export class MatrixClient {
const deviceId = this.client.getDeviceId()?.trim() || null;
const backup = await this.getRoomKeyBackupStatus();
const deviceVerification = await this.getDeviceVerificationStatus(userId, deviceId);
const ownerVerified =
deviceVerification.crossSigningVerified || deviceVerification.signedByOwner;
return {
...deviceVerification,
verified: ownerVerified,
verified: deviceVerification.crossSigningVerified,
recoveryKeyStored: Boolean(recoveryKey),
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
recoveryKeyId: recoveryKey?.keyId ?? null,
@@ -1124,14 +1136,67 @@ export class MatrixClient {
};
}
async getOwnDeviceIdentityVerificationStatus(): Promise<MatrixDeviceVerificationStatus> {
const userId = this.client.getUserId() ?? this.selfUserId ?? null;
const deviceId = this.client.getDeviceId()?.trim() || null;
const deviceVerification = await this.getDeviceVerificationStatus(userId, deviceId);
return {
...deviceVerification,
verified: deviceVerification.crossSigningVerified,
};
}
async trustOwnIdentityAfterSelfVerification(): Promise<void> {
if (!this.encryptionEnabled) {
return;
}
await this.ensureStartedForCryptoControlPlane();
await this.ensureCryptoSupportInitialized();
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
const ownIdentity =
crypto && typeof crypto.getOwnIdentity === "function"
? await crypto.getOwnIdentity().catch(() => undefined)
: undefined;
if (!ownIdentity) {
return;
}
try {
if (typeof ownIdentity.isVerified === "function" && ownIdentity.isVerified()) {
return;
}
if (typeof ownIdentity.verify !== "function") {
return;
}
await ownIdentity.verify();
} finally {
ownIdentity.free?.();
}
}
async verifyWithRecoveryKey(
rawRecoveryKey: string,
): Promise<MatrixRecoveryKeyVerificationResult> {
const fail = async (error: string): Promise<MatrixRecoveryKeyVerificationResult> => ({
success: false,
error,
...(await this.getOwnDeviceVerificationStatus()),
});
const fail = async (
error: string,
fields: Partial<
Pick<
MatrixRecoveryKeyVerificationResult,
"backupUsable" | "deviceOwnerVerified" | "recoveryKeyAccepted"
>
> = {},
): Promise<MatrixRecoveryKeyVerificationResult> => {
const status = await this.getOwnDeviceVerificationStatus();
return {
success: false,
recoveryKeyAccepted: fields.recoveryKeyAccepted ?? false,
backupUsable: fields.backupUsable ?? false,
deviceOwnerVerified: fields.deviceOwnerVerified ?? status.verified,
error,
...status,
};
};
if (!this.encryptionEnabled) {
return await fail("Matrix encryption is disabled for this client");
@@ -1144,15 +1209,21 @@ export class MatrixClient {
return await fail("Matrix crypto is not available (start client with encryption enabled)");
}
const backupUsableBeforeStagedRecovery =
resolveMatrixRoomKeyBackupReadinessError(await this.getRoomKeyBackupStatus(), {
requireServerBackup: true,
}) === null;
const trimmedRecoveryKey = rawRecoveryKey.trim();
if (!trimmedRecoveryKey) {
return await fail("Matrix recovery key is required");
}
let stagedKeyId: string | null = null;
try {
stagedKeyId = (await this.resolveDefaultSecretStorageKeyId(crypto)) ?? null;
this.recoveryKeyStore.stageEncodedRecoveryKey({
encodedPrivateKey: trimmedRecoveryKey,
keyId: await this.resolveDefaultSecretStorageKeyId(crypto),
keyId: stagedKeyId,
});
} catch (err) {
return await fail(formatMatrixErrorMessage(err));
@@ -1168,33 +1239,88 @@ export class MatrixClient {
});
await this.enableTrustedRoomKeyBackupIfPossible(crypto);
const status = await this.getOwnDeviceVerificationStatus();
if (!status.verified) {
this.recoveryKeyStore.discardStagedRecoveryKey();
return {
success: false,
error:
"Matrix device is still not verified by its owner after applying the recovery key. Ensure cross-signing is available and the device is signed.",
...status,
};
}
const backupError = resolveMatrixRoomKeyBackupReadinessError(status.backup, {
requireServerBackup: false,
});
const backupUsable =
resolveMatrixRoomKeyBackupReadinessError(status.backup, {
requireServerBackup: true,
}) === null;
const stagedRecoveryKeyUsed = this.recoveryKeyStore.hasStagedRecoveryKeyBeenUsed();
const secretStorageStatus =
typeof crypto.getSecretStorageStatus === "function"
? await crypto.getSecretStorageStatus().catch(() => null)
: null;
const stagedRecoveryKeyConfirmedBySecretStorage =
Boolean(stagedKeyId) &&
secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId ?? ""] === true;
const stagedRecoveryKeyRejectedBySecretStorage =
Boolean(stagedKeyId) &&
secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId ?? ""] === false;
const stagedRecoveryKeyUnlockedBackup =
stagedRecoveryKeyUsed &&
!stagedRecoveryKeyRejectedBySecretStorage &&
!stagedRecoveryKeyConfirmedBySecretStorage &&
!backupUsableBeforeStagedRecovery &&
backupUsable;
const stagedRecoveryKeyValidated =
stagedRecoveryKeyUsed &&
(stagedRecoveryKeyConfirmedBySecretStorage || stagedRecoveryKeyUnlockedBackup);
const recoveryKeyAccepted = stagedRecoveryKeyValidated && (status.verified || backupUsable);
if (!status.verified) {
if (backupUsable && stagedRecoveryKeyValidated) {
this.recoveryKeyStore.commitStagedRecoveryKey({
keyId: stagedKeyId,
});
} else {
this.recoveryKeyStore.discardStagedRecoveryKey();
}
const committedStatus = recoveryKeyAccepted
? await this.getOwnDeviceVerificationStatus()
: status;
return {
success: false,
recoveryKeyAccepted,
backupUsable,
deviceOwnerVerified: false,
error:
"Matrix recovery key was applied, but this device still lacks full Matrix identity trust. The recovery key can unlock usable backup material only when 'Backup usable' is yes; full identity trust still requires Matrix cross-signing verification.",
...committedStatus,
};
}
if (backupError) {
this.recoveryKeyStore.discardStagedRecoveryKey();
return {
success: false,
recoveryKeyAccepted,
backupUsable,
deviceOwnerVerified: true,
error: backupError,
...status,
};
}
if (!stagedRecoveryKeyValidated) {
this.recoveryKeyStore.discardStagedRecoveryKey();
return {
success: false,
recoveryKeyAccepted: false,
backupUsable,
deviceOwnerVerified: true,
error:
"Matrix recovery key could not be verified against active Matrix backup material; existing backup may be usable from previously loaded recovery material.",
...status,
};
}
this.recoveryKeyStore.commitStagedRecoveryKey({
keyId: await this.resolveDefaultSecretStorageKeyId(crypto),
keyId: stagedKeyId,
});
const committedStatus = await this.getOwnDeviceVerificationStatus();
return {
success: true,
recoveryKeyAccepted: true,
backupUsable,
deviceOwnerVerified: true,
verifiedAt: new Date().toISOString(),
...committedStatus,
};
@@ -1419,8 +1545,10 @@ export class MatrixClient {
}
async bootstrapOwnDeviceVerification(params?: {
allowAutomaticCrossSigningReset?: boolean;
recoveryKey?: string;
forceResetCrossSigning?: boolean;
strict?: boolean;
}): Promise<MatrixVerificationBootstrapResult> {
const pendingVerifications = async (): Promise<number> =>
this.crypto ? (await this.crypto.listVerifications()).length : 0;
@@ -1680,12 +1808,15 @@ export class MatrixClient {
"MatrixClientLite",
"No room key backup version found on server, creating one via secret storage bootstrap",
);
// matrix-js-sdk 41.3.0 can log a transient PerSessionKeyBackupDownloader
// "current backup version ... undefined" warning while setupNewKeyBackup creates
// the backup: resetKeyBackup emits key-backup cache events before its async
// checkKeyBackupAndEnable pass has populated active backup state. Keep the
// explicit server re-check below and do not hide the SDK logs; if this needs
// fixing in code, upstream a minimal Matrix SDK repro instead of patching here.
// matrix-js-sdk 41.3.0 can log transient PerSessionKeyBackupDownloader
// diagnostics while setupNewKeyBackup creates the first backup, including
// "Got current backup version from server: undefined" and
// "Unsupported algorithm undefined". This is an expected upstream
// matrix-js-sdk race: resetKeyBackup emits key-backup cache events before
// its async checkKeyBackupAndEnable pass has populated active backup state.
// Keep the explicit server re-check below and do not hide the SDK logs; if
// this needs fixing in code, upstream a minimal Matrix SDK repro instead of
// patching here.
await this.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, {
setupNewKeyBackup: true,
});

View File

@@ -196,7 +196,8 @@ describe("MatrixCryptoBootstrapper", () => {
userHasCrossSigningKeys: vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true),
.mockResolvedValueOnce(true)
.mockResolvedValue(true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
@@ -253,6 +254,48 @@ describe("MatrixCryptoBootstrapper", () => {
);
});
it("does not mark the own Matrix identity verified before cross-signing the current device", async () => {
const verifyOwnIdentity = vi.fn(async () => undefined);
const freeOwnIdentity = vi.fn();
const setDeviceVerified = vi.fn(async () => {});
const crossSignDevice = vi.fn(async () => {});
const getDeviceVerificationStatus = vi
.fn()
.mockResolvedValueOnce({
isVerified: () => false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: true,
})
.mockResolvedValueOnce({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
});
const { bootstrapper, crypto } = createBootstrapperHarness({
crossSignDevice,
getDeviceVerificationStatus,
getOwnIdentity: vi.fn(async () => ({
free: freeOwnIdentity,
isVerified: () => false,
verify: verifyOwnIdentity,
})),
isCrossSigningReady: vi.fn(async () => true),
setDeviceVerified,
userHasCrossSigningKeys: vi.fn(async () => true),
});
await bootstrapper.bootstrap(crypto, {
allowAutomaticCrossSigningReset: false,
});
expect(verifyOwnIdentity).not.toHaveBeenCalled();
expect(freeOwnIdentity).not.toHaveBeenCalled();
expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true);
expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123");
});
it("refreshes published cross-signing keys before importing private keys from secret storage", async () => {
const bootstrapCrossSigning = vi.fn(async () => {});
const userHasCrossSigningKeys = vi.fn(async () => true);
@@ -419,6 +462,47 @@ describe("MatrixCryptoBootstrapper", () => {
});
});
it("trusts the fresh own identity after a forced cross-signing reset", async () => {
const verifyOwnIdentity = vi.fn(async () => ({}));
const freeOwnIdentity = vi.fn();
const { crypto, bootstrapper } = createForcedResetHarness(vi.fn(async () => {}));
crypto.getOwnIdentity = vi.fn(async () => ({
free: freeOwnIdentity,
isVerified: () => false,
verify: verifyOwnIdentity,
}));
await bootstrapper.bootstrap(crypto, {
strict: true,
forceResetCrossSigning: true,
});
expect(verifyOwnIdentity).toHaveBeenCalledTimes(1);
expect(freeOwnIdentity).toHaveBeenCalledTimes(1);
});
it("does not trust an existing unpublished identity without a reset", async () => {
const verifyOwnIdentity = vi.fn(async () => ({}));
const { crypto, bootstrapper } = createBootstrapperHarness({
bootstrapCrossSigning: vi.fn(async () => {}),
getDeviceVerificationStatus: vi.fn(async () => createVerifiedDeviceStatus()),
getOwnIdentity: vi.fn(async () => ({
isVerified: () => false,
verify: verifyOwnIdentity,
})),
isCrossSigningReady: vi.fn(async () => false),
userHasCrossSigningKeys: vi.fn(async () => false),
});
const result = await bootstrapper.bootstrap(crypto, {
allowAutomaticCrossSigningReset: false,
strict: false,
});
expect(result.crossSigningPublished).toBe(false);
expect(verifyOwnIdentity).not.toHaveBeenCalled();
});
it("fails in strict mode when cross-signing keys are still unpublished", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({

View File

@@ -1,3 +1,4 @@
import { setTimeout as sleep } from "node:timers/promises";
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
import type { MatrixDecryptBridge } from "./decrypt-bridge.js";
import { LogService } from "./logger.js";
@@ -37,6 +38,8 @@ export type MatrixCryptoBootstrapResult = {
ownDeviceVerified: boolean | null;
};
const CROSS_SIGNING_PUBLICATION_WAIT_MS = 5_000;
export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
private verificationHandlerRegistered = false;
@@ -83,7 +86,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
strict,
});
}
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict);
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, {
strict,
});
return {
crossSigningReady: crossSigning.ready,
crossSigningPublished: crossSigning.published,
@@ -165,7 +170,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
const finalize = async (): Promise<{ ready: boolean; published: boolean }> => {
const ready = await isCrossSigningReady();
const published = await hasPublishedCrossSigningKeys();
const published = ready
? await waitForPublishedCrossSigningKeys()
: await hasPublishedCrossSigningKeys();
if (ready && published) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return { ready, published };
@@ -178,6 +185,17 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
return { ready, published };
};
const waitForPublishedCrossSigningKeys = async (): Promise<boolean> => {
const startedAt = Date.now();
do {
if (await hasPublishedCrossSigningKeys()) {
return true;
}
await sleep(250);
} while (Date.now() - startedAt < CROSS_SIGNING_PUBLICATION_WAIT_MS);
return false;
};
if (options.forceResetCrossSigning) {
const resetCrossSigning = async (): Promise<void> => {
await crypto.bootstrapCrossSigning({
@@ -187,6 +205,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
};
try {
await resetCrossSigning();
await this.trustFreshOwnIdentity(crypto);
} catch (err) {
const shouldRepairSecretStorage =
options.allowSecretStorageRecreateWithoutRecoveryKey &&
@@ -202,6 +221,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
forceNewSecretStorage: true,
});
await resetCrossSigning();
await this.trustFreshOwnIdentity(crypto);
} catch (repairErr) {
LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", repairErr);
if (options.strict) {
@@ -287,6 +307,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
setupNewCrossSigning: true,
authUploadDeviceSigningKeys,
});
await this.trustFreshOwnIdentity(crypto);
} catch (err) {
LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err);
if (options.strict) {
@@ -298,6 +319,25 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
return await finalize();
}
private async trustFreshOwnIdentity(crypto: MatrixCryptoBootstrapApi): Promise<void> {
const ownIdentity =
typeof crypto.getOwnIdentity === "function"
? await crypto.getOwnIdentity().catch(() => undefined)
: undefined;
if (!ownIdentity) {
return;
}
try {
if (typeof ownIdentity.isVerified === "function" && ownIdentity.isVerified()) {
return;
}
await ownIdentity.verify?.();
} finally {
ownIdentity.free?.();
}
}
private async bootstrapSecretStorage(
crypto: MatrixCryptoBootstrapApi,
options: {
@@ -349,7 +389,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
private async ensureOwnDeviceTrust(
crypto: MatrixCryptoBootstrapApi,
strict = false,
options: {
strict: boolean;
},
): Promise<boolean | null> {
const deviceId = this.deps.getDeviceId()?.trim();
if (!deviceId) {
@@ -386,8 +428,10 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
: null;
const verified = isMatrixDeviceOwnerVerified(refreshedStatus);
if (!verified && strict) {
throw new Error(`Matrix own device ${deviceId} is not verified by its owner after bootstrap`);
if (!verified && options.strict) {
throw new Error(
`Matrix own device ${deviceId} does not have full Matrix identity trust after bootstrap`,
);
}
return verified;
}

View File

@@ -49,6 +49,7 @@ function createFacadeHarness(params?: {
client: {
getRoom: params?.client?.getRoom ?? (() => null),
getCrypto: params?.client?.getCrypto ?? (() => undefined),
getUserId: params?.client?.getUserId ?? (() => "@bot:example.org"),
},
verificationManager: createVerificationManagerMock(params?.verificationManager),
recoveryKeyStore: createRecoveryKeyStoreMock(params?.recoveryKeySummary ?? null),
@@ -194,4 +195,66 @@ describe("createMatrixCryptoFacade", () => {
expect(trackVerificationRequest).toHaveBeenCalledWith(request);
expect(summary?.transactionId).toBe("txn-dm-in-progress");
});
it("rehydrates in-progress to-device verification requests before listing", async () => {
const request = {
transactionId: "txn-self-in-progress",
otherUserId: "@bot:example.org",
initiatedByMe: true,
isSelfVerification: true,
phase: 2,
pending: true,
accepting: false,
declining: false,
methods: ["m.sas.v1"],
accept: vi.fn(async () => {}),
cancel: vi.fn(async () => {}),
startVerification: vi.fn(),
scanQRCode: vi.fn(),
generateQRCode: vi.fn(),
on: vi.fn(),
verifier: undefined,
};
const tracked = {
id: "verification-1",
transactionId: "txn-self-in-progress",
otherUserId: "@bot:example.org",
isSelfVerification: true,
initiatedByMe: true,
phase: 2,
phaseName: "ready",
pending: true,
methods: ["m.sas.v1"],
canAccept: false,
hasSas: false,
hasReciprocateQr: false,
completed: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const trackVerificationRequest = vi.fn(() => tracked);
const listVerifications = vi.fn(() => [tracked]);
const crypto = {
getVerificationRequestsToDeviceInProgress: vi.fn(() => [request]),
requestOwnUserVerification: vi.fn(async () => null),
};
const { facade } = createFacadeHarness({
client: {
getCrypto: () => crypto,
getUserId: () => "@bot:example.org",
},
verificationManager: {
listVerifications,
trackVerificationRequest,
},
});
const summaries = await facade.listVerifications();
expect(crypto.getVerificationRequestsToDeviceInProgress).toHaveBeenCalledWith(
"@bot:example.org",
);
expect(trackVerificationRequest).toHaveBeenCalledWith(request);
expect(summaries).toEqual([tracked]);
});
});

View File

@@ -10,6 +10,7 @@ import type {
type MatrixCryptoFacadeClient = {
getRoom: (roomId: string) => { hasEncryptionStateEvent: () => boolean } | null;
getCrypto: () => unknown;
getUserId: () => string | null;
};
export type MatrixCryptoFacade = {
@@ -72,6 +73,20 @@ async function loadMatrixCryptoNodeRuntime(): Promise<MatrixCryptoNodeRuntime> {
return await matrixCryptoNodeRuntimePromise;
}
function trackInProgressToDeviceVerifications(deps: {
client: MatrixCryptoFacadeClient;
verificationManager: MatrixVerificationManager;
}) {
const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined;
const userId = deps.client.getUserId();
if (!userId || typeof crypto?.getVerificationRequestsToDeviceInProgress !== "function") {
return;
}
for (const request of crypto.getVerificationRequestsToDeviceInProgress(userId)) {
deps.verificationManager.trackVerificationRequest(request);
}
}
export function createMatrixCryptoFacade(deps: {
client: MatrixCryptoFacadeClient;
verificationManager: MatrixVerificationManager;
@@ -159,6 +174,7 @@ export function createMatrixCryptoFacade(deps: {
return deps.recoveryKeyStore.getRecoveryKeySummary();
},
listVerifications: async () => {
trackInProgressToDeviceVerifications(deps);
return deps.verificationManager.listVerifications();
},
ensureVerificationDmTracked: async ({ roomId, userId }) => {
@@ -177,30 +193,39 @@ export function createMatrixCryptoFacade(deps: {
return await deps.verificationManager.requestVerification(crypto, params);
},
acceptVerification: async (id) => {
trackInProgressToDeviceVerifications(deps);
return await deps.verificationManager.acceptVerification(id);
},
cancelVerification: async (id, params) => {
trackInProgressToDeviceVerifications(deps);
return await deps.verificationManager.cancelVerification(id, params);
},
startVerification: async (id, method = "sas") => {
trackInProgressToDeviceVerifications(deps);
return await deps.verificationManager.startVerification(id, method);
},
generateVerificationQr: async (id) => {
trackInProgressToDeviceVerifications(deps);
return await deps.verificationManager.generateVerificationQr(id);
},
scanVerificationQr: async (id, qrDataBase64) => {
trackInProgressToDeviceVerifications(deps);
return await deps.verificationManager.scanVerificationQr(id, qrDataBase64);
},
confirmVerificationSas: async (id) => {
trackInProgressToDeviceVerifications(deps);
return await deps.verificationManager.confirmVerificationSas(id);
},
mismatchVerificationSas: async (id) => {
trackInProgressToDeviceVerifications(deps);
return deps.verificationManager.mismatchVerificationSas(id);
},
confirmVerificationReciprocateQr: async (id) => {
trackInProgressToDeviceVerifications(deps);
return deps.verificationManager.confirmVerificationReciprocateQr(id);
},
getVerificationSas: async (id) => {
trackInProgressToDeviceVerifications(deps);
return deps.verificationManager.getVerificationSas(id);
},
};

View File

@@ -34,6 +34,7 @@ export class MatrixRecoveryKeyStore {
{ key: Uint8Array; keyInfo?: MatrixStoredRecoveryKey["keyInfo"] }
>();
private stagedRecoveryKey: MatrixStoredRecoveryKey | null = null;
private stagedRecoveryKeyUsed = false;
private readonly stagedCacheKeyIds = new Set<string>();
constructor(private readonly recoveryKeyPath?: string) {}
@@ -46,6 +47,11 @@ export class MatrixRecoveryKeyStore {
return null;
}
const staged = this.resolveStagedSecretStorageKey(requestedKeyIds);
if (staged) {
return staged;
}
for (const keyId of requestedKeyIds) {
const cached = this.secretStorageKeyCache.get(keyId);
if (cached) {
@@ -53,22 +59,6 @@ export class MatrixRecoveryKeyStore {
}
}
const staged = this.stagedRecoveryKey;
if (staged?.privateKeyBase64) {
const privateKey = new Uint8Array(Buffer.from(staged.privateKeyBase64, "base64"));
if (privateKey.length > 0) {
const stagedKeyId =
staged.keyId && requestedKeyIds.includes(staged.keyId)
? staged.keyId
: requestedKeyIds[0];
if (stagedKeyId) {
this.rememberSecretStorageKey(stagedKeyId, privateKey, staged.keyInfo);
this.stagedCacheKeyIds.add(stagedKeyId);
return [stagedKeyId, privateKey];
}
}
}
const stored = this.loadStoredRecoveryKey();
if (!stored?.privateKeyBase64) {
return null;
@@ -196,6 +186,10 @@ export class MatrixRecoveryKeyStore {
};
}
hasStagedRecoveryKeyBeenUsed(): boolean {
return this.stagedRecoveryKeyUsed;
}
commitStagedRecoveryKey(params?: {
keyId?: string | null;
keyInfo?: MatrixStoredRecoveryKey["keyInfo"];
@@ -264,19 +258,24 @@ export class MatrixRecoveryKeyStore {
if (recoveryKey && status?.defaultKeyId) {
const defaultKeyId = status.defaultKeyId;
this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo);
if (!stagedRecovery && storedRecovery && storedRecovery.keyId !== defaultKeyId) {
this.saveRecoveryKeyToDisk({
keyId: defaultKeyId,
keyInfo: recoveryKey.keyInfo,
privateKey: recoveryKey.privateKey,
encodedPrivateKey: recoveryKey.encodedPrivateKey,
});
if (!stagedRecovery) {
this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo);
if (storedRecovery && storedRecovery.keyId !== defaultKeyId) {
this.saveRecoveryKeyToDisk({
keyId: defaultKeyId,
keyInfo: recoveryKey.keyInfo,
privateKey: recoveryKey.privateKey,
encodedPrivateKey: recoveryKey.encodedPrivateKey,
});
}
}
}
const ensureRecoveryKey = async (): Promise<MatrixGeneratedSecretStorageKey> => {
if (recoveryKey) {
if (stagedRecovery) {
this.stagedRecoveryKeyUsed = true;
}
return recoveryKey;
}
if (typeof crypto.createRecoveryKeyFromPassphrase !== "function") {
@@ -347,9 +346,38 @@ export class MatrixRecoveryKeyStore {
private clearStagedRecoveryKeyTracking(): void {
this.stagedRecoveryKey = null;
this.stagedRecoveryKeyUsed = false;
this.stagedCacheKeyIds.clear();
}
private resolveStagedSecretStorageKey(requestedKeyIds: string[]): [string, Uint8Array] | null {
const staged = this.stagedRecoveryKey;
if (!staged?.privateKeyBase64) {
return null;
}
const privateKey = new Uint8Array(Buffer.from(staged.privateKeyBase64, "base64"));
if (privateKey.length === 0) {
return null;
}
const keyId =
staged.keyId && requestedKeyIds.includes(staged.keyId) ? staged.keyId : requestedKeyIds[0];
if (!keyId) {
return null;
}
this.rememberStagedSecretStorageKey(keyId, privateKey, staged.keyInfo);
this.stagedCacheKeyIds.add(keyId);
return [keyId, privateKey];
}
private rememberStagedSecretStorageKey(
keyId: string,
key: Uint8Array,
keyInfo?: MatrixStoredRecoveryKey["keyInfo"],
): void {
this.stagedRecoveryKeyUsed = true;
this.rememberSecretStorageKey(keyId, key, keyInfo);
}
private rememberSecretStorageKey(
keyId: string,
key: Uint8Array,

View File

@@ -232,6 +232,14 @@ export type MatrixCryptoBootstrapApi = {
}) => Promise<MatrixRoomKeyBackupRestoreResult>;
setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise<void>;
crossSignDevice?: (deviceId: string) => Promise<void>;
getOwnIdentity?: () => Promise<
| {
free?: () => void;
isVerified?: () => boolean;
verify?: () => Promise<unknown>;
}
| undefined
>;
isCrossSigningReady?: () => Promise<boolean>;
userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise<boolean>;
};

View File

@@ -188,6 +188,73 @@ describe("MatrixVerificationManager", () => {
expect(secondSummary.chosenMethod).toBe("m.sas.v1");
});
it("reuses the tracked id when the other device id is populated later", () => {
const manager = new MatrixVerificationManager();
const first = new MockVerificationRequest({
transactionId: "txn-device-later",
phase: VerificationPhase.Requested,
});
const second = new MockVerificationRequest({
transactionId: "txn-device-later",
phase: VerificationPhase.Ready,
otherDeviceId: "DEVICE_LATER",
pending: false,
});
const firstSummary = manager.trackVerificationRequest(first);
const secondSummary = manager.trackVerificationRequest(second);
expect(secondSummary.id).toBe(firstSummary.id);
expect(secondSummary.otherDeviceId).toBe("DEVICE_LATER");
expect(manager.listVerifications()).toHaveLength(1);
});
it("keeps separate sessions when stable other device ids differ", () => {
const manager = new MatrixVerificationManager();
const first = new MockVerificationRequest({
transactionId: "txn-different-devices",
otherDeviceId: "DEVICE_A",
});
const second = new MockVerificationRequest({
transactionId: "txn-different-devices",
otherDeviceId: "DEVICE_B",
});
const firstSummary = manager.trackVerificationRequest(first);
const secondSummary = manager.trackVerificationRequest(second);
expect(secondSummary.id).not.toBe(firstSummary.id);
expect(manager.listVerifications()).toHaveLength(2);
});
it("does not overwrite a different verification request with a colliding transaction ID", async () => {
const manager = new MatrixVerificationManager();
const first = new MockVerificationRequest({
transactionId: "txn-collision",
initiatedByMe: true,
otherUserId: "@alice:example.org",
otherDeviceId: "ALICE1",
});
const second = new MockVerificationRequest({
transactionId: "txn-collision",
initiatedByMe: true,
otherUserId: "@mallory:example.org",
otherDeviceId: "MALLORY1",
});
const firstSummary = manager.trackVerificationRequest(first);
const secondSummary = manager.trackVerificationRequest(second);
expect(secondSummary.id).not.toBe(firstSummary.id);
expect(manager.listVerifications()).toHaveLength(2);
expect(() => manager.getVerificationSas("txn-collision")).toThrow(
"Matrix verification request id is ambiguous for transaction txn-collision",
);
await manager.acceptVerification(firstSummary.id);
expect(first.accept).toHaveBeenCalledTimes(1);
expect(second.accept).not.toHaveBeenCalled();
});
it("starts SAS verification and exposes SAS payload/callback flow", async () => {
const confirm = vi.fn(async () => {});
const mismatch = vi.fn();
@@ -231,6 +298,49 @@ describe("MatrixVerificationManager", () => {
expect(mismatch).toHaveBeenCalledTimes(1);
});
it("cross-signs the other own device after confirmed self-verification SAS", async () => {
const { confirm, verifier } = createSasVerifierFixture({
decimal: [111, 222, 333],
emoji: [["cat", "cat"]],
});
const trustOwnDeviceAfterSas = vi.fn(async () => {});
const request = new MockVerificationRequest({
isSelfVerification: true,
otherDeviceId: "OTHERDEVICE",
transactionId: "txn-self-sas",
verifier,
});
const manager = new MatrixVerificationManager({ trustOwnDeviceAfterSas });
const tracked = manager.trackVerificationRequest(request);
await manager.startVerification(tracked.id, "sas");
await manager.confirmVerificationSas(tracked.id);
expect(confirm).toHaveBeenCalledTimes(1);
expect(trustOwnDeviceAfterSas).toHaveBeenCalledWith("OTHERDEVICE");
});
it("does not cross-sign non-self SAS verifications", async () => {
const { verifier } = createSasVerifierFixture({
decimal: [111, 222, 333],
emoji: [["cat", "cat"]],
});
const trustOwnDeviceAfterSas = vi.fn(async () => {});
const request = new MockVerificationRequest({
isSelfVerification: false,
otherDeviceId: "OTHERDEVICE",
transactionId: "txn-remote-sas",
verifier,
});
const manager = new MatrixVerificationManager({ trustOwnDeviceAfterSas });
const tracked = manager.trackVerificationRequest(request);
await manager.startVerification(tracked.id, "sas");
await manager.confirmVerificationSas(tracked.id);
expect(trustOwnDeviceAfterSas).not.toHaveBeenCalled();
});
it("auto-starts an incoming verifier exposed via request change events", async () => {
const { verifier, verify } = createSasVerifierFixture({
decimal: [6158, 1986, 3513],
@@ -410,6 +520,33 @@ describe("MatrixVerificationManager", () => {
}
});
it("does not cross-sign the other own device after auto-confirmed self-verification SAS", async () => {
vi.useFakeTimers();
const { confirm, verifier } = createSasVerifierFixture({
decimal: [6158, 1986, 3513],
emoji: [["gift", "Gift"]],
});
const trustOwnDeviceAfterSas = vi.fn(async () => {});
const request = new MockVerificationRequest({
isSelfVerification: true,
otherDeviceId: "OTHERDEVICE",
transactionId: "txn-auto-confirm-self",
initiatedByMe: false,
verifier,
});
try {
const manager = new MatrixVerificationManager({ trustOwnDeviceAfterSas });
manager.trackVerificationRequest(request);
await vi.advanceTimersByTimeAsync(30_100);
expect(confirm).toHaveBeenCalledTimes(1);
expect(trustOwnDeviceAfterSas).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("does not auto-confirm SAS for verifications initiated by this device", async () => {
vi.useFakeTimers();
const confirm = vi.fn(async () => {});

View File

@@ -52,6 +52,7 @@ export type MatrixVerificationSummary = {
};
type MatrixVerificationSummaryListener = (summary: MatrixVerificationSummary) => void;
type MatrixVerificationOwnerTrustCallback = (deviceId: string) => Promise<void>;
export type MatrixShowSasCallbacks = {
sas: {
@@ -101,6 +102,7 @@ export type MatrixVerificationRequestLike = {
export type MatrixVerificationCryptoApi = {
requestOwnUserVerification: () => Promise<MatrixVerificationRequestLike | null>;
getVerificationRequestsToDeviceInProgress?: (userId: string) => MatrixVerificationRequestLike[];
findVerificationRequestDMInProgress?: (
roomId: string,
userId: string,
@@ -132,6 +134,15 @@ type MatrixVerificationSession = {
reciprocateQrCallbacks?: MatrixShowQrCodeCallbacks;
};
type MatrixVerificationRequestIdentity = {
transactionId: string;
roomId: string;
otherUserId: string;
otherDeviceId: string;
isSelfVerification: boolean;
initiatedByMe: boolean;
};
const MAX_TRACKED_VERIFICATION_SESSIONS = 256;
const TERMINAL_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000;
const SAS_AUTO_CONFIRM_DELAY_MS = 30_000;
@@ -143,6 +154,12 @@ export class MatrixVerificationManager {
private readonly trackedVerificationVerifiers = new WeakSet<object>();
private readonly summaryListeners = new Set<MatrixVerificationSummaryListener>();
constructor(
private readonly opts: {
trustOwnDeviceAfterSas?: MatrixVerificationOwnerTrustCallback;
} = {},
) {}
private readRequestValue<T>(
request: MatrixVerificationRequestLike,
reader: () => T,
@@ -163,6 +180,40 @@ export class MatrixVerificationManager {
return isMatrixVerificationPhase(phase) ? phase : fallback;
}
private readVerificationRequestIdentity(
request: MatrixVerificationRequestLike,
): MatrixVerificationRequestIdentity {
return {
transactionId: this.readRequestValue(request, () => request.transactionId?.trim() ?? "", ""),
roomId: this.readRequestValue(request, () => request.roomId ?? "", ""),
otherUserId: this.readRequestValue(request, () => request.otherUserId, ""),
otherDeviceId: this.readRequestValue(request, () => request.otherDeviceId ?? "", ""),
isSelfVerification: this.readRequestValue(request, () => request.isSelfVerification, false),
initiatedByMe: this.readRequestValue(request, () => request.initiatedByMe, false),
};
}
private isSameLogicalVerificationRequest(
left: MatrixVerificationRequestLike,
right: MatrixVerificationRequestLike,
): boolean {
const leftIdentity = this.readVerificationRequestIdentity(left);
const rightIdentity = this.readVerificationRequestIdentity(right);
return (
leftIdentity.transactionId !== "" &&
leftIdentity.transactionId === rightIdentity.transactionId &&
leftIdentity.roomId === rightIdentity.roomId &&
leftIdentity.otherUserId === rightIdentity.otherUserId &&
this.isSameOptionalIdentityValue(leftIdentity.otherDeviceId, rightIdentity.otherDeviceId) &&
leftIdentity.isSelfVerification === rightIdentity.isSelfVerification &&
leftIdentity.initiatedByMe === rightIdentity.initiatedByMe
);
}
private isSameOptionalIdentityValue(left: string, right: string): boolean {
return left === "" || right === "" || left === right;
}
private pruneVerificationSessions(nowMs: number): void {
for (const [id, session] of this.verificationSessions) {
const phase = this.readVerificationPhase(session.request, -1);
@@ -276,11 +327,21 @@ export class MatrixVerificationManager {
if (direct) {
return direct;
}
for (const session of this.verificationSessions.values()) {
const txId = this.readRequestValue(session.request, () => session.request.transactionId, "");
if (txId === id) {
return session;
}
const transactionMatches = Array.from(this.verificationSessions.values()).filter((session) => {
const txId = this.readRequestValue(
session.request,
() => session.request.transactionId?.trim(),
"",
);
return txId === id;
});
if (transactionMatches.length === 1) {
return transactionMatches[0];
}
if (transactionMatches.length > 1) {
throw new Error(
`Matrix verification request id is ambiguous for transaction ${id}; use the verification id instead`,
);
}
throw new Error(`Matrix verification request not found: ${id}`);
}
@@ -443,8 +504,7 @@ export class MatrixVerificationManager {
return;
}
session.sasAutoConfirmStarted = true;
void callbacks
.confirm()
void this.confirmSasForSession(session, callbacks, { trustOwnDevice: false })
.then(() => {
this.touchVerificationSession(session);
})
@@ -455,6 +515,17 @@ export class MatrixVerificationManager {
}, SAS_AUTO_CONFIRM_DELAY_MS);
}
private async confirmSasForSession(
session: MatrixVerificationSession,
callbacks: MatrixShowSasCallbacks,
opts: { trustOwnDevice: boolean } = { trustOwnDevice: true },
): Promise<void> {
await callbacks.confirm();
if (opts.trustOwnDevice) {
await this.trustOwnDeviceAfterConfirmedSas(session);
}
}
private ensureVerificationStarted(session: MatrixVerificationSession): void {
if (!session.activeVerifier || session.verifyStarted) {
return;
@@ -472,6 +543,21 @@ export class MatrixVerificationManager {
});
}
private async trustOwnDeviceAfterConfirmedSas(session: MatrixVerificationSession): Promise<void> {
if (!this.readRequestValue(session.request, () => session.request.isSelfVerification, false)) {
return;
}
const deviceId = this.readRequestValue(
session.request,
() => session.request.otherDeviceId?.trim(),
"",
);
if (!deviceId || !this.opts.trustOwnDeviceAfterSas) {
return;
}
await this.opts.trustOwnDeviceAfterSas(deviceId);
}
onSummaryChanged(listener: MatrixVerificationSummaryListener): () => void {
this.summaryListeners.add(listener);
return () => {
@@ -481,15 +567,17 @@ export class MatrixVerificationManager {
trackVerificationRequest(request: MatrixVerificationRequestLike): MatrixVerificationSummary {
this.pruneVerificationSessions(Date.now());
const txId = this.readRequestValue(request, () => request.transactionId?.trim(), "");
const requestObj = request as unknown as object;
for (const existing of this.verificationSessions.values()) {
if ((existing.request as unknown as object) === requestObj) {
this.touchVerificationSession(existing);
return this.buildVerificationSummary(existing);
}
}
const txId = this.readVerificationRequestIdentity(request).transactionId;
if (txId) {
for (const existing of this.verificationSessions.values()) {
const existingTxId = this.readRequestValue(
existing.request,
() => existing.request.transactionId,
"",
);
if (existingTxId === txId) {
if (this.isSameLogicalVerificationRequest(existing.request, request)) {
existing.request = request;
this.ensureVerificationRequestTracked(existing);
const verifier = this.readRequestValue(request, () => request.verifier, null);
@@ -643,7 +731,7 @@ export class MatrixVerificationManager {
this.clearSasAutoConfirmTimer(session);
session.sasCallbacks = callbacks;
session.sasAutoConfirmStarted = true;
await callbacks.confirm();
await this.confirmSasForSession(session, callbacks);
this.touchVerificationSession(session);
return this.buildVerificationSummary(session);
}

View File

@@ -9,7 +9,7 @@ export function isMatrixDeviceLocallyVerified(
export function isMatrixDeviceOwnerVerified(
status: MatrixDeviceVerificationStatusLike | null | undefined,
): boolean {
return status?.crossSigningVerified === true || status?.signedByOwner === true;
return status?.crossSigningVerified === true;
}
export function isMatrixDeviceVerifiedInCurrentClient(