diff --git a/CHANGELOG.md b/CHANGELOG.md index f842f8cd405..aa05ec0f67c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Docs: https://docs.openclaw.ai +## Unreleased + +### Changes + +- Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras. + ## 2026.4.24 ### Breaking @@ -218,6 +224,7 @@ Docs: https://docs.openclaw.ai - Codex harness/hooks: fire `llm_input`, `llm_output`, and `agent_end` for native Codex app-server turns so lifecycle hooks stop drifting from Pi. Thanks @vincentkoc. - QA/Telegram: record per-scenario reply RTT in the live Telegram QA report and summary, starting with the canary response. (#70550) Thanks @obviyus. - Status: add an explicit `Runner:` field to `/status` so sessions now report whether they are running on embedded Pi, a CLI-backed provider, or an ACP harness agent/backend such as `codex (acp/acpx)` or `gemini (acp/acpx)`. (#70595) +- Gateway/diagnostics: enable payload-free stability recording by default and add a support-ready diagnostics export with sanitized logs, status, health, config, and stability snapshots for bug reports. (#70324) Thanks @gumadeiras. ### Fixes diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 0363c188c09..e1160ef8b8f 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -310,16 +310,127 @@ Enable encryption: Verification commands (all take `--verbose` for diagnostics and `--json` for machine-readable output): -| Command | Purpose | -| -------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `openclaw matrix verify status` | Check cross-signing and device verification state | -| `openclaw matrix verify status --include-recovery-key --json` | Include the stored recovery key | -| `openclaw matrix verify bootstrap` | Bootstrap cross-signing and verification (see below) | -| `openclaw matrix verify bootstrap --force-reset-cross-signing` | Discard the current cross-signing identity and create a new one | -| `openclaw matrix verify device ""` | Verify this device with a recovery key | -| `openclaw matrix verify backup status` | Check room-key backup health | -| `openclaw matrix verify backup restore` | Restore room keys from server backup | -| `openclaw matrix verify backup reset --yes` | Delete the current backup and create a fresh baseline (may recreate secret storage) | +```bash +openclaw matrix verify status +``` + +Verbose status (full diagnostics): + +```bash +openclaw matrix verify status --verbose +``` + +Include the stored recovery key in machine-readable output: + +```bash +openclaw matrix verify status --include-recovery-key --json +``` + +Bootstrap cross-signing and verification state: + +```bash +openclaw matrix verify bootstrap +``` + +Verbose bootstrap diagnostics: + +```bash +openclaw matrix verify bootstrap --verbose +``` + +Force a fresh cross-signing identity reset before bootstrapping: + +```bash +openclaw matrix verify bootstrap --force-reset-cross-signing +``` + +Verify this device with a recovery key: + +```bash +openclaw matrix verify device "" +``` + +This command reports three separate states: + +- `Recovery key accepted`: Matrix accepted the recovery key for secret storage or device trust. +- `Backup usable`: room-key backup can be loaded with trusted recovery material. +- `Device verified by owner`: the current OpenClaw device has full Matrix cross-signing identity trust. + +`Signed by owner` in verbose or JSON output is diagnostic only. OpenClaw does not +treat that as sufficient unless `Cross-signing verified` is also `yes`. + +The command still exits non-zero when full Matrix identity trust is incomplete, +even if the recovery key can unlock backup material. In that case, complete +self-verification from another Matrix client: + +```bash +openclaw matrix verify self +``` + +Accept the request in another Matrix client, compare the SAS emoji or decimals, +and type `yes` only when they match. The command waits for Matrix to report +`Cross-signing verified: yes` before it exits successfully. + +Use `verify bootstrap --force-reset-cross-signing` only when you intentionally +want to replace the current cross-signing identity. + +Verbose device verification details: + +```bash +openclaw matrix verify device "" --verbose +``` + +Check room-key backup health: + +```bash +openclaw matrix verify backup status +``` + +Verbose backup health diagnostics: + +```bash +openclaw matrix verify backup status --verbose +``` + +Restore room keys from server backup: + +```bash +openclaw matrix verify backup restore +``` + +Interactive self-verification flow: + +```bash +openclaw matrix verify self +``` + +For lower-level or inbound verification requests, use: + +```bash +openclaw matrix verify accept +openclaw matrix verify start +openclaw matrix verify sas +openclaw matrix verify confirm-sas +``` + +Use `openclaw matrix verify cancel ` to cancel a request. + +Verbose restore diagnostics: + +```bash +openclaw matrix verify backup restore --verbose +``` + +Delete the current server backup and create a fresh backup baseline. If the stored +backup key cannot be loaded cleanly, this reset can also recreate secret storage so +future cold starts can load the new backup key: + +```bash +openclaw matrix verify backup reset --yes +``` + +All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`. +Use `--json` for full machine-readable output when scripting. In multi-account setups, Matrix CLI commands use the implicit Matrix default account unless you pass `--account `. If you configure multiple named accounts, set `channels.matrix.defaultAccount` first or those implicit CLI operations will stop and ask you to choose an account explicitly. @@ -341,7 +452,9 @@ When encryption is disabled or unavailable for a named account, Matrix warnings - `Cross-signing verified`: the SDK reports verification via cross-signing - `Signed by owner`: signed by your own self-signing key - `Verified by owner` becomes `yes` only when cross-signing or owner-signing is present. Local trust alone is not enough. + `Verified by owner` becomes `yes` only when cross-signing verification is present. + Local trust or an owner signature by itself is not enough for OpenClaw to treat + the device as fully verified. diff --git a/docs/install/migrating-matrix.md b/docs/install/migrating-matrix.md index cc31a6ce424..27e1dc98c67 100644 --- a/docs/install/migrating-matrix.md +++ b/docs/install/migrating-matrix.md @@ -105,6 +105,17 @@ If your old installation had local-only encrypted history that was never backed openclaw matrix verify device "" ``` + If the recovery key is accepted and backup is usable, but `Cross-signing verified` + is still `no`, complete self-verification from another Matrix client: + + ```bash + openclaw matrix verify self + ``` + + Accept the request in another Matrix client, compare the emoji or decimals, + and type `yes` only when they match. The command exits successfully only + after `Cross-signing verified` becomes `yes`. + 7. If you are intentionally abandoning unrecoverable old history and want a fresh backup baseline for future messages, run: ```bash @@ -293,10 +304,17 @@ new backup key can load correctly after restart. - Meaning: the provided key could not be parsed or did not match the expected format. - What to do: retry with the exact recovery key from your Matrix client or recovery-key file. -`Matrix device is still unverified after applying recovery key. Verify your recovery key and ensure cross-signing is available.` +`Matrix recovery key was applied, but this device still lacks full Matrix identity trust.` -- Meaning: the key was applied, but the device still could not complete verification. -- What to do: confirm you used the correct key and that cross-signing is available on the account, then retry. +- Meaning: OpenClaw could apply the recovery key, but Matrix still has not + established full cross-signing identity trust for this device. Check the + command output for `Recovery key accepted`, `Backup usable`, + `Cross-signing verified`, and `Device verified by owner`. +- What to do: run `openclaw matrix verify self`, accept the request in another + Matrix client, compare the SAS, and type `yes` only when it matches. The + command waits for full Matrix identity trust before reporting success. Use + `openclaw matrix verify bootstrap --recovery-key "" --force-reset-cross-signing` + only when you intentionally want to replace the current cross-signing identity. `Matrix key backup is not active on this device after loading from secret storage.` diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index b2d30804630..12fcd328304 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -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 = {}) { + 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 '' --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 '.", @@ -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 --account assistant' to verify this device.", + "- Run openclaw matrix verify device '' --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.", ); }); diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index a5455c14d3e..c67e0990c11 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -6,11 +6,20 @@ import { resolveMatrixAccount, resolveMatrixAccountConfig } from "./matrix/accou import { listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } from "./matrix/actions/devices.js"; import { updateMatrixOwnProfile } from "./matrix/actions/profile.js"; import { + acceptMatrixVerification, bootstrapMatrixVerification, + cancelMatrixVerification, + confirmMatrixVerificationSas, + getMatrixVerificationSas, getMatrixRoomKeyBackupStatus, getMatrixVerificationStatus, + listMatrixVerifications, + mismatchMatrixVerificationSas, + requestMatrixVerification, resetMatrixRoomKeyBackup, restoreMatrixRoomKeyBackup, + runMatrixSelfVerification, + startMatrixVerification, verifyMatrixRecoveryKey, } from "./matrix/actions/verification.js"; import { resolveMatrixRoomKeyBackupIssue } from "./matrix/backup-health.js"; @@ -53,7 +62,11 @@ function scheduleMatrixCliExit(): void { matrixCliExitScheduled = true; // matrix-js-sdk rust crypto can leave background async work alive after command completion. setTimeout(() => { - process.exit(process.exitCode ?? 0); + process.stdout.write("", () => { + process.stderr.write("", () => { + process.exit(process.exitCode ?? 0); + }); + }); }, 0); } @@ -66,7 +79,7 @@ function toErrorMessage(err: unknown): string { } function printJson(payload: unknown): void { - console.log(JSON.stringify(payload, null, 2)); + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); } function formatLocalTimestamp(value: string | null | undefined): string | null { @@ -83,17 +96,16 @@ function formatLocalTimestamp(value: string | null | undefined): string | null { function printTimestamp(label: string, value: string | null | undefined): void { const formatted = formatLocalTimestamp(value); if (formatted) { - console.log(`${label}: ${formatted}`); + console.log(`${label}: ${formatMatrixCliText(formatted)}`); } } function printAccountLabel(accountId?: string): void { - console.log(`Account: ${normalizeAccountId(accountId)}`); + console.log(`Account: ${formatMatrixCliText(normalizeAccountId(accountId))}`); } function resolveMatrixCliAccountId(accountId?: string): string { - const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig; - return resolveMatrixAuthContext({ cfg, accountId }).accountId; + return resolveMatrixCliAccountContext(accountId).accountId; } function resolveMatrixCliAccountContext(accountId?: string): { @@ -108,9 +120,32 @@ function resolveMatrixCliAccountContext(accountId?: string): { } function formatMatrixCliCommand(command: string, accountId?: string): string { + return formatMatrixCliCommandParts(command.split(" "), accountId); +} + +function formatMatrixCliCommandParts(parts: string[], accountId?: string): string { const normalizedAccountId = normalizeAccountId(accountId); - const suffix = normalizedAccountId === "default" ? "" : ` --account ${normalizedAccountId}`; - return `openclaw matrix ${command}${suffix}`; + const command = ["openclaw", "matrix", ...parts]; + if (normalizedAccountId !== "default") { + const optionTerminatorIndex = command.indexOf("--"); + if (optionTerminatorIndex >= 0) { + command.splice(optionTerminatorIndex, 0, "--account", normalizedAccountId); + } else { + command.push("--account", normalizedAccountId); + } + } + return command.map(formatMatrixCliShellArg).join(" "); +} + +function formatMatrixCliShellArg(value: string): string { + if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value)) { + return value; + } + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function formatMatrixCliText(value: string | null | undefined, fallback = "unknown"): string { + return sanitizeMatrixCliText(value ?? fallback); } function printMatrixOwnDevices( @@ -127,13 +162,17 @@ function printMatrixOwnDevices( return; } for (const device of devices) { - const labels = [device.current ? "current" : null, device.displayName].filter(Boolean); - console.log(`- ${device.deviceId}${labels.length ? ` (${labels.join(", ")})` : ""}`); + const labels = [device.current ? "current" : null, device.displayName] + .filter((label): label is string => Boolean(label)) + .map((label) => formatMatrixCliText(label)); + console.log( + `- ${formatMatrixCliText(device.deviceId)}${labels.length ? ` (${labels.join(", ")})` : ""}`, + ); if (device.lastSeenTs) { printTimestamp(" Last seen", new Date(device.lastSeenTs).toISOString()); } if (device.lastSeenIp) { - console.log(` Last IP: ${device.lastSeenIp}`); + console.log(` Last IP: ${formatMatrixCliText(device.lastSeenIp)}`); } } } @@ -301,7 +340,7 @@ async function addMatrixAccount(params: { staleOpenClawDeviceIds: [], }; try { - const addedDevices = await listMatrixOwnDevices({ accountId }); + const addedDevices = await listMatrixOwnDevices({ accountId, cfg: updated }); deviceHealth = { currentDeviceId: addedDevices.find((device) => device.current)?.deviceId ?? null, staleOpenClawDeviceIds: addedDevices @@ -328,22 +367,34 @@ async function addMatrixAccount(params: { function printDirectRoomCandidate(room: MatrixCliDirectRoomCandidate): void { const members = - room.joinedMembers === null ? "unavailable" : room.joinedMembers.join(", ") || "none"; + room.joinedMembers === null + ? "unavailable" + : room.joinedMembers.map((member) => formatMatrixCliText(member)).join(", ") || "none"; console.log( - `- ${room.roomId} [${room.source}] strict=${room.strict ? "yes" : "no"} joined=${members}`, + `- ${formatMatrixCliText(room.roomId)} [${room.source}] strict=${ + room.strict ? "yes" : "no" + } joined=${members}`, ); } function printDirectRoomInspection(result: MatrixCliDirectRoomInspection): void { printAccountLabel(result.accountId); - console.log(`Peer: ${result.remoteUserId}`); - console.log(`Self: ${result.selfUserId ?? "unknown"}`); - console.log(`Active direct room: ${result.activeRoomId ?? "none"}`); + console.log(`Peer: ${formatMatrixCliText(result.remoteUserId)}`); + console.log(`Self: ${formatMatrixCliText(result.selfUserId)}`); + console.log(`Active direct room: ${formatMatrixCliText(result.activeRoomId, "none")}`); console.log( - `Mapped rooms: ${result.mappedRoomIds.length ? result.mappedRoomIds.join(", ") : "none"}`, + `Mapped rooms: ${ + result.mappedRoomIds.length + ? result.mappedRoomIds.map((roomId) => formatMatrixCliText(roomId)).join(", ") + : "none" + }`, ); console.log( - `Discovered strict rooms: ${result.discoveredStrictRoomIds.length ? result.discoveredStrictRoomIds.join(", ") : "none"}`, + `Discovered strict rooms: ${ + result.discoveredStrictRoomIds.length + ? result.discoveredStrictRoomIds.map((roomId) => formatMatrixCliText(roomId)).join(", ") + : "none" + }`, ); if (result.mappedRooms.length > 0) { console.log("Mapped room details:"); @@ -357,12 +408,13 @@ async function inspectMatrixDirectRoom(params: { accountId: string; userId: string; }): Promise { + const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig; const [{ withResolvedActionClient }, { inspectMatrixDirectRooms }] = await Promise.all([ loadMatrixActionClientModule(), loadMatrixDirectManagementModule(), ]); return await withResolvedActionClient( - { accountId: params.accountId }, + { accountId: params.accountId, cfg }, async (client) => { const inspection = await inspectMatrixDirectRooms({ client, @@ -392,7 +444,7 @@ async function repairMatrixDirectRoom(params: { loadMatrixActionClientModule(), loadMatrixDirectManagementModule(), ]); - return await withStartedActionClient({ accountId: params.accountId }, async (client) => { + return await withStartedActionClient({ accountId: params.accountId, cfg }, async (client) => { const repaired = await repairMatrixDirectRooms({ client, remoteUserId: params.userId, @@ -459,7 +511,7 @@ async function runMatrixCliCommand( if (config.json) { printJson(config.onJsonError ? config.onJsonError(message) : { error: message }); } else { - console.error(`${config.errorPrefix}: ${message}`); + console.error(`${config.errorPrefix}: ${formatMatrixCliText(message)}`); } markCliFailure(); } finally { @@ -490,6 +542,46 @@ type MatrixCliVerificationStatus = { recoveryKeyStored: boolean; recoveryKeyCreatedAt: string | null; pendingVerifications: number; + recoveryKeyAccepted?: boolean; + backupUsable?: boolean; + deviceOwnerVerified?: boolean; +}; + +type MatrixCliVerificationCommandOptions = { + account?: string; + userId?: string; + roomId?: string; + verbose?: boolean; + json?: boolean; +}; + +type MatrixCliSelfVerificationCommandOptions = { + account?: string; + timeoutMs?: string; + verbose?: boolean; +}; + +type MatrixCliVerificationSummary = { + id: string; + transactionId?: string; + roomId?: string; + otherUserId: string; + otherDeviceId?: string; + isSelfVerification: boolean; + initiatedByMe: boolean; + phaseName: string; + pending: boolean; + methods: string[]; + chosenMethod?: string | null; + hasSas: boolean; + sas?: MatrixCliVerificationSas; + completed: boolean; + error?: string; +}; + +type MatrixCliVerificationSas = { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; }; type MatrixCliDirectRoomCandidate = { @@ -552,14 +644,14 @@ function yesNoUnknown(value: boolean | null): string { } function printBackupStatus(backup: MatrixCliBackupStatus): void { - console.log(`Backup server version: ${backup.serverVersion ?? "none"}`); - console.log(`Backup active on this device: ${backup.activeVersion ?? "no"}`); + console.log(`Backup server version: ${formatMatrixCliText(backup.serverVersion, "none")}`); + console.log(`Backup active on this device: ${formatMatrixCliText(backup.activeVersion, "no")}`); console.log(`Backup trusted by this device: ${yesNoUnknown(backup.trusted)}`); console.log(`Backup matches local decryption key: ${yesNoUnknown(backup.matchesDecryptionKey)}`); console.log(`Backup key cached locally: ${yesNoUnknown(backup.decryptionKeyCached)}`); console.log(`Backup key load attempted: ${yesNoUnknown(backup.keyLoadAttempted)}`); if (backup.keyLoadError) { - console.log(`Backup key load error: ${backup.keyLoadError}`); + console.log(`Backup key load error: ${formatMatrixCliText(backup.keyLoadError)}`); } } @@ -567,8 +659,8 @@ function printVerificationIdentity(status: { userId: string | null; deviceId: string | null; }): void { - console.log(`User: ${status.userId ?? "unknown"}`); - console.log(`Device: ${status.deviceId ?? "unknown"}`); + console.log(`User: ${formatMatrixCliText(status.userId)}`); + console.log(`Device: ${formatMatrixCliText(status.deviceId)}`); } function printVerificationBackupSummary(status: { @@ -595,6 +687,346 @@ function printVerificationTrustDiagnostics(status: { console.log(`Signed by owner: ${status.signedByOwner ? "yes" : "no"}`); } +function sanitizeMatrixCliText(value: string): string { + let withoutAnsi = ""; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code === 0x9b) { + index++; + while (index < value.length && !isAnsiFinalByte(value.charCodeAt(index))) { + index++; + } + continue; + } + if (code === 0x9d) { + index++; + while (index < value.length) { + const current = value.charCodeAt(index); + if (current === 0x07 || current === 0x9c) { + break; + } + if (current === 0x1b && value[index + 1] === "\\") { + index++; + break; + } + index++; + } + continue; + } + if (code === 0x90 || code === 0x9e || code === 0x9f) { + index++; + while (index < value.length) { + const current = value.charCodeAt(index); + if (current === 0x07 || current === 0x9c) { + break; + } + if (current === 0x1b && value[index + 1] === "\\") { + index++; + break; + } + index++; + } + continue; + } + if (code !== 0x1b) { + withoutAnsi += value[index]; + continue; + } + + const marker = value[index + 1]; + if (marker === "[") { + index += 2; + while (index < value.length && !isAnsiFinalByte(value.charCodeAt(index))) { + index++; + } + continue; + } + if (marker === "]") { + index += 2; + while (index < value.length) { + const current = value.charCodeAt(index); + if (current === 0x07) { + break; + } + if (current === 0x1b && value[index + 1] === "\\") { + index++; + break; + } + index++; + } + continue; + } + index++; + } + + let sanitized = ""; + for (const character of withoutAnsi) { + const code = character.charCodeAt(0); + if (!isUnsafeMatrixCliTerminalCode(code)) { + sanitized += character; + } + } + return sanitized; +} + +function isUnsafeMatrixCliTerminalCode(code: number): boolean { + return ( + code < 0x20 || + code === 0x7f || + (code >= 0x80 && code <= 0x9f) || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2066 && code <= 0x2069) + ); +} + +function isAnsiFinalByte(code: number): boolean { + return code >= 0x40 && code <= 0x7e; +} + +function formatMatrixCliSasEmoji(emoji: NonNullable): string { + return emoji + .map( + ([emojiValue, label]) => + `${sanitizeMatrixCliText(emojiValue)} ${sanitizeMatrixCliText(label)}`, + ) + .join(" | "); +} + +function printMatrixVerificationSummary(summary: MatrixCliVerificationSummary): void { + console.log(`Verification id: ${sanitizeMatrixCliText(summary.id)}`); + if (summary.transactionId) { + console.log(`Transaction id: ${sanitizeMatrixCliText(summary.transactionId)}`); + } + if (summary.roomId) { + console.log(`Room id: ${sanitizeMatrixCliText(summary.roomId)}`); + } + console.log(`Other user: ${sanitizeMatrixCliText(summary.otherUserId)}`); + console.log(`Other device: ${sanitizeMatrixCliText(summary.otherDeviceId ?? "unknown")}`); + console.log(`Self-verification: ${summary.isSelfVerification ? "yes" : "no"}`); + console.log(`Initiated by OpenClaw: ${summary.initiatedByMe ? "yes" : "no"}`); + console.log(`Phase: ${sanitizeMatrixCliText(summary.phaseName)}`); + console.log(`Pending: ${summary.pending ? "yes" : "no"}`); + console.log(`Completed: ${summary.completed ? "yes" : "no"}`); + console.log( + `Methods: ${ + summary.methods.length ? summary.methods.map(sanitizeMatrixCliText).join(", ") : "none" + }`, + ); + if (summary.chosenMethod) { + console.log(`Chosen method: ${sanitizeMatrixCliText(summary.chosenMethod)}`); + } + if (summary.hasSas && summary.sas?.emoji?.length) { + console.log(`SAS emoji: ${formatMatrixCliSasEmoji(summary.sas.emoji)}`); + } else if (summary.hasSas && summary.sas?.decimal) { + console.log(`SAS decimals: ${summary.sas.decimal.join(" ")}`); + } + if (summary.error) { + console.log(`Verification error: ${sanitizeMatrixCliText(summary.error)}`); + } +} + +function printMatrixVerificationSummaries(summaries: MatrixCliVerificationSummary[]): void { + if (summaries.length === 0) { + console.log("Verifications: none"); + return; + } + summaries.forEach((summary, index) => { + if (index > 0) { + console.log(""); + } + printMatrixVerificationSummary(summary); + }); +} + +function printMatrixVerificationSas(sas: MatrixCliVerificationSas): void { + if (sas.emoji?.length) { + console.log(`SAS emoji: ${formatMatrixCliSasEmoji(sas.emoji)}`); + } else if (sas.decimal) { + console.log(`SAS decimals: ${sas.decimal.join(" ")}`); + } else { + console.log("SAS: unavailable"); + } +} + +function matrixCliVerificationDmLookupOptions(options: MatrixCliVerificationCommandOptions): { + verificationDmRoomId?: string; + verificationDmUserId?: string; +} { + const lookup: { + verificationDmRoomId?: string; + verificationDmUserId?: string; + } = {}; + if (options.roomId !== undefined) { + lookup.verificationDmRoomId = options.roomId; + } + if (options.userId !== undefined) { + lookup.verificationDmUserId = options.userId; + } + return lookup; +} + +function formatMatrixVerificationDmFollowupParts(params: { + roomId?: string; + userId?: string; +}): string[] { + if (!params.roomId || !params.userId) { + return []; + } + return [ + "--user-id", + sanitizeMatrixCliText(params.userId), + "--room-id", + sanitizeMatrixCliText(params.roomId), + ]; +} + +function formatMatrixVerificationSummaryDmFollowupParts( + summary: MatrixCliVerificationSummary, +): string[] { + return formatMatrixVerificationDmFollowupParts({ + roomId: summary.roomId, + userId: summary.otherUserId, + }); +} + +function formatMatrixVerificationOptionsDmFollowupParts( + options: MatrixCliVerificationCommandOptions, +): string[] { + return formatMatrixVerificationDmFollowupParts({ + roomId: options.roomId, + userId: options.userId, + }); +} + +function formatMatrixVerificationPreferredDmFollowupParts( + summary: MatrixCliVerificationSummary, + options: MatrixCliVerificationCommandOptions, +): string[] { + const summaryParts = formatMatrixVerificationSummaryDmFollowupParts(summary); + return summaryParts.length + ? summaryParts + : formatMatrixVerificationOptionsDmFollowupParts(options); +} + +function formatMatrixVerificationFollowupCommand(params: { + action: string; + requestId: string; + accountId?: string; + dmParts?: string[]; +}): string { + return formatMatrixCliCommandParts( + ["verify", params.action, ...(params.dmParts ?? []), "--", params.requestId], + params.accountId, + ); +} + +function printMatrixVerificationSasGuidance( + requestId: string, + accountId?: string, + dmParts: string[] = [], +): void { + printGuidance([ + `Compare the emoji or decimals with the other Matrix client.`, + `If they match, run ${formatMatrixVerificationFollowupCommand({ action: "confirm-sas", requestId, accountId, dmParts })}.`, + `If they do not match, run ${formatMatrixVerificationFollowupCommand({ action: "mismatch-sas", requestId, accountId, dmParts })}.`, + ]); +} + +function formatMatrixVerificationCommandId(summary: MatrixCliVerificationSummary): string { + return sanitizeMatrixCliText(summary.transactionId ?? summary.id); +} + +async function promptMatrixVerificationSasMatch(): Promise { + const { createInterface } = await import("node:readline/promises"); + const prompt = createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + const answer = await prompt.question("Do the emoji or decimals match? Type yes to confirm: "); + return /^(?:y|yes)$/i.test(answer.trim()); + } finally { + prompt.close(); + } +} + +function printMatrixVerificationRequestGuidance( + summary: MatrixCliVerificationSummary, + accountId?: string, +): void { + const requestId = formatMatrixVerificationCommandId(summary); + const dmParts = formatMatrixVerificationSummaryDmFollowupParts(summary); + printGuidance([ + `Accept the verification request in another Matrix client for this account.`, + `Then run ${formatMatrixVerificationFollowupCommand({ action: "start", requestId, accountId, dmParts })} to start SAS verification.`, + `Run ${formatMatrixVerificationFollowupCommand({ action: "sas", requestId, accountId, dmParts })} to display the SAS emoji or decimals.`, + `When the SAS matches, run ${formatMatrixVerificationFollowupCommand({ action: "confirm-sas", requestId, accountId, dmParts })}.`, + ]); +} + +async function runMatrixCliVerificationSummaryCommand(params: { + options: MatrixCliVerificationCommandOptions; + run: (accountId: string, cfg: CoreConfig) => Promise; + afterText?: (summary: MatrixCliVerificationSummary, accountId: string) => void; + errorPrefix: string; +}): Promise { + const { accountId, cfg } = resolveMatrixCliAccountContext(params.options.account); + await runMatrixCliCommand({ + verbose: params.options.verbose === true, + json: params.options.json === true, + run: async () => await params.run(accountId, cfg), + onText: (summary) => { + printAccountLabel(accountId); + printMatrixVerificationSummary(summary); + params.afterText?.(summary, accountId); + }, + errorPrefix: params.errorPrefix, + }); +} + +async function runMatrixCliSelfVerificationCommand( + options: MatrixCliSelfVerificationCommandOptions, +): Promise { + const { accountId, cfg } = resolveMatrixCliAccountContext(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: false, + run: async () => + await runMatrixSelfVerification({ + accountId, + cfg, + timeoutMs: parseOptionalInt(options.timeoutMs, "--timeout-ms"), + onRequested: (summary) => { + printAccountLabel(accountId); + printMatrixVerificationSummary(summary); + console.log("Accept this verification request in another Matrix client."); + }, + onReady: (summary) => { + console.log("Verification request accepted."); + if (!summary.hasSas) { + console.log("Starting SAS verification..."); + } + }, + onSas: (summary) => { + printMatrixVerificationSas(summary.sas ?? {}); + console.log("Compare this SAS with the other Matrix client."); + }, + confirmSas: async () => await promptMatrixVerificationSasMatch(), + }), + onText: (summary, verbose) => { + printMatrixVerificationSummary(summary); + console.log(`Device verified by owner: ${summary.deviceOwnerVerified ? "yes" : "no"}`); + printVerificationTrustDiagnostics(summary.ownerVerification); + printVerificationBackupSummary(summary.ownerVerification); + if (verbose) { + printVerificationBackupStatus(summary.ownerVerification); + } + console.log("Self-verification complete."); + }, + errorPrefix: "Self-verification failed", + }); +} + function printVerificationGuidance(status: MatrixCliVerificationStatus, accountId?: string): void { printGuidance(buildVerificationGuidance(status, accountId)); } @@ -603,7 +1035,7 @@ function printBackupSummary(backup: MatrixCliBackupStatus): void { const issue = resolveMatrixRoomKeyBackupIssue(backup); console.log(`Backup: ${issue.summary}`); if (backup.serverVersion) { - console.log(`Backup version: ${backup.serverVersion}`); + console.log(`Backup version: ${formatMatrixCliText(backup.serverVersion)}`); } } @@ -615,13 +1047,22 @@ function buildVerificationGuidance( const backupIssue = resolveMatrixRoomKeyBackupIssue(backup); const nextSteps = new Set(); if (!status.verified) { - nextSteps.add( - `Run '${formatMatrixCliCommand("verify device ", accountId)}' to verify this device.`, - ); + if (status.recoveryKeyAccepted === true && status.backupUsable === true) { + nextSteps.add( + `Recovery key can unlock the room-key backup, but full Matrix identity trust is still incomplete. Run ${formatMatrixCliCommand("verify self", accountId)} and follow the prompts from another Matrix client.`, + ); + nextSteps.add( + `If you intend to replace the current cross-signing identity, run ${formatMatrixCliCommand("verify bootstrap --recovery-key --force-reset-cross-signing", accountId)}.`, + ); + } else { + nextSteps.add( + `Run ${formatMatrixCliCommand("verify device ", accountId)} to verify this device.`, + ); + } } if (backupIssue.code === "missing-server-backup") { nextSteps.add( - `Run '${formatMatrixCliCommand("verify bootstrap", accountId)}' to create a room key backup.`, + `Run ${formatMatrixCliCommand("verify bootstrap", accountId)} to create a room key backup.`, ); } else if ( backupIssue.code === "key-load-failed" || @@ -630,30 +1071,30 @@ function buildVerificationGuidance( ) { if (status.recoveryKeyStored) { nextSteps.add( - `Backup key is not loaded on this device. Run '${formatMatrixCliCommand("verify backup restore", accountId)}' to load it and restore old room keys.`, + `Backup key is not loaded on this device. Run ${formatMatrixCliCommand("verify backup restore", accountId)} to load it and restore old room keys.`, ); } else { nextSteps.add( - `Store a recovery key with '${formatMatrixCliCommand("verify device ", accountId)}', then run '${formatMatrixCliCommand("verify backup restore", accountId)}'.`, + `Store a recovery key with ${formatMatrixCliCommand("verify device ", accountId)}, then run ${formatMatrixCliCommand("verify backup restore", accountId)}.`, ); } } else if (backupIssue.code === "key-mismatch") { nextSteps.add( - `Backup key mismatch on this device. Re-run '${formatMatrixCliCommand("verify device ", accountId)}' with the matching recovery key.`, + `Backup key mismatch on this device. Re-run ${formatMatrixCliCommand("verify device ", accountId)} with the matching recovery key.`, ); nextSteps.add( - `If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'. 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 ${formatMatrixCliCommand("verify backup reset --yes", accountId)}. This may also repair secret storage so the new backup key can be loaded after restart.`, ); } else if (backupIssue.code === "untrusted-signature") { nextSteps.add( - `Backup trust chain is not verified on this device. Re-run '${formatMatrixCliCommand("verify device ", accountId)}' if you have the correct recovery key.`, + `Backup trust chain is not verified on this device. Re-run ${formatMatrixCliCommand("verify device ", accountId)} if you have the correct recovery key.`, ); nextSteps.add( - `If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'. 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 ${formatMatrixCliCommand("verify backup reset --yes", accountId)}. This may also repair secret storage so the new backup key can be loaded after restart.`, ); } else if (backupIssue.code === "indeterminate") { nextSteps.add( - `Run '${formatMatrixCliCommand("verify status --verbose", accountId)}' to inspect backup trust diagnostics.`, + `Run ${formatMatrixCliCommand("verify status --verbose", accountId)} to inspect backup trust diagnostics.`, ); } if (status.pendingVerifications > 0) { @@ -765,8 +1206,8 @@ export function registerMatrixCli(params: { program: Command }): void { useEnv: options.useEnv === true, }), onText: (result) => { - console.log(`Saved matrix account: ${result.accountId}`); - console.log(`Config path: ${result.configPath}`); + console.log(`Saved matrix account: ${formatMatrixCliText(result.accountId)}`); + console.log(`Config path: ${formatMatrixCliText(result.configPath)}`); console.log( `Credentials source: ${result.useEnv ? "MATRIX_* / MATRIX__* env vars" : "inline config"}`, ); @@ -778,30 +1219,39 @@ export function registerMatrixCli(params: { program: Command }): void { result.verificationBootstrap.recoveryKeyCreatedAt, ); if (result.verificationBootstrap.backupVersion) { - console.log(`Backup version: ${result.verificationBootstrap.backupVersion}`); + console.log( + `Backup version: ${formatMatrixCliText(result.verificationBootstrap.backupVersion)}`, + ); } } else { console.error( - `Matrix verification bootstrap warning: ${result.verificationBootstrap.error}`, + `Matrix verification bootstrap warning: ${formatMatrixCliText(result.verificationBootstrap.error)}`, ); } } if (result.deviceHealth.error) { - console.error(`Matrix device health warning: ${result.deviceHealth.error}`); + console.error( + `Matrix device health warning: ${formatMatrixCliText(result.deviceHealth.error)}`, + ); } else if (result.deviceHealth.staleOpenClawDeviceIds.length > 0) { + const staleDeviceIds = result.deviceHealth.staleOpenClawDeviceIds + .map((deviceId) => formatMatrixCliText(deviceId)) + .join(", "); console.log( - `Matrix device hygiene warning: stale OpenClaw devices detected (${result.deviceHealth.staleOpenClawDeviceIds.join(", ")}). Run 'openclaw matrix devices prune-stale --account ${result.accountId}'.`, + `Matrix device hygiene warning: stale OpenClaw devices detected (${staleDeviceIds}). Run ${formatMatrixCliCommand("devices prune-stale", result.accountId)}.`, ); } if (result.profile.attempted) { if (result.profile.error) { - console.error(`Profile sync warning: ${result.profile.error}`); + console.error(`Profile sync warning: ${formatMatrixCliText(result.profile.error)}`); } else { console.log( `Profile sync: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`, ); if (result.profile.convertedAvatarFromHttp && result.profile.resolvedAvatarUrl) { - console.log(`Avatar converted and saved as: ${result.profile.resolvedAvatarUrl}`); + console.log( + `Avatar converted and saved as: ${formatMatrixCliText(result.profile.resolvedAvatarUrl)}`, + ); } } } @@ -847,7 +1297,9 @@ export function registerMatrixCli(params: { program: Command }): void { `Profile update: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`, ); if (result.profile.convertedAvatarFromHttp && result.avatarUrl) { - console.log(`Avatar converted and saved as: ${result.avatarUrl}`); + console.log( + `Avatar converted and saved as: ${formatMatrixCliText(result.avatarUrl)}`, + ); } }, errorPrefix: "Profile update failed", @@ -904,14 +1356,14 @@ export function registerMatrixCli(params: { program: Command }): void { onText: (result, verbose) => { printDirectRoomInspection(result); console.log(`Encrypted room creation: ${result.encrypted ? "enabled" : "disabled"}`); - console.log(`Created room: ${result.createdRoomId ?? "none"}`); + console.log(`Created room: ${formatMatrixCliText(result.createdRoomId, "none")}`); console.log(`m.direct updated: ${result.changed ? "yes" : "no"}`); if (verbose) { console.log( - `m.direct before: ${JSON.stringify(result.directContentBefore[result.remoteUserId] ?? [])}`, + `m.direct before: ${formatMatrixCliText(JSON.stringify(result.directContentBefore[result.remoteUserId] ?? []))}`, ); console.log( - `m.direct after: ${JSON.stringify(result.directContentAfter[result.remoteUserId] ?? [])}`, + `m.direct after: ${formatMatrixCliText(JSON.stringify(result.directContentAfter[result.remoteUserId] ?? []))}`, ); } }, @@ -922,6 +1374,253 @@ export function registerMatrixCli(params: { program: Command }): void { const verify = root.command("verify").description("Device verification for Matrix E2EE"); + verify + .command("list") + .description("List pending Matrix verification requests") + .option("--account ", "Account ID (for multi-account setups)") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => { + const { accountId, cfg } = resolveMatrixCliAccountContext(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => await listMatrixVerifications({ accountId, cfg }), + onText: (summaries) => { + printAccountLabel(accountId); + printMatrixVerificationSummaries(summaries); + }, + errorPrefix: "Verification listing failed", + }); + }); + + verify + .command("self") + .description("Interactively self-verify this Matrix device") + .option("--account ", "Account ID (for multi-account setups)") + .option("--timeout-ms ", "How long to wait for the other Matrix client") + .option("--verbose", "Show detailed diagnostics") + .action(async (options: MatrixCliSelfVerificationCommandOptions) => { + await runMatrixCliSelfVerificationCommand(options); + }); + + verify + .command("request") + .description("Request Matrix device verification from another Matrix client") + .option("--account ", "Account ID (for multi-account setups)") + .option("--own-user", "Request self-verification for this Matrix account") + .option("--user-id ", "Matrix user ID to verify") + .option("--device-id ", "Matrix device ID to verify") + .option("--room-id ", "Matrix direct-message room ID for verification") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async (options: { + account?: string; + ownUser?: boolean; + userId?: string; + deviceId?: string; + roomId?: string; + verbose?: boolean; + json?: boolean; + }) => { + const { accountId, cfg } = resolveMatrixCliAccountContext(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => { + if ( + options.ownUser === true && + (options.userId || options.deviceId || options.roomId) + ) { + throw new Error( + "--own-user cannot be combined with --user-id, --device-id, or --room-id", + ); + } + return await requestMatrixVerification({ + accountId, + cfg, + ownUser: options.ownUser === true ? true : undefined, + userId: options.userId, + deviceId: options.deviceId, + roomId: options.roomId, + }); + }, + onText: (summary) => { + printAccountLabel(accountId); + printMatrixVerificationSummary(summary); + printMatrixVerificationRequestGuidance(summary, accountId); + }, + errorPrefix: "Verification request failed", + }); + }, + ); + + verify + .command("accept ") + .description("Accept an inbound Matrix verification request") + .option("--account ", "Account ID (for multi-account setups)") + .option("--user-id ", "Matrix user ID for DM verification follow-up") + .option("--room-id ", "Matrix direct-message room ID for verification follow-up") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action(async (id: string, options: MatrixCliVerificationCommandOptions) => { + await runMatrixCliVerificationSummaryCommand({ + options, + run: async (accountId, cfg) => + await acceptMatrixVerification(id, { + accountId, + cfg, + ...matrixCliVerificationDmLookupOptions(options), + }), + afterText: (summary, accountId) => { + const requestId = formatMatrixVerificationCommandId(summary); + const dmParts = formatMatrixVerificationPreferredDmFollowupParts(summary, options); + printGuidance([ + `Run ${formatMatrixVerificationFollowupCommand({ action: "start", requestId, accountId, dmParts })} to start SAS verification.`, + ]); + }, + errorPrefix: "Verification accept failed", + }); + }); + + verify + .command("start ") + .description("Start SAS verification for a Matrix verification request") + .option("--account ", "Account ID (for multi-account setups)") + .option("--user-id ", "Matrix user ID for DM verification follow-up") + .option("--room-id ", "Matrix direct-message room ID for verification follow-up") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action(async (id: string, options: MatrixCliVerificationCommandOptions) => { + await runMatrixCliVerificationSummaryCommand({ + options, + run: async (accountId, cfg) => + await startMatrixVerification(id, { + accountId, + cfg, + method: "sas", + ...matrixCliVerificationDmLookupOptions(options), + }), + afterText: (summary, accountId) => + printMatrixVerificationSasGuidance( + formatMatrixVerificationCommandId(summary), + accountId, + formatMatrixVerificationPreferredDmFollowupParts(summary, options), + ), + errorPrefix: "Verification start failed", + }); + }); + + verify + .command("sas ") + .description("Show SAS emoji or decimals for a Matrix verification request") + .option("--account ", "Account ID (for multi-account setups)") + .option("--user-id ", "Matrix user ID for DM verification follow-up") + .option("--room-id ", "Matrix direct-message room ID for verification follow-up") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action(async (id: string, options: MatrixCliVerificationCommandOptions) => { + const { accountId, cfg } = resolveMatrixCliAccountContext(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => + await getMatrixVerificationSas(id, { + accountId, + cfg, + ...matrixCliVerificationDmLookupOptions(options), + }), + onText: (sas) => { + const requestId = formatMatrixCliText(id); + printAccountLabel(accountId); + console.log(`Verification id: ${requestId}`); + printMatrixVerificationSas(sas); + printMatrixVerificationSasGuidance( + requestId, + accountId, + formatMatrixVerificationOptionsDmFollowupParts(options), + ); + }, + errorPrefix: "Verification SAS lookup failed", + }); + }); + + verify + .command("confirm-sas ") + .description("Confirm matching SAS emoji or decimals for a Matrix verification request") + .option("--account ", "Account ID (for multi-account setups)") + .option("--user-id ", "Matrix user ID for DM verification follow-up") + .option("--room-id ", "Matrix direct-message room ID for verification follow-up") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action(async (id: string, options: MatrixCliVerificationCommandOptions) => { + await runMatrixCliVerificationSummaryCommand({ + options, + run: async (accountId, cfg) => + await confirmMatrixVerificationSas(id, { + accountId, + cfg, + ...matrixCliVerificationDmLookupOptions(options), + }), + errorPrefix: "Verification SAS confirm failed", + }); + }); + + verify + .command("mismatch-sas ") + .description("Reject a Matrix SAS verification when the emoji or decimals do not match") + .option("--account ", "Account ID (for multi-account setups)") + .option("--user-id ", "Matrix user ID for DM verification follow-up") + .option("--room-id ", "Matrix direct-message room ID for verification follow-up") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action(async (id: string, options: MatrixCliVerificationCommandOptions) => { + await runMatrixCliVerificationSummaryCommand({ + options, + run: async (accountId, cfg) => + await mismatchMatrixVerificationSas(id, { + accountId, + cfg, + ...matrixCliVerificationDmLookupOptions(options), + }), + errorPrefix: "Verification SAS mismatch failed", + }); + }); + + verify + .command("cancel ") + .description("Cancel a Matrix verification request") + .option("--account ", "Account ID (for multi-account setups)") + .option("--user-id ", "Matrix user ID for DM verification follow-up") + .option("--room-id ", "Matrix direct-message room ID for verification follow-up") + .option("--reason ", "Cancellation reason") + .option("--code ", "Matrix cancellation code") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async ( + id: string, + options: MatrixCliVerificationCommandOptions & { + reason?: string; + code?: string; + }, + ) => { + await runMatrixCliVerificationSummaryCommand({ + options, + run: async (accountId, cfg) => + await cancelMatrixVerification(id, { + accountId, + cfg, + reason: options.reason, + code: options.code, + ...matrixCliVerificationDmLookupOptions(options), + }), + errorPrefix: "Verification cancel failed", + }); + }, + ); + verify .command("status") .description("Check Matrix device verification status") @@ -1005,11 +1704,17 @@ export function registerMatrixCli(params: { program: Command }): void { printAccountLabel(accountId); console.log(`Reset success: ${result.success ? "yes" : "no"}`); if (result.error) { - console.log(`Error: ${result.error}`); + console.log(`Error: ${formatMatrixCliText(result.error)}`); } - console.log(`Previous backup version: ${result.previousVersion ?? "none"}`); - console.log(`Deleted backup version: ${result.deletedVersion ?? "none"}`); - console.log(`Current backup version: ${result.createdVersion ?? "none"}`); + console.log( + `Previous backup version: ${formatMatrixCliText(result.previousVersion, "none")}`, + ); + console.log( + `Deleted backup version: ${formatMatrixCliText(result.deletedVersion, "none")}`, + ); + console.log( + `Current backup version: ${formatMatrixCliText(result.createdVersion, "none")}`, + ); printBackupSummary(result.backup); if (verbose) { printTimestamp("Reset at", result.resetAt); @@ -1051,9 +1756,9 @@ export function registerMatrixCli(params: { program: Command }): void { printAccountLabel(accountId); console.log(`Restore success: ${result.success ? "yes" : "no"}`); if (result.error) { - console.log(`Error: ${result.error}`); + console.log(`Error: ${formatMatrixCliText(result.error)}`); } - console.log(`Backup version: ${result.backupVersion ?? "none"}`); + console.log(`Backup version: ${formatMatrixCliText(result.backupVersion, "none")}`); console.log(`Imported keys: ${result.imported}/${result.total}`); printBackupSummary(result.backup); if (verbose) { @@ -1102,7 +1807,7 @@ export function registerMatrixCli(params: { program: Command }): void { printAccountLabel(accountId); console.log(`Bootstrap success: ${result.success ? "yes" : "no"}`); if (result.error) { - console.log(`Error: ${result.error}`); + console.log(`Error: ${formatMatrixCliText(result.error)}`); } console.log(`Verified by owner: ${result.verification.verified ? "yes" : "no"}`); printVerificationIdentity(result.verification); @@ -1151,11 +1856,31 @@ export function registerMatrixCli(params: { program: Command }): void { onText: (result, verbose) => { printAccountLabel(accountId); if (!result.success) { - console.error(`Verification failed: ${result.error ?? "unknown error"}`); + console.error(`Verification failed: ${formatMatrixCliText(result.error)}`); + printVerificationIdentity(result); + console.log(`Recovery key accepted: ${result.recoveryKeyAccepted ? "yes" : "no"}`); + console.log(`Backup usable: ${result.backupUsable ? "yes" : "no"}`); + console.log(`Device verified by owner: ${result.deviceOwnerVerified ? "yes" : "no"}`); + printVerificationBackupSummary(result); + if (verbose) { + printVerificationTrustDiagnostics(result); + printVerificationBackupStatus(result); + printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt); + } + printVerificationGuidance( + { + ...result, + pendingVerifications: 0, + }, + accountId, + ); return; } console.log("Device verification completed successfully."); printVerificationIdentity(result); + console.log(`Recovery key accepted: ${result.recoveryKeyAccepted ? "yes" : "no"}`); + console.log(`Backup usable: ${result.backupUsable ? "yes" : "no"}`); + console.log(`Device verified by owner: ${result.deviceOwnerVerified ? "yes" : "no"}`); printVerificationBackupSummary(result); if (verbose) { printVerificationTrustDiagnostics(result); @@ -1187,11 +1912,11 @@ export function registerMatrixCli(params: { program: Command }): void { .option("--verbose", "Show detailed diagnostics") .option("--json", "Output as JSON") .action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => { - const accountId = resolveMatrixCliAccountId(options.account); + const { accountId, cfg } = resolveMatrixCliAccountContext(options.account); await runMatrixCliCommand({ verbose: options.verbose === true, json: options.json === true, - run: async () => await listMatrixOwnDevices({ accountId }), + run: async () => await listMatrixOwnDevices({ accountId, cfg }), onText: (result) => { printAccountLabel(accountId); printMatrixOwnDevices(result); @@ -1207,17 +1932,23 @@ export function registerMatrixCli(params: { program: Command }): void { .option("--verbose", "Show detailed diagnostics") .option("--json", "Output as JSON") .action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => { - const accountId = resolveMatrixCliAccountId(options.account); + const { accountId, cfg } = resolveMatrixCliAccountContext(options.account); await runMatrixCliCommand({ verbose: options.verbose === true, json: options.json === true, - run: async () => await pruneMatrixStaleGatewayDevices({ accountId }), + run: async () => await pruneMatrixStaleGatewayDevices({ accountId, cfg }), onText: (result, verbose) => { printAccountLabel(accountId); console.log( - `Deleted stale OpenClaw devices: ${result.deletedDeviceIds.length ? result.deletedDeviceIds.join(", ") : "none"}`, + `Deleted stale OpenClaw devices: ${ + result.deletedDeviceIds.length + ? result.deletedDeviceIds + .map((deviceId) => formatMatrixCliText(deviceId)) + .join(", ") + : "none" + }`, ); - console.log(`Current device: ${result.currentDeviceId ?? "unknown"}`); + console.log(`Current device: ${formatMatrixCliText(result.currentDeviceId)}`); console.log(`Remaining devices: ${result.remainingDevices.length}`); if (verbose) { console.log("Devices before cleanup:"); diff --git a/extensions/matrix/src/matrix/actions/verification.test.ts b/extensions/matrix/src/matrix/actions/verification.test.ts index 9b62747c5a1..8665221240f 100644 --- a/extensions/matrix/src/matrix/actions/verification.test.ts +++ b/extensions/matrix/src/matrix/actions/verification.test.ts @@ -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", + }); + }); }); diff --git a/extensions/matrix/src/matrix/actions/verification.ts b/extensions/matrix/src/matrix/actions/verification.ts index 0979dd61317..3f1dee60622 100644 --- a/extensions/matrix/src/matrix/actions/verification.ts +++ b/extensions/matrix/src/matrix/actions/verification.ts @@ -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; +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 { + 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 { + 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 { + 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 { + 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 { + 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, + summary: MatrixVerificationSummary, + ) => Promise; + onReady?: (summary: MatrixVerificationSummary) => void | Promise; + onRequested?: (summary: MatrixVerificationSummary) => void | Promise; + onSas?: (summary: MatrixVerificationSummary) => void | Promise; + timeoutMs?: number; + }, +): Promise { + 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)); }); } diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index f490bb247d7..fecaa83b828 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -33,6 +33,20 @@ function stubRuntimeFetch(fetchImpl: typeof fetch): void { }; } +async function consumeMatrixSecretStorageKey(keyId = "SSSSKEY"): Promise { + const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as { + getSecretStorageKey?: ( + params: { keys: Record }, + 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 () => { diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index dfa102a3f40..36e197098a6 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -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({ 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 { + 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 { + 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 { - const fail = async (error: string): Promise => ({ - success: false, - error, - ...(await this.getOwnDeviceVerificationStatus()), - }); + const fail = async ( + error: string, + fields: Partial< + Pick< + MatrixRecoveryKeyVerificationResult, + "backupUsable" | "deviceOwnerVerified" | "recoveryKeyAccepted" + > + > = {}, + ): Promise => { + 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 { const pendingVerifications = async (): Promise => 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, }); diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts index 185b17e8c21..e2e5d2064b2 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts @@ -196,7 +196,8 @@ describe("MatrixCryptoBootstrapper", () => { userHasCrossSigningKeys: vi .fn<() => Promise>() .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({ diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts index cbcd942dabe..257c76b9c35 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts @@ -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 { private verificationHandlerRegistered = false; @@ -83,7 +86,9 @@ export class MatrixCryptoBootstrapper { 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 { 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 { return { ready, published }; }; + const waitForPublishedCrossSigningKeys = async (): Promise => { + 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 => { await crypto.bootstrapCrossSigning({ @@ -187,6 +205,7 @@ export class MatrixCryptoBootstrapper { }; try { await resetCrossSigning(); + await this.trustFreshOwnIdentity(crypto); } catch (err) { const shouldRepairSecretStorage = options.allowSecretStorageRecreateWithoutRecoveryKey && @@ -202,6 +221,7 @@ export class MatrixCryptoBootstrapper { 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 { 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 { return await finalize(); } + private async trustFreshOwnIdentity(crypto: MatrixCryptoBootstrapApi): Promise { + 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 { private async ensureOwnDeviceTrust( crypto: MatrixCryptoBootstrapApi, - strict = false, + options: { + strict: boolean; + }, ): Promise { const deviceId = this.deps.getDeviceId()?.trim(); if (!deviceId) { @@ -386,8 +428,10 @@ export class MatrixCryptoBootstrapper { ? 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; } diff --git a/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts b/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts index b081f27f4a4..cce5712fdb1 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts @@ -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]); + }); }); diff --git a/extensions/matrix/src/matrix/sdk/crypto-facade.ts b/extensions/matrix/src/matrix/sdk/crypto-facade.ts index 14b1c7bee46..f5bbfefee0d 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-facade.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-facade.ts @@ -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 { 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); }, }; diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts index 0b81da072ea..0e045e10785 100644 --- a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts @@ -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(); 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 => { 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, diff --git a/extensions/matrix/src/matrix/sdk/types.ts b/extensions/matrix/src/matrix/sdk/types.ts index c262fdcf2f5..6834ee5eaed 100644 --- a/extensions/matrix/src/matrix/sdk/types.ts +++ b/extensions/matrix/src/matrix/sdk/types.ts @@ -232,6 +232,14 @@ export type MatrixCryptoBootstrapApi = { }) => Promise; setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise; crossSignDevice?: (deviceId: string) => Promise; + getOwnIdentity?: () => Promise< + | { + free?: () => void; + isVerified?: () => boolean; + verify?: () => Promise; + } + | undefined + >; isCrossSigningReady?: () => Promise; userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise; }; diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts index d9313cf1286..65b56777930 100644 --- a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts +++ b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts @@ -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 () => {}); diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.ts b/extensions/matrix/src/matrix/sdk/verification-manager.ts index df7065010f4..08698a61cf8 100644 --- a/extensions/matrix/src/matrix/sdk/verification-manager.ts +++ b/extensions/matrix/src/matrix/sdk/verification-manager.ts @@ -52,6 +52,7 @@ export type MatrixVerificationSummary = { }; type MatrixVerificationSummaryListener = (summary: MatrixVerificationSummary) => void; +type MatrixVerificationOwnerTrustCallback = (deviceId: string) => Promise; export type MatrixShowSasCallbacks = { sas: { @@ -101,6 +102,7 @@ export type MatrixVerificationRequestLike = { export type MatrixVerificationCryptoApi = { requestOwnUserVerification: () => Promise; + 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(); private readonly summaryListeners = new Set(); + constructor( + private readonly opts: { + trustOwnDeviceAfterSas?: MatrixVerificationOwnerTrustCallback; + } = {}, + ) {} + private readRequestValue( 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 { + 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 { + 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); } diff --git a/extensions/matrix/src/matrix/sdk/verification-status.ts b/extensions/matrix/src/matrix/sdk/verification-status.ts index e6de1906a75..ebaf62e0b27 100644 --- a/extensions/matrix/src/matrix/sdk/verification-status.ts +++ b/extensions/matrix/src/matrix/sdk/verification-status.ts @@ -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( diff --git a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts index 5cb1a080d9c..e5b0be77060 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts @@ -58,6 +58,8 @@ export type MatrixQaScenarioId = | "matrix-e2ee-thread-follow-up" | "matrix-e2ee-bootstrap-success" | "matrix-e2ee-recovery-key-lifecycle" + | "matrix-e2ee-recovery-owner-verification-required" + | "matrix-e2ee-cli-self-verification" | "matrix-e2ee-device-sas-verification" | "matrix-e2ee-qr-verification" | "matrix-e2ee-stale-device-hygiene" @@ -567,6 +569,26 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [ }), configOverrides: MATRIX_QA_E2EE_CONFIG, }, + { + id: "matrix-e2ee-recovery-owner-verification-required", + timeoutMs: 90_000, + title: "Matrix E2EE recovery key backup access still requires Matrix identity trust", + topology: buildMatrixQaE2eeScenarioTopology({ + scenarioId: "matrix-e2ee-recovery-owner-verification-required", + name: "Matrix QA E2EE Recovery Owner Verification Room", + }), + configOverrides: MATRIX_QA_E2EE_CONFIG, + }, + { + id: "matrix-e2ee-cli-self-verification", + timeoutMs: 180_000, + title: "Matrix E2EE CLI interactive self-verification establishes identity trust", + topology: buildMatrixQaE2eeScenarioTopology({ + scenarioId: "matrix-e2ee-cli-self-verification", + name: "Matrix QA E2EE CLI Self Verification Room", + }), + configOverrides: MATRIX_QA_E2EE_CONFIG, + }, { id: "matrix-e2ee-device-sas-verification", timeoutMs: 90_000, diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts new file mode 100644 index 00000000000..ae104b6eab8 --- /dev/null +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts @@ -0,0 +1,44 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + formatMatrixQaCliCommand, + redactMatrixQaCliOutput, + resolveMatrixQaOpenClawCliEntryPath, +} from "./scenario-runtime-cli.js"; + +describe("Matrix QA CLI runtime", () => { + it("redacts secret CLI arguments in diagnostic command text", () => { + expect( + formatMatrixQaCliCommand([ + "matrix", + "verify", + "backup", + "restore", + "--recovery-key", + "abcdef1234567890ghij", + ]), + ).toBe("openclaw matrix verify backup restore --recovery-key [REDACTED]"); + expect(formatMatrixQaCliCommand(["matrix", "account", "add", "--access-token=token-123"])).toBe( + "openclaw matrix account add --access-token=[REDACTED]", + ); + }); + + it("redacts Matrix token output before diagnostics and artifacts", () => { + expect( + redactMatrixQaCliOutput("GET /_matrix/client/v3/sync?access_token=abcdef1234567890ghij"), + ).toBe("GET /_matrix/client/v3/sync?access_token=abcdef…ghij"); + }); + + it("prefers the ESM OpenClaw CLI entrypoint when present", async () => { + const root = await mkdtemp(path.join(tmpdir(), "matrix-qa-cli-entry-")); + try { + await mkdir(path.join(root, "dist")); + await writeFile(path.join(root, "dist", "index.mjs"), ""); + expect(resolveMatrixQaOpenClawCliEntryPath(root)).toBe(path.join(root, "dist", "index.mjs")); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts new file mode 100644 index 00000000000..d6eb7edc902 --- /dev/null +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts @@ -0,0 +1,224 @@ +import { spawn as startOpenClawCliProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core"; + +export type MatrixQaCliRunResult = { + args: string[]; + exitCode: number; + stderr: string; + stdout: string; +}; + +export type MatrixQaCliSession = { + args: string[]; + output: () => { stderr: string; stdout: string }; + wait: () => Promise; + waitForOutput: ( + predicate: (output: { stderr: string; stdout: string; text: string }) => boolean, + label: string, + timeoutMs: number, + ) => Promise<{ stderr: string; stdout: string; text: string }>; + writeStdin: (text: string) => Promise; + kill: () => void; +}; + +const MATRIX_QA_CLI_SECRET_ARG_FLAGS = new Set(["--access-token", "--password", "--recovery-key"]); + +function redactMatrixQaCliArgs(args: string[]): string[] { + return args.map((arg, index) => { + const [flag] = arg.split("=", 1); + if (MATRIX_QA_CLI_SECRET_ARG_FLAGS.has(flag) && arg.includes("=")) { + return `${flag}=[REDACTED]`; + } + const previous = args[index - 1]; + if (previous && MATRIX_QA_CLI_SECRET_ARG_FLAGS.has(previous)) { + return "[REDACTED]"; + } + return arg; + }); +} + +export function redactMatrixQaCliOutput(text: string): string { + return redactSensitiveText(text); +} + +export function formatMatrixQaCliCommand(args: string[]) { + return `openclaw ${redactMatrixQaCliArgs(args).join(" ")}`; +} + +export function resolveMatrixQaOpenClawCliEntryPath(cwd: string): string { + const mjsEntryPath = path.join(cwd, "dist", "index.mjs"); + if (existsSync(mjsEntryPath)) { + return mjsEntryPath; + } + return path.join(cwd, "dist", "index.js"); +} + +function buildMatrixQaCliResult(params: { + args: string[]; + exitCode: number; + output: { stderr: string; stdout: string }; +}): MatrixQaCliRunResult { + return { + args: params.args, + exitCode: params.exitCode, + stderr: params.output.stderr, + stdout: params.output.stdout, + }; +} + +function formatMatrixQaCliExitError(result: MatrixQaCliRunResult) { + return [ + `${formatMatrixQaCliCommand(result.args)} exited ${result.exitCode}`, + result.stderr.trim() ? `stderr:\n${redactMatrixQaCliOutput(result.stderr.trim())}` : null, + result.stdout.trim() ? `stdout:\n${redactMatrixQaCliOutput(result.stdout.trim())}` : null, + ] + .filter(Boolean) + .join("\n"); +} + +export function startMatrixQaOpenClawCli(params: { + args: string[]; + cwd?: string; + env: NodeJS.ProcessEnv; + timeoutMs: number; +}): MatrixQaCliSession { + const cwd = params.cwd ?? process.cwd(); + const distEntryPath = resolveMatrixQaOpenClawCliEntryPath(cwd); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let closed = false; + let closeResult: MatrixQaCliRunResult | undefined; + let settleWait: + | { + reject: (error: Error) => void; + resolve: (result: MatrixQaCliRunResult) => void; + } + | undefined; + + const child = startOpenClawCliProcess(process.execPath, [distEntryPath, ...params.args], { + cwd, + env: params.env, + stdio: ["pipe", "pipe", "pipe"], + }); + const readOutput = () => ({ + stderr: Buffer.concat(stderr).toString("utf8"), + stdout: Buffer.concat(stdout).toString("utf8"), + }); + const finish = (result: MatrixQaCliRunResult, error?: Error) => { + if (closed) { + return; + } + closed = true; + closeResult = result; + if (!settleWait) { + return; + } + if (error) { + settleWait.reject(error); + } else { + settleWait.resolve(result); + } + }; + + const timeout = setTimeout(() => { + const result = buildMatrixQaCliResult({ + args: params.args, + exitCode: 1, + output: readOutput(), + }); + child.kill("SIGTERM"); + finish( + result, + new Error(`${formatMatrixQaCliCommand(params.args)} timed out after ${params.timeoutMs}ms`), + ); + }, params.timeoutMs); + + child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk))); + child.on("error", (error) => { + clearTimeout(timeout); + finish( + buildMatrixQaCliResult({ + args: params.args, + exitCode: 1, + output: readOutput(), + }), + error, + ); + }); + child.on("close", (exitCode) => { + clearTimeout(timeout); + const result = buildMatrixQaCliResult({ + args: params.args, + exitCode: exitCode ?? 1, + output: readOutput(), + }); + if (result.exitCode !== 0) { + finish(result, new Error(formatMatrixQaCliExitError(result))); + return; + } + finish(result); + }); + + return { + args: params.args, + output: readOutput, + wait: async () => + await new Promise((resolve, reject) => { + if (closed && closeResult) { + if (closeResult.exitCode === 0) { + resolve(closeResult); + } else { + reject(new Error(formatMatrixQaCliExitError(closeResult))); + } + return; + } + settleWait = { reject, resolve }; + }).catch((error) => { + throw new Error( + `Matrix QA CLI command failed (${formatMatrixQaCliCommand(params.args)}): ${redactMatrixQaCliOutput(formatErrorMessage(error))}`, + ); + }), + waitForOutput: async (predicate, label, timeoutMs) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const output = readOutput(); + const text = `${output.stdout}\n${output.stderr}`; + if (predicate({ ...output, text })) { + return { ...output, text }; + } + if (closed) { + break; + } + await sleep(Math.min(100, Math.max(25, timeoutMs - (Date.now() - startedAt)))); + } + const output = readOutput(); + throw new Error( + `${formatMatrixQaCliCommand(params.args)} did not print ${label} before timeout\nstdout:\n${redactMatrixQaCliOutput(output.stdout.trim())}\nstderr:\n${redactMatrixQaCliOutput(output.stderr.trim())}`, + ); + }, + writeStdin: async (text) => { + if (!child.stdin.write(text)) { + await new Promise((resolve) => child.stdin.once("drain", resolve)); + } + }, + kill: () => { + if (!closed) { + child.kill("SIGTERM"); + } + }, + }; +} + +export async function runMatrixQaOpenClawCli(params: { + args: string[]; + cwd?: string; + env: NodeJS.ProcessEnv; + timeoutMs: number; +}): Promise { + return await startMatrixQaOpenClawCli(params).wait(); +} diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee.ts index f07438c3f88..ec05cccecc6 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee.ts @@ -1,4 +1,7 @@ import { randomUUID } from "node:crypto"; +import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import type { MatrixVerificationSummary } from "@openclaw/matrix/test-api.js"; import { createMatrixQaClient } from "../../substrate/client.js"; @@ -25,6 +28,13 @@ import { hasMatrixQaExpectedColorReply, MATRIX_QA_IMAGE_ATTACHMENT_FILENAME, } from "./scenario-media-fixtures.js"; +import { + formatMatrixQaCliCommand, + redactMatrixQaCliOutput, + runMatrixQaOpenClawCli, + startMatrixQaOpenClawCli, + type MatrixQaCliRunResult, +} from "./scenario-runtime-cli.js"; import { assertThreadReplyArtifact, assertTopLevelReplyArtifact, @@ -40,8 +50,32 @@ import type { MatrixQaReplyArtifact, MatrixQaScenarioExecution } from "./scenari const MATRIX_QA_ROOM_KEY_BACKUP_VERSION_ENDPOINT = "/_matrix/client/v3/room_keys/version"; const MATRIX_QA_ROOM_KEY_BACKUP_FAULT_RULE_ID = "room-key-backup-version-unavailable"; +const MATRIX_QA_OWNER_SIGNATURE_UPLOAD_BLOCKED_RULE_ID = "owner-signature-upload-blocked"; +const MATRIX_QA_KEYS_SIGNATURES_UPLOAD_ENDPOINT = "/_matrix/client/v3/keys/signatures/upload"; type MatrixQaE2eeBootstrapResult = Awaited>; +type MatrixQaCliVerificationStatus = { + backup?: { + decryptionKeyCached?: boolean | null; + keyLoadError?: string | null; + matchesDecryptionKey?: boolean | null; + trusted?: boolean | null; + }; + crossSigningVerified?: boolean; + verified?: boolean; + signedByOwner?: boolean; + deviceId?: string | null; + userId?: string | null; +}; +type MatrixQaCliBackupRestoreStatus = { + success?: boolean; + backup?: MatrixQaCliVerificationStatus["backup"]; + error?: string; +}; + +function isMatrixQaCliBackupUsable(backup: MatrixQaCliVerificationStatus["backup"]): boolean { + return Boolean(backup?.trusted && backup.matchesDecryptionKey && !backup.keyLoadError); +} function requireMatrixQaE2eeOutputDir(context: MatrixQaScenarioContext) { if (!context.outputDir) { @@ -50,6 +84,13 @@ function requireMatrixQaE2eeOutputDir(context: MatrixQaScenarioContext) { return context.outputDir; } +function requireMatrixQaCliRuntimeEnv(context: MatrixQaScenarioContext) { + if (!context.gatewayRuntimeEnv) { + throw new Error("Matrix CLI QA scenarios require the gateway runtime environment"); + } + return context.gatewayRuntimeEnv; +} + function requireMatrixQaPassword(context: MatrixQaScenarioContext, actor: "driver" | "observer") { const password = actor === "driver" ? context.driverPassword : context.observerPassword; if (!password) { @@ -76,6 +117,9 @@ function assertMatrixQaBootstrapSucceeded(label: string, result: MatrixQaE2eeBoo if (!result.verification.verified || !result.verification.signedByOwner) { throw new Error(`${label} bootstrap did not leave the device verified by its owner`); } + if (!result.verification.crossSigningVerified) { + throw new Error(`${label} bootstrap did not establish full Matrix identity trust`); + } if (!result.crossSigning.published) { throw new Error(`${label} bootstrap did not publish cross-signing keys`); } @@ -190,6 +234,242 @@ function formatMatrixQaSasEmoji(summary: MatrixVerificationSummary) { return summary.sas?.emoji?.map(([emoji, label]) => `${emoji} ${label}`) ?? []; } +function parseMatrixQaCliJsonText(text: string): unknown { + const candidate = text.trim(); + if (!candidate) { + throw new Error("no JSON payload found"); + } + return JSON.parse(candidate) as unknown; +} + +function parseMatrixQaCliJson(result: MatrixQaCliRunResult): unknown { + const stdout = result.stdout.trim(); + const stderr = result.stderr.trim(); + if (stdout && stderr) { + throw new Error( + `${formatMatrixQaCliCommand(result.args)} printed JSON with extra output\nstdout:\n${redactMatrixQaCliOutput(stdout)}\nstderr:\n${redactMatrixQaCliOutput(stderr)}`, + ); + } + if (stdout) { + try { + return parseMatrixQaCliJsonText(stdout); + } catch (error) { + throw new Error( + `${formatMatrixQaCliCommand(result.args)} printed invalid JSON: ${ + error instanceof Error ? error.message : String(error) + }\nstdout:\n${redactMatrixQaCliOutput(stdout)}`, + { cause: error }, + ); + } + } + + if (!stderr) { + throw new Error(`${formatMatrixQaCliCommand(result.args)} did not print JSON`); + } + try { + return parseMatrixQaCliJsonText(stderr); + } catch (error) { + throw new Error( + `${formatMatrixQaCliCommand(result.args)} printed invalid JSON: ${ + error instanceof Error ? error.message : String(error) + }\nstderr:\n${redactMatrixQaCliOutput(stderr)}`, + { cause: error }, + ); + } +} + +function parseMatrixQaCliSasText( + text: string, + label: string, +): { kind: "emoji"; value: string } | { kind: "decimal"; value: string } { + const emoji = text.match(/^SAS emoji:\s*(.+)$/m)?.[1]?.trim(); + if (emoji) { + return { kind: "emoji", value: emoji }; + } + const decimal = text.match(/^SAS decimals:\s*(.+)$/m)?.[1]?.trim(); + if (decimal) { + return { kind: "decimal", value: decimal }; + } + throw new Error(`${label} did not print SAS emoji or decimals`); +} + +function parseMatrixQaCliSummaryField(text: string, field: string): string | null { + const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return text.match(new RegExp(`^${escaped}:\\s*(.+)$`, "m"))?.[1]?.trim() ?? null; +} + +async function writeMatrixQaCliOutputArtifacts(params: { + label: string; + result: MatrixQaCliRunResult; + rootDir: string; +}) { + await mkdir(params.rootDir, { mode: 0o700, recursive: true }); + await chmod(params.rootDir, 0o700).catch(() => undefined); + const prefix = params.label.replace(/[^A-Za-z0-9_-]/g, "-"); + const stdoutPath = path.join(params.rootDir, `${prefix}.stdout.txt`); + const stderrPath = path.join(params.rootDir, `${prefix}.stderr.txt`); + await Promise.all([ + writeFile(stdoutPath, redactMatrixQaCliOutput(params.result.stdout), { mode: 0o600 }), + writeFile(stderrPath, redactMatrixQaCliOutput(params.result.stderr), { mode: 0o600 }), + ]); + return { stderrPath, stdoutPath }; +} + +async function assertMatrixQaPrivatePathMode(pathToCheck: string, label: string) { + if (process.platform === "win32") { + return; + } + const mode = (await stat(pathToCheck)).mode & 0o777; + if ((mode & 0o077) !== 0) { + throw new Error(`${label} permissions are too broad: ${mode.toString(8)}`); + } +} + +function assertMatrixQaCliSasMatches(params: { + cliSas: ReturnType; + owner: MatrixVerificationSummary; +}) { + if (params.cliSas.kind === "emoji") { + const ownerEmoji = formatMatrixQaSasEmoji(params.owner).join(" | "); + if (!ownerEmoji) { + throw new Error("Matrix owner client did not expose SAS emoji"); + } + if (params.cliSas.value !== ownerEmoji) { + throw new Error("Matrix CLI SAS emoji did not match the owner client"); + } + return ownerEmoji.split(" | "); + } + + const ownerDecimal = params.owner.sas?.decimal?.join(" "); + if (!ownerDecimal) { + throw new Error("Matrix owner client did not expose SAS decimals"); + } + if (params.cliSas.value !== ownerDecimal) { + throw new Error("Matrix CLI SAS decimals did not match the owner client"); + } + return [ownerDecimal]; +} + +function isMatrixQaCliOwnerSelfVerification(params: { + cliDeviceId?: string; + driverUserId: string; + requireCompleted?: boolean; + requirePending?: boolean; + requireSas?: boolean; + summary: MatrixVerificationSummary; + transactionId?: string; +}) { + const summary = params.summary; + if ( + !summary.isSelfVerification || + summary.initiatedByMe || + summary.otherUserId !== params.driverUserId + ) { + return false; + } + if (params.transactionId) { + if (summary.transactionId !== params.transactionId) { + return false; + } + } else if (params.cliDeviceId && summary.otherDeviceId !== params.cliDeviceId) { + return false; + } + if (params.requirePending === true && !summary.pending) { + return false; + } + if (params.requireSas === true && !summary.hasSas) { + return false; + } + return params.requireCompleted !== true || summary.completed; +} + +async function createMatrixQaCliSelfVerificationRuntime(params: { + accountId: string; + accessToken: string; + context: MatrixQaScenarioContext; + deviceId: string; + userId: string; +}) { + const outputDir = requireMatrixQaE2eeOutputDir(params.context); + const rootDir = await mkdtemp(path.join(tmpdir(), "openclaw-matrix-cli-qa-")); + const artifactDir = path.join( + outputDir, + "cli-self-verification", + randomUUID().replaceAll("-", "").slice(0, 12), + ); + const stateDir = path.join(rootDir, "state"); + const configPath = path.join(rootDir, "config.json"); + await chmod(rootDir, 0o700).catch(() => undefined); + await assertMatrixQaPrivatePathMode(rootDir, "Matrix QA CLI temp directory"); + await mkdir(artifactDir, { mode: 0o700, recursive: true }); + await chmod(artifactDir, 0o700).catch(() => undefined); + await assertMatrixQaPrivatePathMode(artifactDir, "Matrix QA CLI artifact directory"); + await mkdir(stateDir, { mode: 0o700, recursive: true }); + await chmod(stateDir, 0o700).catch(() => undefined); + await assertMatrixQaPrivatePathMode(stateDir, "Matrix QA CLI state directory"); + await writeFile( + configPath, + `${JSON.stringify( + { + channels: { + matrix: { + defaultAccount: params.accountId, + accounts: { + [params.accountId]: { + accessToken: params.accessToken, + deviceId: params.deviceId, + encryption: true, + homeserver: params.context.baseUrl, + initialSyncLimit: 1, + name: "Matrix QA CLI self-verification", + network: { + dangerouslyAllowPrivateNetwork: true, + }, + startupVerification: "off", + userId: params.userId, + }, + }, + }, + }, + }, + null, + 2, + )}\n`, + { flag: "wx", mode: 0o600 }, + ); + await assertMatrixQaPrivatePathMode(configPath, "Matrix QA CLI config file"); + const env = { + ...requireMatrixQaCliRuntimeEnv(params.context), + FORCE_COLOR: "0", + NO_COLOR: "1", + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_DISABLE_AUTO_UPDATE: "1", + OPENCLAW_STATE_DIR: stateDir, + }; + const run = async (args: string[], timeoutMs = params.context.timeoutMs) => + await runMatrixQaOpenClawCli({ + args, + env, + timeoutMs, + }); + const start = (args: string[], timeoutMs = params.context.timeoutMs) => + startMatrixQaOpenClawCli({ + args, + env, + timeoutMs, + }); + return { + configPath, + dispose: async () => { + await rm(rootDir, { force: true, recursive: true }); + }, + run, + rootDir: artifactDir, + start, + stateDir, + }; +} + function assertMatrixQaSasEmojiMatches(params: { initiator: MatrixVerificationSummary; recipient: MatrixVerificationSummary; @@ -205,20 +485,6 @@ function assertMatrixQaSasEmojiMatches(params: { return initiatorEmoji; } -function isMatrixQaOwnerVerificationOnlyRecoveryError(error: string | undefined) { - return error?.toLowerCase().includes("device is still not verified by its owner") === true; -} - -function hasMatrixQaUsableRecoveryBackup( - result: Awaited>, -) { - return ( - Boolean(result.backup.serverVersion) && - result.backup.decryptionKeyCached !== false && - result.backup.keyLoadError === null - ); -} - function isMatrixQaE2eeNoticeTriggeredSutReply(params: { event: MatrixQaObservedEvent; noticeEventId: string; @@ -405,6 +671,20 @@ function buildRoomKeyBackupUnavailableFaultRule(accessToken: string): MatrixQaFa }; } +function buildOwnerSignatureUploadBlockedFaultRule(accessToken: string): MatrixQaFaultProxyRule { + return { + id: MATRIX_QA_OWNER_SIGNATURE_UPLOAD_BLOCKED_RULE_ID, + match: (request) => + request.method === "POST" && + request.path === MATRIX_QA_KEYS_SIGNATURES_UPLOAD_ENDPOINT && + request.bearerToken === accessToken, + response: () => ({ + body: {}, + status: 200, + }), + }; +} + async function runMatrixQaFaultedE2eeBootstrap(context: MatrixQaScenarioContext): Promise<{ faultHits: MatrixQaFaultProxyHit[]; result: MatrixQaE2eeBootstrapResult; @@ -434,6 +714,77 @@ async function runMatrixQaFaultedE2eeBootstrap(context: MatrixQaScenarioContext) } } +async function runMatrixQaFaultedRecoveryOwnerVerification(params: { + accessToken: string; + context: MatrixQaScenarioContext; + deviceId: string; + encodedRecoveryKey: string; + userId: string; +}): Promise<{ + faultHits: MatrixQaFaultProxyHit[]; + restore: Awaited>; + verification: Awaited>; +}> { + const proxy = await startMatrixQaFaultProxy({ + targetBaseUrl: params.context.baseUrl, + rules: [buildOwnerSignatureUploadBlockedFaultRule(params.accessToken)], + }); + const recoveryClient = await createMatrixQaE2eeScenarioClient({ + accessToken: params.accessToken, + actorId: `driver-recovery-${randomUUID().slice(0, 8)}`, + baseUrl: proxy.baseUrl, + deviceId: params.deviceId, + observedEvents: params.context.observedEvents, + outputDir: requireMatrixQaE2eeOutputDir(params.context), + scenarioId: "matrix-e2ee-recovery-owner-verification-required", + timeoutMs: params.context.timeoutMs, + userId: params.userId, + }); + try { + const verification = await recoveryClient.verifyWithRecoveryKey(params.encodedRecoveryKey); + const restore = await waitForMatrixQaNonEmptyRoomKeyRestore({ + client: recoveryClient, + recoveryKey: params.encodedRecoveryKey, + timeoutMs: params.context.timeoutMs, + }); + return { + faultHits: proxy.hits(), + restore, + verification, + }; + } finally { + await recoveryClient.stop().catch(() => undefined); + await proxy.stop(); + } +} + +function assertMatrixQaFaultedRecoveryOwnerVerificationRequired( + faulted: Awaited>, +) { + if (faulted.faultHits.length === 0) { + throw new Error("Matrix E2EE owner signature fault proxy was not exercised"); + } + if (faulted.verification.success) { + throw new Error( + "Matrix E2EE recovery verification unexpectedly succeeded while owner signature upload was blocked", + ); + } + if (!faulted.verification.recoveryKeyAccepted) { + throw new Error("Matrix E2EE recovery key was not accepted"); + } + if (!faulted.verification.backupUsable) { + throw new Error("Matrix E2EE recovery key did not leave room-key backup usable"); + } + if (faulted.verification.deviceOwnerVerified) { + throw new Error("Matrix E2EE recovery device should still require Matrix identity trust"); + } + if (!faulted.restore.success) { + throw new Error( + `Matrix E2EE room-key backup restore failed after owner-verification fault: ${faulted.restore.error ?? "unknown error"}`, + ); + } +} + function assertMatrixQaExpectedBootstrapFailure(params: { faultHits: MatrixQaFaultProxyHit[]; result: MatrixQaE2eeBootstrapResult; @@ -617,6 +968,7 @@ export async function runMatrixQaE2eeBootstrapSuccessScenario( details: [ "driver bootstrap succeeded through real Matrix crypto bootstrap", `device verified: ${result.verification.verified ? "yes" : "no"}`, + `cross-signing verified: ${result.verification.crossSigningVerified ? "yes" : "no"}`, `signed by owner: ${result.verification.signedByOwner ? "yes" : "no"}`, `cross-signing published: ${result.crossSigning.published ? "yes" : "no"}`, `room-key backup version: ${result.verification.backupVersion ?? ""}`, @@ -677,11 +1029,7 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario( let cleanupRecoveryDevice = true; try { const recoveryVerification = await recoveryClient.verifyWithRecoveryKey(encodedRecoveryKey); - const recoveryKeyUsable = - recoveryVerification.success || - isMatrixQaOwnerVerificationOnlyRecoveryError(recoveryVerification.error) || - hasMatrixQaUsableRecoveryBackup(recoveryVerification); - if (!recoveryVerification.success && !recoveryKeyUsable) { + if (!recoveryVerification.success) { throw new Error( `Matrix E2EE recovery device verification failed: ${recoveryVerification.error ?? "unknown error"}`, ); @@ -709,9 +1057,10 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario( bootstrapSuccess: ready.bootstrap?.success ?? true, recoveryDeviceId: recoveryDevice.deviceId, recoveryKeyId: recoveryKey?.keyId ?? null, - recoveryKeyUsable, + recoveryKeyUsable: + recoveryVerification.recoveryKeyAccepted && recoveryVerification.backupUsable, recoveryKeyStored: true, - recoveryVerified: recoveryVerification.success, + recoveryVerified: recoveryVerification.deviceOwnerVerified, restoreImported: restored.imported, restoreTotal: restored.total, seededEventId, @@ -721,8 +1070,8 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario( `bootstrap backup version: ${ready.verification.backupVersion ?? ""}`, `seeded encrypted event: ${seededEventId}`, `recovery device: ${recoveryDevice.deviceId}`, - `recovery key usable: ${recoveryKeyUsable ? "yes" : "no"}`, - `recovery device verified: ${recoveryVerification.success ? "yes" : "no"}`, + `recovery key usable: ${recoveryVerification.backupUsable ? "yes" : "no"}`, + `recovery device verified: ${recoveryVerification.deviceOwnerVerified ? "yes" : "no"}`, `restore imported/total: ${restored.imported}/${restored.total}`, `restore loaded from secret storage: ${restored.loadedFromSecretStorage ? "yes" : "no"}`, `reset previous version: ${reset.previousVersion ?? ""}`, @@ -739,6 +1088,310 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario( ); } +export async function runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario( + context: MatrixQaScenarioContext, +): Promise { + const driverPassword = requireMatrixQaPassword(context, "driver"); + return await withMatrixQaE2eeDriver( + context, + "matrix-e2ee-recovery-owner-verification-required", + async (client) => { + const { roomId } = resolveMatrixQaE2eeScenarioGroupRoom( + context, + "matrix-e2ee-recovery-owner-verification-required", + ); + const ready = await ensureMatrixQaE2eeOwnDeviceVerified({ + client, + label: "driver", + }); + const recoveryKey = ready.recoveryKey; + const encodedRecoveryKey = recoveryKey?.encodedPrivateKey?.trim(); + if (!encodedRecoveryKey) { + throw new Error("Matrix E2EE bootstrap did not expose an encoded recovery key"); + } + const seededEventId = await client.sendTextMessage({ + body: `E2EE recovery owner-verification seed ${randomUUID().slice(0, 8)}`, + roomId, + }); + const loginClient = createMatrixQaClient({ + baseUrl: context.baseUrl, + }); + const recoveryDevice = await loginClient.loginWithPassword({ + deviceName: "OpenClaw Matrix QA Owner Verification Required Device", + password: driverPassword, + userId: context.driverUserId, + }); + if (!recoveryDevice.deviceId) { + throw new Error("Matrix E2EE recovery login did not return a secondary device id"); + } + try { + const faulted = await runMatrixQaFaultedRecoveryOwnerVerification({ + accessToken: recoveryDevice.accessToken, + context, + deviceId: recoveryDevice.deviceId, + encodedRecoveryKey, + userId: recoveryDevice.userId, + }); + assertMatrixQaFaultedRecoveryOwnerVerificationRequired(faulted); + return { + artifacts: { + backupRestored: faulted.restore.success, + backupUsable: faulted.verification.backupUsable, + faultHitCount: faulted.faultHits.length, + faultedEndpoints: faulted.faultHits.map((hit) => hit.path), + faultRuleId: MATRIX_QA_OWNER_SIGNATURE_UPLOAD_BLOCKED_RULE_ID, + recoveryDeviceId: recoveryDevice.deviceId, + recoveryKeyAccepted: faulted.verification.recoveryKeyAccepted, + recoveryKeyId: recoveryKey?.keyId ?? null, + recoveryVerified: faulted.verification.deviceOwnerVerified, + restoreImported: faulted.restore.imported, + restoreTotal: faulted.restore.total, + verificationSuccess: faulted.verification.success, + }, + details: [ + "driver recovery key unlocked backup while owner signature upload was blocked", + `seeded encrypted event: ${seededEventId}`, + `recovery device: ${recoveryDevice.deviceId}`, + `fault hits: ${faulted.faultHits.length}`, + `recovery key accepted: ${faulted.verification.recoveryKeyAccepted ? "yes" : "no"}`, + `backup usable: ${faulted.verification.backupUsable ? "yes" : "no"}`, + `device owner verified: ${faulted.verification.deviceOwnerVerified ? "yes" : "no"}`, + `restore imported/total: ${faulted.restore.imported}/${faulted.restore.total}`, + ].join("\n"), + }; + } finally { + await client.deleteOwnDevices([recoveryDevice.deviceId]).catch(() => undefined); + } + }, + ); +} + +export async function runMatrixQaE2eeCliSelfVerificationScenario( + context: MatrixQaScenarioContext, +): Promise { + const driverPassword = requireMatrixQaPassword(context, "driver"); + const accountId = "cli"; + return await withMatrixQaE2eeDriver( + context, + "matrix-e2ee-cli-self-verification", + async (owner) => { + const ownerReady = await ensureMatrixQaE2eeOwnDeviceVerified({ + client: owner, + label: "driver", + }); + const encodedRecoveryKey = ownerReady.recoveryKey?.encodedPrivateKey?.trim(); + if (!encodedRecoveryKey) { + throw new Error("Matrix E2EE self-verification scenario did not expose a recovery key"); + } + const loginClient = createMatrixQaClient({ + baseUrl: context.baseUrl, + }); + const cliDevice = await loginClient.loginWithPassword({ + deviceName: "OpenClaw Matrix QA CLI Self Verification Device", + password: driverPassword, + userId: context.driverUserId, + }); + if (!cliDevice.deviceId) { + throw new Error("Matrix E2EE CLI verification login did not return a device id"); + } + + const cli = await createMatrixQaCliSelfVerificationRuntime({ + accountId, + accessToken: cliDevice.accessToken, + context, + deviceId: cliDevice.deviceId, + userId: cliDevice.userId, + }); + try { + const restoreResult = await cli.run([ + "matrix", + "verify", + "backup", + "restore", + "--account", + accountId, + "--recovery-key", + encodedRecoveryKey, + "--json", + ]); + const restoreArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "verify-backup-restore", + result: restoreResult, + rootDir: cli.rootDir, + }); + const restored = parseMatrixQaCliJson(restoreResult) as MatrixQaCliBackupRestoreStatus; + if ( + restored.success !== true || + restored.backup?.decryptionKeyCached !== true || + restored.backup?.matchesDecryptionKey !== true || + restored.backup?.keyLoadError + ) { + throw new Error( + `Matrix CLI recovery key did not load matching room-key backup material before self-verification: ${ + restored.error ?? restored.backup?.keyLoadError ?? "unknown backup state" + }`, + ); + } + const session = cli.start(["matrix", "verify", "self", "--account", accountId]); + try { + const requestOutput = await session.waitForOutput( + (output) => output.text.includes("Accept this verification request"), + "self-verification request guidance", + context.timeoutMs, + ); + const cliTransactionId = parseMatrixQaCliSummaryField( + requestOutput.text, + "Transaction id", + ); + const ownerRequested = await waitForMatrixQaVerificationSummary({ + client: owner, + label: "owner received CLI self-verification request", + predicate: (summary) => + isMatrixQaCliOwnerSelfVerification({ + cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId, + driverUserId: context.driverUserId, + requirePending: true, + summary, + transactionId: cliTransactionId ?? undefined, + }), + timeoutMs: context.timeoutMs, + }); + if (ownerRequested.canAccept) { + await owner.acceptVerification(ownerRequested.id); + } + + const sasOutput = await session.waitForOutput( + (output) => /^SAS (?:emoji|decimals):/m.test(output.text), + "SAS emoji or decimals", + context.timeoutMs, + ); + const cliSas = parseMatrixQaCliSasText( + sasOutput.text, + "interactive openclaw matrix verify self", + ); + const ownerSas = await waitForMatrixQaVerificationSummary({ + client: owner, + label: "owner SAS for CLI self-verification", + predicate: (summary) => + isMatrixQaCliOwnerSelfVerification({ + cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId, + driverUserId: context.driverUserId, + requireSas: true, + summary, + transactionId: cliTransactionId ?? undefined, + }), + timeoutMs: context.timeoutMs, + }); + const sasArtifact = assertMatrixQaCliSasMatches({ + cliSas, + owner: ownerSas, + }); + await session.writeStdin("yes\n"); + await owner.confirmVerificationSas(ownerSas.id); + const completedCli = await session.wait(); + const selfVerificationArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "verify-self", + result: completedCli, + rootDir: cli.rootDir, + }); + if (!/^Device verified by owner:\s*yes$/m.test(completedCli.stdout)) { + throw new Error( + "Interactive Matrix CLI self-verification did not report final device verification", + ); + } + if (!/^Cross-signing verified:\s*yes$/m.test(completedCli.stdout)) { + throw new Error( + "Interactive Matrix CLI self-verification did not report full Matrix identity trust", + ); + } + const completedOwner = await waitForMatrixQaVerificationSummary({ + client: owner, + label: "owner completed CLI self-verification", + predicate: (summary) => + isMatrixQaCliOwnerSelfVerification({ + cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId, + driverUserId: context.driverUserId, + requireCompleted: true, + summary, + transactionId: cliTransactionId ?? undefined, + }), + timeoutMs: context.timeoutMs, + }); + const cliVerificationId = + completedCli.stdout.match(/^Verification id:\s*(\S+)/m)?.[1] ?? "interactive-cli"; + const statusResult = await cli.run([ + "matrix", + "verify", + "status", + "--account", + accountId, + "--json", + ]); + const statusArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "verify-status", + result: statusResult, + rootDir: cli.rootDir, + }); + const status = parseMatrixQaCliJson(statusResult) as MatrixQaCliVerificationStatus; + if ( + status.verified !== true || + status.crossSigningVerified !== true || + status.signedByOwner !== true || + status.backup?.trusted !== true || + status.backup?.matchesDecryptionKey !== true || + status.backup?.keyLoadError + ) { + throw new Error( + `Matrix CLI device was not fully usable after SAS completion: ownerVerified=${ + status.verified === true && + status.crossSigningVerified === true && + status.signedByOwner === true + ? "yes" + : "no" + }, backupUsable=${isMatrixQaCliBackupUsable(status.backup) ? "yes" : "no"}${ + status.backup?.keyLoadError ? `, backupError=${status.backup.keyLoadError}` : "" + }`, + ); + } + return { + artifacts: { + completedVerificationIds: [cliVerificationId, completedOwner.id], + currentDeviceId: status.deviceId ?? cliDevice.deviceId, + ...(cliSas.kind === "emoji" ? { sasEmoji: sasArtifact } : {}), + secondaryDeviceId: cliDevice.deviceId, + }, + details: [ + "Matrix CLI self-verification established full Matrix identity trust through interactive openclaw matrix verify self", + "cli secret config cleaned after run: yes", + `cli backup restore stdout: ${restoreArtifacts.stdoutPath}`, + `cli backup restore stderr: ${restoreArtifacts.stderrPath}`, + `cli verify self stdout: ${selfVerificationArtifacts.stdoutPath}`, + `cli verify self stderr: ${selfVerificationArtifacts.stderrPath}`, + `cli verify status stdout: ${statusArtifacts.stdoutPath}`, + `cli verify status stderr: ${statusArtifacts.stderrPath}`, + `cli device: ${cliDevice.deviceId}`, + `cli verification id: ${cliVerificationId}`, + `owner-side verification id: ${completedOwner.id}`, + `transaction: ${completedOwner.transactionId ?? ""}`, + `cli verified by owner: ${status.verified ? "yes" : "no"}`, + `cli cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}`, + `cli backup usable: ${isMatrixQaCliBackupUsable(status.backup) ? "yes" : "no"}`, + ].join("\n"), + }; + } finally { + session.kill(); + } + } finally { + try { + await cli.dispose(); + } finally { + await owner.deleteOwnDevices([cliDevice.deviceId]).catch(() => undefined); + } + } + }, + ); +} + export async function runMatrixQaE2eeDeviceSasVerificationScenario( context: MatrixQaScenarioContext, ): Promise { diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts index d4c38496ca4..f18a9b89dd5 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts @@ -27,6 +27,7 @@ export type MatrixQaScenarioContext = { observerDeviceId?: string; observerPassword?: string; observerUserId: string; + gatewayRuntimeEnv?: NodeJS.ProcessEnv; gatewayStateDir?: string; outputDir?: string; restartGateway?: () => Promise; diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts index a55271c5544..481d4c1cf31 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts @@ -12,12 +12,14 @@ import { runMatrixQaE2eeArtifactRedactionScenario, runMatrixQaE2eeBasicReplyScenario, runMatrixQaE2eeBootstrapSuccessScenario, + runMatrixQaE2eeCliSelfVerificationScenario, runMatrixQaE2eeDeviceSasVerificationScenario, runMatrixQaE2eeDmSasVerificationScenario, runMatrixQaE2eeKeyBootstrapFailureScenario, runMatrixQaE2eeMediaImageScenario, runMatrixQaE2eeQrVerificationScenario, runMatrixQaE2eeRecoveryKeyLifecycleScenario, + runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario, runMatrixQaE2eeRestartResumeScenario, runMatrixQaE2eeStaleDeviceHygieneScenario, runMatrixQaE2eeThreadFollowUpScenario, @@ -308,6 +310,10 @@ export async function runMatrixQaScenario( return await runMatrixQaE2eeBootstrapSuccessScenario(context); case "matrix-e2ee-recovery-key-lifecycle": return await runMatrixQaE2eeRecoveryKeyLifecycleScenario(context); + case "matrix-e2ee-recovery-owner-verification-required": + return await runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario(context); + case "matrix-e2ee-cli-self-verification": + return await runMatrixQaE2eeCliSelfVerificationScenario(context); case "matrix-e2ee-device-sas-verification": return await runMatrixQaE2eeDeviceSasVerificationScenario(context); case "matrix-e2ee-qr-verification": diff --git a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts index 18605679915..51ccbacb9dc 100644 --- a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts +++ b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, beforeEach, vi } from "vitest"; @@ -11,6 +11,19 @@ const { createMatrixQaE2eeScenarioClient, runMatrixQaE2eeBootstrap, startMatrixQ runMatrixQaE2eeBootstrap: vi.fn(), startMatrixQaFaultProxy: vi.fn(), })); +const { + formatMatrixQaCliCommand, + redactMatrixQaCliOutput, + resolveMatrixQaOpenClawCliEntryPath, + runMatrixQaOpenClawCli, + startMatrixQaOpenClawCli, +} = vi.hoisted(() => ({ + formatMatrixQaCliCommand: (args: string[]) => `openclaw ${args.join(" ")}`, + redactMatrixQaCliOutput: (text: string) => text, + resolveMatrixQaOpenClawCliEntryPath: (cwd: string) => `${cwd}/dist/index.js`, + runMatrixQaOpenClawCli: vi.fn(), + startMatrixQaOpenClawCli: vi.fn(), +})); vi.mock("../../substrate/client.js", () => ({ createMatrixQaClient, @@ -22,6 +35,13 @@ vi.mock("../../substrate/e2ee-client.js", () => ({ vi.mock("../../substrate/fault-proxy.js", () => ({ startMatrixQaFaultProxy, })); +vi.mock("./scenario-runtime-cli.js", () => ({ + formatMatrixQaCliCommand, + redactMatrixQaCliOutput, + resolveMatrixQaOpenClawCliEntryPath, + runMatrixQaOpenClawCli, + startMatrixQaOpenClawCli, +})); import { LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS, @@ -95,6 +115,8 @@ describe("matrix live qa scenarios", () => { createMatrixQaClient.mockReset(); createMatrixQaE2eeScenarioClient.mockReset(); runMatrixQaE2eeBootstrap.mockReset(); + runMatrixQaOpenClawCli.mockReset(); + startMatrixQaOpenClawCli.mockReset(); startMatrixQaFaultProxy.mockReset(); }); @@ -145,6 +167,8 @@ describe("matrix live qa scenarios", () => { "matrix-e2ee-thread-follow-up", "matrix-e2ee-bootstrap-success", "matrix-e2ee-recovery-key-lifecycle", + "matrix-e2ee-recovery-owner-verification-required", + "matrix-e2ee-cli-self-verification", "matrix-e2ee-device-sas-verification", "matrix-e2ee-qr-verification", "matrix-e2ee-stale-device-hygiene", @@ -2583,9 +2607,10 @@ describe("matrix live qa scenarios", () => { serverVersion: "backup-v1", trusted: true, }, - 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.", - success: false, + backupUsable: true, + deviceOwnerVerified: true, + recoveryKeyAccepted: true, + success: true, }); const restoreRoomKeyBackup = vi.fn().mockResolvedValue({ imported: 1, @@ -2618,6 +2643,7 @@ describe("matrix live qa scenarios", () => { success: true, verification: { backupVersion: "backup-v1", + crossSigningVerified: true, recoveryKeyStored: true, signedByOwner: true, verified: true, @@ -2687,7 +2713,7 @@ describe("matrix live qa scenarios", () => { backupRestored: true, recoveryDeviceId: "RECOVERYDEVICE", recoveryKeyUsable: true, - recoveryVerified: false, + recoveryVerified: true, restoreImported: 1, restoreTotal: 1, }, @@ -2699,6 +2725,460 @@ describe("matrix live qa scenarios", () => { ); }); + it("keeps recovery-key backup access distinct from Matrix identity trust in Matrix E2EE QA", async () => { + const verifyWithRecoveryKey = vi.fn().mockResolvedValue({ + backupUsable: true, + deviceOwnerVerified: false, + error: + "Matrix recovery key was applied, but this device still lacks full Matrix identity trust.", + recoveryKeyAccepted: true, + success: false, + }); + const restoreRoomKeyBackup = vi.fn().mockResolvedValue({ + imported: 1, + loadedFromSecretStorage: true, + success: true, + total: 1, + }); + const driverDeleteOwnDevices = vi.fn().mockResolvedValue(undefined); + const driverStop = vi.fn().mockResolvedValue(undefined); + const recoveryStop = vi.fn().mockResolvedValue(undefined); + const proxyStop = vi.fn().mockResolvedValue(undefined); + const proxyHits = vi.fn().mockReturnValue([ + { + method: "POST", + path: "/_matrix/client/v3/keys/signatures/upload", + ruleId: "owner-signature-upload-blocked", + }, + ]); + startMatrixQaFaultProxy.mockResolvedValue({ + baseUrl: "http://127.0.0.1:39877", + hits: proxyHits, + stop: proxyStop, + }); + createMatrixQaClient.mockReturnValue({ + loginWithPassword: vi.fn().mockResolvedValue({ + accessToken: "recovery-token", + deviceId: "RECOVERYDEVICE", + password: "driver-password", + userId: "@driver:matrix-qa.test", + }), + }); + createMatrixQaE2eeScenarioClient + .mockResolvedValueOnce({ + bootstrapOwnDeviceVerification: vi.fn().mockResolvedValue({ + crossSigning: { + published: true, + }, + success: true, + verification: { + backupVersion: "backup-v1", + crossSigningVerified: true, + recoveryKeyStored: true, + signedByOwner: true, + verified: true, + }, + }), + deleteOwnDevices: driverDeleteOwnDevices, + getRecoveryKey: vi.fn().mockResolvedValue({ + encodedPrivateKey: "encoded-recovery-key", + keyId: "SSSS", + }), + sendTextMessage: vi.fn().mockResolvedValue("$seeded-event"), + stop: driverStop, + }) + .mockResolvedValueOnce({ + restoreRoomKeyBackup, + stop: recoveryStop, + verifyWithRecoveryKey, + }); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-e2ee-recovery-owner-verification-required", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + baseUrl: "http://127.0.0.1:28008/", + canary: undefined, + driverAccessToken: "driver-token", + driverDeviceId: "DRIVERDEVICE", + driverPassword: "driver-password", + driverUserId: "@driver:matrix-qa.test", + observedEvents: [], + observerAccessToken: "observer-token", + observerUserId: "@observer:matrix-qa.test", + outputDir: "/tmp/matrix-qa", + roomId: "!main:matrix-qa.test", + restartGateway: undefined, + syncState: {}, + sutAccessToken: "sut-token", + sutUserId: "@sut:matrix-qa.test", + timeoutMs: 8_000, + topology: { + defaultRoomId: "!main:matrix-qa.test", + defaultRoomKey: "main", + rooms: [ + { + encrypted: true, + key: matrixQaE2eeRoomKey("matrix-e2ee-recovery-owner-verification-required"), + kind: "group", + memberRoles: ["driver", "observer", "sut"], + memberUserIds: [ + "@driver:matrix-qa.test", + "@observer:matrix-qa.test", + "@sut:matrix-qa.test", + ], + name: "E2EE", + requireMention: true, + roomId: "!e2ee:matrix-qa.test", + }, + ], + }, + }), + ).resolves.toMatchObject({ + artifacts: { + backupRestored: true, + backupUsable: true, + faultHitCount: 1, + faultRuleId: "owner-signature-upload-blocked", + recoveryDeviceId: "RECOVERYDEVICE", + recoveryKeyAccepted: true, + recoveryVerified: false, + restoreImported: 1, + restoreTotal: 1, + verificationSuccess: false, + }, + }); + + const proxyArgs = startMatrixQaFaultProxy.mock.calls[0]?.[0]; + expect(proxyArgs).toBeDefined(); + if (!proxyArgs) { + throw new Error("expected Matrix QA fault proxy to start"); + } + const [faultRule] = proxyArgs.rules; + expect(faultRule).toBeDefined(); + if (!faultRule) { + throw new Error("expected Matrix QA fault proxy rule"); + } + expect(proxyArgs.targetBaseUrl).toBe("http://127.0.0.1:28008/"); + expect( + faultRule.match({ + bearerToken: "recovery-token", + headers: {}, + method: "POST", + path: "/_matrix/client/v3/keys/signatures/upload", + search: "", + }), + ).toBe(true); + expect( + faultRule.match({ + bearerToken: "recovery-token", + headers: {}, + method: "GET", + path: "/_matrix/client/v3/user/%40driver%3Amatrix-qa.test/account_data/m.megolm_backup.v1", + search: "", + }), + ).toBe(false); + expect(createMatrixQaE2eeScenarioClient).toHaveBeenLastCalledWith( + expect.objectContaining({ + accessToken: "recovery-token", + baseUrl: "http://127.0.0.1:39877", + deviceId: "RECOVERYDEVICE", + scenarioId: "matrix-e2ee-recovery-owner-verification-required", + }), + ); + expect(verifyWithRecoveryKey).toHaveBeenCalledWith("encoded-recovery-key"); + expect(restoreRoomKeyBackup).toHaveBeenCalledWith({ + recoveryKey: "encoded-recovery-key", + }); + expect(driverDeleteOwnDevices).toHaveBeenCalledWith(["RECOVERYDEVICE"]); + expect(recoveryStop).toHaveBeenCalledTimes(1); + expect(proxyStop).toHaveBeenCalledTimes(1); + }); + + it("runs Matrix self-verification through the interactive CLI command", async () => { + const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-cli-self-verification-")); + try { + const acceptVerification = vi.fn().mockResolvedValue(undefined); + const confirmVerificationSas = vi.fn().mockResolvedValue(undefined); + const deleteOwnDevices = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const bootstrapOwnDeviceVerification = vi.fn().mockResolvedValue({ + crossSigning: { + published: true, + }, + success: true, + verification: { + backupVersion: "backup-v1", + crossSigningVerified: true, + recoveryKeyStored: true, + signedByOwner: true, + verified: true, + }, + }); + const baseSummary = { + canAccept: false, + chosenMethod: "m.sas.v1", + completed: false, + createdAt: "2026-04-22T12:00:00.000Z", + error: undefined, + hasReciprocateQr: false, + methods: ["m.sas.v1"], + otherDeviceId: "CLIDEVICE", + otherUserId: "@driver:matrix-qa.test", + pending: true, + phase: 2, + phaseName: "ready", + roomId: undefined, + transactionId: "tx-cli-self", + updatedAt: "2026-04-22T12:00:00.000Z", + }; + const listVerifications = vi + .fn() + .mockResolvedValueOnce([ + { + ...baseSummary, + canAccept: true, + hasSas: false, + id: "owner-request", + initiatedByMe: false, + isSelfVerification: true, + phaseName: "requested", + }, + ]) + .mockResolvedValueOnce([ + { + ...baseSummary, + hasSas: true, + id: "owner-request", + initiatedByMe: false, + isSelfVerification: true, + sas: { + emoji: [["🐶", "Dog"]], + }, + }, + ]) + .mockResolvedValueOnce([ + { + ...baseSummary, + completed: true, + hasSas: true, + id: "owner-request", + initiatedByMe: false, + isSelfVerification: true, + pending: false, + phaseName: "done", + sas: { + emoji: [["🐶", "Dog"]], + }, + }, + ]); + createMatrixQaClient.mockReturnValue({ + loginWithPassword: vi.fn().mockResolvedValue({ + accessToken: "cli-token", + deviceId: "CLIDEVICE", + password: "driver-password", + userId: "@driver:matrix-qa.test", + }), + }); + createMatrixQaE2eeScenarioClient.mockResolvedValueOnce({ + acceptVerification, + bootstrapOwnDeviceVerification, + confirmVerificationSas, + deleteOwnDevices, + getRecoveryKey: vi.fn().mockResolvedValue({ + encodedPrivateKey: "encoded-recovery-key", + keyId: "SSSS", + }), + listVerifications, + stop, + }); + const waitForOutput = vi + .fn() + .mockResolvedValueOnce({ + stderr: "", + stdout: + "Verification id: verification-1\nTransaction id: tx-cli-self\nAccept this verification request in another Matrix client.\n", + text: "Verification id: verification-1\nTransaction id: tx-cli-self\nAccept this verification request in another Matrix client.\n", + }) + .mockResolvedValueOnce({ + stderr: "", + stdout: "Verification id: verification-1\nSAS emoji: 🐶 Dog\n", + text: "Verification id: verification-1\nSAS emoji: 🐶 Dog\n", + }); + const writeStdin = vi.fn().mockResolvedValue(undefined); + const wait = vi.fn().mockResolvedValue({ + args: ["matrix", "verify", "self", "--account", "cli"], + exitCode: 0, + stderr: "", + stdout: + "Verification id: verification-1\nCompleted: yes\nDevice verified by owner: yes\nCross-signing verified: yes\n", + }); + const kill = vi.fn(); + startMatrixQaOpenClawCli.mockReturnValue({ + args: ["matrix", "verify", "self", "--account", "cli"], + kill, + output: vi.fn(() => ({ stderr: "", stdout: "" })), + wait, + waitForOutput, + writeStdin, + }); + let cliAccountConfigDuringRun: Record | null = null; + runMatrixQaOpenClawCli.mockImplementation(async ({ args, env }) => { + if (!cliAccountConfigDuringRun && env.OPENCLAW_CONFIG_PATH) { + const cliConfig = JSON.parse( + await readFile(String(env.OPENCLAW_CONFIG_PATH), "utf8"), + ) as { + channels?: { + matrix?: { + accounts?: Record>; + }; + }; + }; + cliAccountConfigDuringRun = cliConfig.channels?.matrix?.accounts?.cli ?? null; + } + const joined = args.join(" "); + if (joined === "matrix verify status --account cli --json") { + return { + args, + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ + backup: { + decryptionKeyCached: true, + keyLoadError: null, + matchesDecryptionKey: true, + trusted: true, + }, + crossSigningVerified: true, + deviceId: "CLIDEVICE", + signedByOwner: true, + userId: "@driver:matrix-qa.test", + verified: true, + }), + }; + } + if ( + joined === + "matrix verify backup restore --account cli --recovery-key encoded-recovery-key --json" + ) { + return { + args, + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ + backup: { + decryptionKeyCached: true, + keyLoadError: null, + matchesDecryptionKey: true, + trusted: false, + }, + success: true, + }), + }; + } + throw new Error(`unexpected CLI command: ${joined}`); + }); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-e2ee-cli-self-verification", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + ...matrixQaScenarioContext(), + driverDeviceId: "DRIVERDEVICE", + driverPassword: "driver-password", + gatewayRuntimeEnv: { + OPENCLAW_CONFIG_PATH: "/tmp/gateway-config.json", + OPENCLAW_STATE_DIR: "/tmp/gateway-state", + PATH: process.env.PATH, + }, + outputDir, + }), + ).resolves.toMatchObject({ + artifacts: { + completedVerificationIds: ["verification-1", "owner-request"], + currentDeviceId: "CLIDEVICE", + sasEmoji: ["🐶 Dog"], + secondaryDeviceId: "CLIDEVICE", + }, + }); + + expect(startMatrixQaOpenClawCli).toHaveBeenCalledTimes(1); + expect(startMatrixQaOpenClawCli.mock.calls[0]?.[0].args).toEqual([ + "matrix", + "verify", + "self", + "--account", + "cli", + ]); + expect(waitForOutput).toHaveBeenCalledTimes(2); + expect(writeStdin).toHaveBeenCalledWith("yes\n"); + expect(wait).toHaveBeenCalledTimes(1); + expect(kill).toHaveBeenCalledTimes(1); + expect(runMatrixQaOpenClawCli).toHaveBeenCalledTimes(2); + expect(runMatrixQaOpenClawCli.mock.calls.map(([params]) => params.args)).toEqual([ + [ + "matrix", + "verify", + "backup", + "restore", + "--account", + "cli", + "--recovery-key", + "encoded-recovery-key", + "--json", + ], + ["matrix", "verify", "status", "--account", "cli", "--json"], + ]); + const cliEnv = startMatrixQaOpenClawCli.mock.calls[0]?.[0].env; + expect(cliEnv?.OPENCLAW_STATE_DIR).toContain("openclaw-matrix-cli-qa-"); + expect(cliEnv?.OPENCLAW_CONFIG_PATH).toContain("openclaw-matrix-cli-qa-"); + const configPath = String(cliEnv?.OPENCLAW_CONFIG_PATH); + expect(cliAccountConfigDuringRun).toMatchObject({ + accessToken: "cli-token", + deviceId: "CLIDEVICE", + encryption: true, + homeserver: "http://127.0.0.1:28008/", + startupVerification: "off", + userId: "@driver:matrix-qa.test", + }); + await expect(readFile(configPath, "utf8")).rejects.toThrow(); + await expect(readdir(String(cliEnv?.OPENCLAW_STATE_DIR))).rejects.toThrow(); + expect(acceptVerification).toHaveBeenCalledWith("owner-request"); + expect(confirmVerificationSas).toHaveBeenCalledWith("owner-request"); + expect(deleteOwnDevices).toHaveBeenCalledWith(["CLIDEVICE"]); + const [cliRunDir] = await readdir(path.join(outputDir, "cli-self-verification")); + const cliArtifactDir = path.join(outputDir, "cli-self-verification", cliRunDir ?? ""); + await expect(stat(cliArtifactDir)).resolves.toMatchObject({ mode: expect.any(Number) }); + expect((await stat(cliArtifactDir)).mode & 0o777).toBe(0o700); + await expect( + readFile(path.join(cliArtifactDir, "verify-backup-restore.stdout.txt"), "utf8"), + ).resolves.toContain('"success":true'); + expect( + (await stat(path.join(cliArtifactDir, "verify-backup-restore.stdout.txt"))).mode & 0o777, + ).toBe(0o600); + await expect( + readFile(path.join(cliArtifactDir, "verify-self.stdout.txt"), "utf8"), + ).resolves.toContain("Device verified by owner: yes"); + await expect( + readFile(path.join(cliArtifactDir, "verify-self.stdout.txt"), "utf8"), + ).resolves.toContain("Cross-signing verified: yes"); + await expect( + readFile(path.join(cliArtifactDir, "verify-status.stdout.txt"), "utf8"), + ).resolves.toContain('"verified":true'); + await expect( + readFile(path.join(cliArtifactDir, "verify-status.stdout.txt"), "utf8"), + ).resolves.toContain('"crossSigningVerified":true'); + } finally { + await rm(outputDir, { force: true, recursive: true }); + } + }); + it("runs Matrix E2EE bootstrap failure through a real faulted homeserver endpoint", async () => { const stop = vi.fn().mockResolvedValue(undefined); const hits = vi.fn().mockReturnValue([ diff --git a/extensions/qa-matrix/src/substrate/e2ee-client.ts b/extensions/qa-matrix/src/substrate/e2ee-client.ts index 090a72ba6db..5bce56161be 100644 --- a/extensions/qa-matrix/src/substrate/e2ee-client.ts +++ b/extensions/qa-matrix/src/substrate/e2ee-client.ts @@ -46,8 +46,10 @@ const MATRIX_QA_E2EE_SYNC_FILTER = { export type MatrixQaE2eeScenarioClient = { acceptVerification(id: string): Promise; bootstrapOwnDeviceVerification(params?: { + allowAutomaticCrossSigningReset?: boolean; forceResetCrossSigning?: boolean; recoveryKey?: string; + verifyOwnIdentity?: boolean; }): Promise; confirmVerificationReciprocateQr(id: string): Promise; confirmVerificationSas(id: string): Promise;