From a0a927ddb0184585cc8d8cd94d6720f818e19820 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 22 Apr 2026 20:18:48 -0400 Subject: [PATCH] matrix: require full identity trust --- docs/channels/matrix.md | 135 +++- docs/install/migrating-matrix.md | 24 +- extensions/matrix/src/cli.test.ts | 290 +++++++- extensions/matrix/src/cli.ts | 450 +++++++++++- .../src/matrix/actions/verification.test.ts | 231 ++++++ .../matrix/src/matrix/actions/verification.ts | 199 ++++++ .../matrix/src/matrix/client/logging.ts | 84 +-- extensions/matrix/src/matrix/sdk.test.ts | 81 ++- extensions/matrix/src/matrix/sdk.ts | 97 ++- .../src/matrix/sdk/crypto-bootstrap.test.ts | 43 ++ .../matrix/src/matrix/sdk/crypto-bootstrap.ts | 39 +- .../src/matrix/sdk/crypto-facade.test.ts | 63 ++ .../matrix/src/matrix/sdk/crypto-facade.ts | 25 + extensions/matrix/src/matrix/sdk/types.ts | 8 + .../src/matrix/sdk/verification-manager.ts | 1 + .../src/matrix/sdk/verification-status.ts | 2 +- .../src/runners/contract/scenario-catalog.ts | 22 + .../runners/contract/scenario-runtime-cli.ts | 194 +++++ .../runners/contract/scenario-runtime-e2ee.ts | 662 +++++++++++++++++- .../contract/scenario-runtime-shared.ts | 1 + .../src/runners/contract/scenario-runtime.ts | 6 + .../src/runners/contract/scenarios.test.ts | 465 +++++++++++- .../qa-matrix/src/substrate/e2ee-client.ts | 2 + 23 files changed, 2986 insertions(+), 138 deletions(-) create mode 100644 extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts 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..03c154c4367 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..ac99b0b5914 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,245 @@ 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( + "- Accept the verification request in another Matrix client for this account.", + ); + expect(consoleLogMock).toHaveBeenCalledWith( + "- Then run 'openclaw matrix verify start self-verify-1 --account ops' to start SAS verification.", + ); + expect(consoleLogMock).toHaveBeenCalledWith( + "- Run 'openclaw matrix verify sas self-verify-1 --account ops' to display the SAS emoji or decimals.", + ); + expect(consoleLogMock).toHaveBeenCalledWith( + "- When the SAS matches, run 'openclaw matrix verify confirm-sas self-verify-1 --account ops'.", + ); + }); + + 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("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("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, @@ -360,7 +644,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"); @@ -630,7 +914,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 +1049,7 @@ describe("matrix CLI verification commands", () => { }); expect(process.exitCode).toBe(1); - expect(console.log).toHaveBeenCalledWith( + expect(stdoutWriteMock).toHaveBeenCalledWith( expect.stringContaining('"error": "Matrix requires --homeserver"'), ); }); diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index a5455c14d3e..980bf8e1ccd 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 { @@ -92,8 +105,7 @@ function printAccountLabel(accountId?: string): void { } 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): { @@ -301,7 +313,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 @@ -357,12 +369,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 +405,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, @@ -490,6 +503,43 @@ type MatrixCliVerificationStatus = { recoveryKeyStored: boolean; recoveryKeyCreatedAt: string | null; pendingVerifications: number; + recoveryKeyAccepted?: boolean; + backupUsable?: boolean; + deviceOwnerVerified?: boolean; +}; + +type MatrixCliVerificationCommandOptions = { + account?: string; + verbose?: boolean; + json?: boolean; +}; + +type MatrixCliSelfVerificationCommandOptions = { + account?: string; + timeoutMs?: string; + verbose?: boolean; +}; + +type MatrixCliVerificationSummary = { + id: string; + transactionId?: 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 = { @@ -595,6 +645,151 @@ function printVerificationTrustDiagnostics(status: { console.log(`Signed by owner: ${status.signedByOwner ? "yes" : "no"}`); } +function printMatrixVerificationSummary(summary: MatrixCliVerificationSummary): void { + console.log(`Verification id: ${summary.id}`); + if (summary.transactionId) { + console.log(`Transaction id: ${summary.transactionId}`); + } + console.log(`Other user: ${summary.otherUserId}`); + console.log(`Other device: ${summary.otherDeviceId ?? "unknown"}`); + console.log(`Self-verification: ${summary.isSelfVerification ? "yes" : "no"}`); + console.log(`Initiated by OpenClaw: ${summary.initiatedByMe ? "yes" : "no"}`); + console.log(`Phase: ${summary.phaseName}`); + console.log(`Pending: ${summary.pending ? "yes" : "no"}`); + console.log(`Completed: ${summary.completed ? "yes" : "no"}`); + console.log(`Methods: ${summary.methods.length ? summary.methods.join(", ") : "none"}`); + if (summary.chosenMethod) { + console.log(`Chosen method: ${summary.chosenMethod}`); + } + if (summary.hasSas && summary.sas?.emoji?.length) { + console.log( + `SAS emoji: ${summary.sas.emoji.map(([emoji, label]) => `${emoji} ${label}`).join(" | ")}`, + ); + } else if (summary.hasSas && summary.sas?.decimal) { + console.log(`SAS decimals: ${summary.sas.decimal.join(" ")}`); + } + if (summary.error) { + console.log(`Verification error: ${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: ${sas.emoji.map(([emoji, label]) => `${emoji} ${label}`).join(" | ")}`); + } else if (sas.decimal) { + console.log(`SAS decimals: ${sas.decimal.join(" ")}`); + } else { + console.log("SAS: unavailable"); + } +} + +function printMatrixVerificationSasGuidance(requestId: string, accountId?: string): void { + printGuidance([ + `Compare the emoji or decimals with the other Matrix client.`, + `If they match, run '${formatMatrixCliCommand(`verify confirm-sas ${requestId}`, accountId)}'.`, + `If they do not match, run '${formatMatrixCliCommand(`verify mismatch-sas ${requestId}`, accountId)}'.`, + ]); +} + +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(requestId: string, accountId?: string): void { + printGuidance([ + `Accept the verification request in another Matrix client for this account.`, + `Then run '${formatMatrixCliCommand(`verify start ${requestId}`, accountId)}' to start SAS verification.`, + `Run '${formatMatrixCliCommand(`verify sas ${requestId}`, accountId)}' to display the SAS emoji or decimals.`, + `When the SAS matches, run '${formatMatrixCliCommand(`verify confirm-sas ${requestId}`, accountId)}'.`, + ]); +} + +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)); } @@ -615,9 +810,18 @@ 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( @@ -922,6 +1126,204 @@ 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.id, accountId); + }, + errorPrefix: "Verification request failed", + }); + }, + ); + + verify + .command("accept ") + .description("Accept an inbound Matrix verification request") + .option("--account ", "Account ID (for multi-account setups)") + .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 }), + afterText: (summary, accountId) => { + printGuidance([ + `Run '${formatMatrixCliCommand(`verify start ${summary.id}`, accountId)}' 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("--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" }), + afterText: (summary, accountId) => + printMatrixVerificationSasGuidance(summary.id, accountId), + 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("--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 }), + onText: (sas) => { + printAccountLabel(accountId); + console.log(`Verification id: ${id}`); + printMatrixVerificationSas(sas); + printMatrixVerificationSasGuidance(id, accountId); + }, + 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("--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 }), + 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("--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 }), + errorPrefix: "Verification SAS mismatch failed", + }); + }); + + verify + .command("cancel ") + .description("Cancel a Matrix verification request") + .option("--account ", "Account ID (for multi-account setups)") + .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, + }), + errorPrefix: "Verification cancel failed", + }); + }, + ); + verify .command("status") .description("Check Matrix device verification status") @@ -1152,10 +1554,30 @@ export function registerMatrixCli(params: { program: Command }): void { printAccountLabel(accountId); if (!result.success) { console.error(`Verification failed: ${result.error ?? "unknown 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 +1609,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,11 +1629,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 pruneMatrixStaleGatewayDevices({ accountId }), + run: async () => await pruneMatrixStaleGatewayDevices({ accountId, cfg }), onText: (result, verbose) => { printAccountLabel(accountId); console.log( diff --git a/extensions/matrix/src/matrix/actions/verification.test.ts b/extensions/matrix/src/matrix/actions/verification.test.ts index 9b62747c5a1..2e975b4356a 100644 --- a/extensions/matrix/src/matrix/actions/verification.test.ts +++ b/extensions/matrix/src/matrix/actions/verification.test.ts @@ -35,6 +35,7 @@ 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; describe("matrix verification actions", () => { beforeAll(async () => { @@ -43,6 +44,7 @@ describe("matrix verification actions", () => { getMatrixRoomKeyBackupStatus, getMatrixVerificationStatus, listMatrixVerifications, + runMatrixSelfVerification, } = await import("./verification.js")); }); @@ -55,6 +57,40 @@ 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, + }; + } + it("points encryption guidance at the selected Matrix account", async () => { loadConfigMock.mockReturnValue({ channels: { @@ -213,4 +249,199 @@ describe("matrix verification actions", () => { expect(withResolvedActionClientMock).toHaveBeenCalledTimes(2); expect(withStartedActionClientMock).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 bootstrapOwnDeviceVerification = vi.fn(async () => ({ + success: true, + verification: mockVerifiedOwnerStatus(), + })); + withStartedActionClientMock.mockImplementation(async (_opts, run) => { + return await run({ bootstrapOwnDeviceVerification, crypto, 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, + verifyOwnIdentity: true, + }); + expect(getOwnDeviceVerificationStatus).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 getOwnDeviceVerificationStatus = vi + .fn() + .mockResolvedValueOnce(mockUnverifiedOwnerStatus()) + .mockResolvedValueOnce(mockVerifiedOwnerStatus()); + const bootstrapOwnDeviceVerification = vi.fn(async () => ({ + success: true, + verification: mockVerifiedOwnerStatus(), + })); + withStartedActionClientMock.mockImplementation(async (_opts, run) => { + return await run({ bootstrapOwnDeviceVerification, crypto, getOwnDeviceVerificationStatus }); + }); + + await expect( + runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }), + ).resolves.toMatchObject({ + completed: true, + deviceOwnerVerified: true, + ownerVerification: { + verified: true, + }, + }); + + expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2); + }); + + 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 () => ({ + success: false, + error: "cross-signing identity is still not trusted", + verification: mockUnverifiedOwnerStatus(), + })); + withStartedActionClientMock.mockImplementation(async (_opts, run) => { + return await run({ + bootstrapOwnDeviceVerification, + crypto, + getOwnDeviceVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()), + }); + }); + + await expect( + runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 30 }), + ).rejects.toThrow( + "Matrix self-verification completed, but full Matrix identity trust is still incomplete", + ); + + expect(crypto.cancelVerification).not.toHaveBeenCalled(); + }); + + 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", + }); + }); }); diff --git a/extensions/matrix/src/matrix/actions/verification.ts b/extensions/matrix/src/matrix/actions/verification.ts index 0979dd61317..eaff46c67d5 100644 --- a/extensions/matrix/src/matrix/actions/verification.ts +++ b/extensions/matrix/src/matrix/actions/verification.ts @@ -1,10 +1,23 @@ +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 { 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; + +export type MatrixSelfVerificationResult = MatrixVerificationSummary & { + deviceOwnerVerified: boolean; + ownerVerification: MatrixOwnDeviceVerificationStatus; +}; + function requireCrypto( client: import("../sdk.js").MatrixClient, opts: MatrixActionClientOpts, @@ -29,6 +42,98 @@ function resolveVerificationId(input: string): string { return normalized; } +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" + ); +} + +async function waitForMatrixVerificationSummary(params: { + crypto: MatrixCryptoActionFacade; + label: string; + request: MatrixVerificationSummary; + timeoutMs: number; + predicate: (summary: MatrixVerificationSummary) => boolean; +}): 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; + } + } + 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: 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 waitForMatrixOwnerVerificationStatus(params: { + client: MatrixActionClient; + timeoutMs: number; +}): Promise { + const startedAt = Date.now(); + let last: MatrixOwnDeviceVerificationStatus | undefined; + while (Date.now() - startedAt < params.timeoutMs) { + last = await params.client.getOwnDeviceVerificationStatus(); + if (last.verified) { + return last; + } + 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, + )}). 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); +} + export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) { return await withStartedActionClient(opts, async (client) => { const crypto = requireCrypto(client, opts); @@ -56,6 +161,100 @@ 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); + + let ready = requested; + if (!ready.hasSas) { + ready = await waitForMatrixVerificationSummary({ + crypto, + label: "be accepted in another Matrix client", + request: requested, + timeoutMs, + predicate: isMatrixVerificationReadyForSas, + }); + } + await params.onReady?.(ready); + + const started = ready.hasSas ? ready : await crypto.startVerification(ready.id, "sas"); + let sasSummary = started; + if (!sasSummary.hasSas) { + sasSummary = await waitForMatrixVerificationSummary({ + crypto, + label: "show SAS emoji or decimals", + request: started, + timeoutMs, + predicate: (summary) => summary.hasSas, + }); + } + 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) { + handledByMismatch = true; + await crypto.mismatchVerificationSas(sasSummary.id); + 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; + const bootstrap = await client.bootstrapOwnDeviceVerification({ + allowAutomaticCrossSigningReset: false, + verifyOwnIdentity: true, + }); + if (!bootstrap.success) { + throw new Error( + `Matrix self-verification completed, but full Matrix identity trust is still incomplete: ${ + bootstrap.error ?? formatMatrixOwnerVerificationDiagnostics(bootstrap.verification) + }`, + ); + } + const ownerVerification = await waitForMatrixOwnerVerificationStatus({ client, timeoutMs }); + return { + ...completed, + deviceOwnerVerified: ownerVerification.verified, + ownerVerification, + }; + } catch (error) { + if (!requestCompleted && !handledByMismatch) { + await cancelMatrixSelfVerificationOnFailure({ crypto, request: requested }); + } + throw error; + } + }); +} + export async function acceptMatrixVerification( requestId: string, opts: MatrixActionClientOpts = {}, diff --git a/extensions/matrix/src/matrix/client/logging.ts b/extensions/matrix/src/matrix/client/logging.ts index 386ca295eb6..f75ce101a62 100644 --- a/extensions/matrix/src/matrix/client/logging.ts +++ b/extensions/matrix/src/matrix/client/logging.ts @@ -1,7 +1,7 @@ +import { logger as matrixJsSdkLogger } from "matrix-js-sdk/lib/logger.js"; import { ConsoleLogger, LogService, setMatrixConsoleLogging } from "../sdk/logger.js"; let matrixSdkLoggingConfigured = false; -let matrixSdkLogMode: "default" | "quiet" = "default"; const matrixSdkBaseLogger = new ConsoleLogger(); type MatrixJsSdkLogger = { @@ -13,17 +13,16 @@ type MatrixJsSdkLogger = { getChild: (namespace: string) => MatrixJsSdkLogger; }; -function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean { - if (!module.includes("MatrixHttpClient")) { - return false; - } - return messageOrObject.some((entry) => { - if (!entry || typeof entry !== "object") { - return false; - } - return (entry as { errcode?: string }).errcode === "M_NOT_FOUND"; - }); -} +type MatrixJsSdkLoglevelLogger = MatrixJsSdkLogger & { + levels?: { DEBUG?: number }; + methodFactory?: ( + methodName: string, + logLevel: number, + loggerName: string | symbol, + ) => (...args: unknown[]) => void; + rebuild?: () => void; + setLevel?: (level: number | string, persist?: boolean) => void; +}; export function ensureMatrixSdkLoggingConfigured(): void { if (!matrixSdkLoggingConfigured) { @@ -33,7 +32,7 @@ export function ensureMatrixSdkLoggingConfigured(): void { } export function setMatrixSdkLogMode(mode: "default" | "quiet"): void { - matrixSdkLogMode = mode; + void mode; if (!matrixSdkLoggingConfigured) { return; } @@ -49,36 +48,48 @@ export function createMatrixJsSdkClientLogger(prefix = "matrix"): MatrixJsSdkLog } function applyMatrixSdkLogger(): void { - if (matrixSdkLogMode === "quiet") { - LogService.setLogger({ - trace: () => {}, - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }); - return; - } - LogService.setLogger({ trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject), debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject), info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject), warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject), - error: (module, ...messageOrObject) => { - if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) { - return; - } - matrixSdkBaseLogger.error(module, ...messageOrObject); - }, + error: (module, ...messageOrObject) => matrixSdkBaseLogger.error(module, ...messageOrObject), }); + applyMatrixJsSdkLogger(); +} + +function normalizeMatrixJsSdkLogMethod(methodName: string): keyof ConsoleLogger { + if (methodName === "trace" || methodName === "debug" || methodName === "info") { + return methodName; + } + if (methodName === "warn" || methodName === "error") { + return methodName; + } + return "debug"; +} + +function formatMatrixJsSdkLoggerName(loggerName: string | symbol): string { + return typeof loggerName === "symbol" ? loggerName.toString() : loggerName; +} + +function applyMatrixJsSdkLogger(): void { + const logger = matrixJsSdkLogger as MatrixJsSdkLoglevelLogger; + logger.methodFactory = (methodName, _logLevel, loggerName) => { + const method = normalizeMatrixJsSdkLogMethod(methodName); + const module = formatMatrixJsSdkLoggerName(loggerName); + return (...messageOrObject) => { + (matrixSdkBaseLogger[method] as (module: string, ...args: unknown[]) => void)( + module, + ...messageOrObject, + ); + }; + }; + logger.setLevel?.(logger.levels?.DEBUG ?? "debug", false); + logger.rebuild?.(); } function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger { const log = (method: keyof ConsoleLogger, ...messageOrObject: unknown[]): void => { - if (matrixSdkLogMode === "quiet") { - return; - } (matrixSdkBaseLogger[method] as (module: string, ...args: unknown[]) => void)( prefix, ...messageOrObject, @@ -90,12 +101,7 @@ function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger { debug: (...messageOrObject) => log("debug", ...messageOrObject), info: (...messageOrObject) => log("info", ...messageOrObject), warn: (...messageOrObject) => log("warn", ...messageOrObject), - error: (...messageOrObject) => { - if (shouldSuppressMatrixHttpNotFound(prefix, messageOrObject)) { - return; - } - log("error", ...messageOrObject); - }, + error: (...messageOrObject) => log("error", ...messageOrObject), getChild: (namespace: string) => { const nextNamespace = namespace.trim(); return createMatrixJsSdkLoggerInstance(nextNamespace ? `${prefix}.${nextNamespace}` : prefix); diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index f490bb247d7..f5357c91fcb 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -1248,7 +1248,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 +1273,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 +1493,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(() => ({ @@ -1591,6 +1591,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,7 +1603,46 @@ describe("MatrixClient crypto bootstrapping", () => { expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1); }); - it("fails recovery-key verification when the device is only locally trusted", async () => { + 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(async () => {}), + 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 encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1))); matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); @@ -1621,19 +1663,35 @@ 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-usable-")); + const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json"); 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); 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.error).toContain("not verified by its owner"); + expect(result.recoveryKeyStored).toBe(true); + expect(fs.existsSync(recoveryKeyPath)).toBe(true); }); it("fails recovery-key verification when backup remains untrusted after device verification", async () => { @@ -1680,6 +1738,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 +1800,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 +2599,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..2e62255e2de 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,11 +163,15 @@ const MATRIX_AUTOMATIC_REPAIR_BOOTSTRAP_OPTIONS = { } satisfies MatrixCryptoBootstrapOptions; function createMatrixExplicitBootstrapOptions(params?: { + allowAutomaticCrossSigningReset?: boolean; forceResetCrossSigning?: boolean; + verifyOwnIdentity?: boolean; }): MatrixCryptoBootstrapOptions { return { forceResetCrossSigning: params?.forceResetCrossSigning === true, + allowAutomaticCrossSigningReset: params?.allowAutomaticCrossSigningReset !== false, allowSecretStorageRecreateWithoutRecoveryKey: true, + verifyOwnIdentity: params?.verifyOwnIdentity === true, strict: true, }; } @@ -1110,12 +1117,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, @@ -1127,11 +1132,25 @@ export class MatrixClient { 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"); @@ -1168,22 +1187,42 @@ 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 recoveryKeyAccepted = status.verified || backupUsable; + if (!status.verified) { + if (backupUsable) { + this.recoveryKeyStore.commitStagedRecoveryKey({ + keyId: await this.resolveDefaultSecretStorageKeyId(crypto), + }); + } 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, }; @@ -1195,6 +1234,9 @@ export class MatrixClient { const committedStatus = await this.getOwnDeviceVerificationStatus(); return { success: true, + recoveryKeyAccepted: true, + backupUsable, + deviceOwnerVerified: true, verifiedAt: new Date().toISOString(), ...committedStatus, }; @@ -1419,8 +1461,10 @@ export class MatrixClient { } async bootstrapOwnDeviceVerification(params?: { + allowAutomaticCrossSigningReset?: boolean; recoveryKey?: string; forceResetCrossSigning?: boolean; + verifyOwnIdentity?: boolean; }): Promise { const pendingVerifications = async (): Promise => this.crypto ? (await this.crypto.listVerifications()).length : 0; @@ -1680,12 +1724,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..8197ba36dae 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts @@ -253,6 +253,49 @@ describe("MatrixCryptoBootstrapper", () => { ); }); + it("can 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, + verifyOwnIdentity: true, + }); + + expect(verifyOwnIdentity).toHaveBeenCalledTimes(1); + expect(freeOwnIdentity).toHaveBeenCalledTimes(1); + 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); diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts index cbcd942dabe..20e1f6dd622 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts @@ -28,6 +28,7 @@ export type MatrixCryptoBootstrapOptions = { forceResetCrossSigning?: boolean; allowAutomaticCrossSigningReset?: boolean; allowSecretStorageRecreateWithoutRecoveryKey?: boolean; + verifyOwnIdentity?: boolean; strict?: boolean; }; @@ -83,7 +84,10 @@ export class MatrixCryptoBootstrapper { strict, }); } - const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict); + const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, { + strict, + verifyOwnIdentity: options.verifyOwnIdentity === true, + }); return { crossSigningReady: crossSigning.ready, crossSigningPublished: crossSigning.published, @@ -347,9 +351,30 @@ export class MatrixCryptoBootstrapper { LogService.info("MatrixClientLite", "Verification request handler registered"); } + private async verifyOwnIdentityTrust(crypto: MatrixCryptoBootstrapApi): Promise { + if (typeof crypto.getOwnIdentity !== "function") { + return; + } + const identity = await crypto.getOwnIdentity(); + if (!identity) { + return; + } + try { + if (identity.isVerified?.() === true) { + return; + } + await identity.verify?.(); + } finally { + identity.free?.(); + } + } + private async ensureOwnDeviceTrust( crypto: MatrixCryptoBootstrapApi, - strict = false, + options: { + strict: boolean; + verifyOwnIdentity: boolean; + }, ): Promise { const deviceId = this.deps.getDeviceId()?.trim(); if (!deviceId) { @@ -367,6 +392,10 @@ export class MatrixCryptoBootstrapper { return true; } + if (options.verifyOwnIdentity) { + await this.verifyOwnIdentityTrust(crypto); + } + if (typeof crypto.setDeviceVerified === "function") { await crypto.setDeviceVerified(userId, deviceId, true); } @@ -386,8 +415,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/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.ts b/extensions/matrix/src/matrix/sdk/verification-manager.ts index df7065010f4..2ffd071096c 100644 --- a/extensions/matrix/src/matrix/sdk/verification-manager.ts +++ b/extensions/matrix/src/matrix/sdk/verification-manager.ts @@ -101,6 +101,7 @@ export type MatrixVerificationRequestLike = { export type MatrixVerificationCryptoApi = { requestOwnUserVerification: () => Promise; + getVerificationRequestsToDeviceInProgress?: (userId: string) => MatrixVerificationRequestLike[]; findVerificationRequestDMInProgress?: ( roomId: string, userId: string, 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.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts new file mode 100644 index 00000000000..0cdb422d153 --- /dev/null +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts @@ -0,0 +1,194 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; + +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; +}; + +function formatMatrixQaCliCommand(args: string[]) { + return `openclaw ${args.join(" ")}`; +} + +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${result.stderr.trim()}` : null, + result.stdout.trim() ? `stdout:\n${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 = path.join(cwd, "dist", "index.js"); + 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 = spawn(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 (${params.args.join(" ")}): ${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( + `openclaw ${params.args.join(" ")} did not print ${label} before timeout\nstdout:\n${output.stdout.trim()}\nstderr:\n${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..0335d01fb6d 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,6 @@ import { randomUUID } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +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 +27,11 @@ import { hasMatrixQaExpectedColorReply, MATRIX_QA_IMAGE_ATTACHMENT_FILENAME, } from "./scenario-media-fixtures.js"; +import { + runMatrixQaOpenClawCli, + startMatrixQaOpenClawCli, + type MatrixQaCliRunResult, +} from "./scenario-runtime-cli.js"; import { assertThreadReplyArtifact, assertTopLevelReplyArtifact, @@ -40,8 +47,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 +81,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 +114,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 +231,218 @@ 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( + `openclaw ${result.args.join(" ")} printed JSON with extra output\nstdout:\n${stdout}\nstderr:\n${stderr}`, + ); + } + if (stdout) { + try { + return parseMatrixQaCliJsonText(stdout); + } catch (error) { + throw new Error( + `openclaw ${result.args.join(" ")} printed invalid JSON: ${ + error instanceof Error ? error.message : String(error) + }\nstdout:\n${stdout}`, + { cause: error }, + ); + } + } + + if (!stderr) { + throw new Error(`openclaw ${result.args.join(" ")} did not print JSON`); + } + try { + return parseMatrixQaCliJsonText(stderr); + } catch (error) { + throw new Error( + `openclaw ${result.args.join(" ")} printed invalid JSON: ${ + error instanceof Error ? error.message : String(error) + }\nstderr:\n${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; +}) { + 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, params.result.stdout), + writeFile(stderrPath, params.result.stderr), + ]); + return { stderrPath, stdoutPath }; +} + +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 = 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 mkdir(stateDir, { recursive: true }); + 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`, + { mode: 0o600 }, + ); + 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, + run, + rootDir, + start, + stateDir, + }; +} + function assertMatrixQaSasEmojiMatches(params: { initiator: MatrixVerificationSummary; recipient: MatrixVerificationSummary; @@ -205,20 +458,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 +644,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 +687,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 +941,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 +1002,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 +1030,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 +1043,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 +1061,300 @@ 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, + }); + 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 config path: ${cli.configPath}`, + `cli state dir: ${cli.stateDir}`, + `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(); + } + }, + ); +} + 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..24fb0a24027 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, 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,10 @@ const { createMatrixQaE2eeScenarioClient, runMatrixQaE2eeBootstrap, startMatrixQ runMatrixQaE2eeBootstrap: vi.fn(), startMatrixQaFaultProxy: vi.fn(), })); +const { runMatrixQaOpenClawCli, startMatrixQaOpenClawCli } = vi.hoisted(() => ({ + runMatrixQaOpenClawCli: vi.fn(), + startMatrixQaOpenClawCli: vi.fn(), +})); vi.mock("../../substrate/client.js", () => ({ createMatrixQaClient, @@ -22,6 +26,10 @@ vi.mock("../../substrate/e2ee-client.js", () => ({ vi.mock("../../substrate/fault-proxy.js", () => ({ startMatrixQaFaultProxy, })); +vi.mock("./scenario-runtime-cli.js", () => ({ + runMatrixQaOpenClawCli, + startMatrixQaOpenClawCli, +})); import { LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS, @@ -95,6 +103,8 @@ describe("matrix live qa scenarios", () => { createMatrixQaClient.mockReset(); createMatrixQaE2eeScenarioClient.mockReset(); runMatrixQaE2eeBootstrap.mockReset(); + runMatrixQaOpenClawCli.mockReset(); + startMatrixQaOpenClawCli.mockReset(); startMatrixQaFaultProxy.mockReset(); }); @@ -145,6 +155,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 +2595,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 +2631,7 @@ describe("matrix live qa scenarios", () => { success: true, verification: { backupVersion: "backup-v1", + crossSigningVerified: true, recoveryKeyStored: true, signedByOwner: true, verified: true, @@ -2687,7 +2701,7 @@ describe("matrix live qa scenarios", () => { backupRestored: true, recoveryDeviceId: "RECOVERYDEVICE", recoveryKeyUsable: true, - recoveryVerified: false, + recoveryVerified: true, restoreImported: 1, restoreTotal: 1, }, @@ -2699,6 +2713,447 @@ 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, + }); + runMatrixQaOpenClawCli.mockImplementation(async ({ args }) => { + 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("cli-self-verification"); + expect(cliEnv?.OPENCLAW_CONFIG_PATH).toContain("cli-self-verification"); + const configPath = String(cliEnv?.OPENCLAW_CONFIG_PATH); + const cliConfig = JSON.parse(await readFile(configPath, "utf8")) as { + channels?: { + matrix?: { + accounts?: Record>; + }; + }; + }; + expect(cliConfig.channels?.matrix?.accounts?.cli).toMatchObject({ + accessToken: "cli-token", + deviceId: "CLIDEVICE", + encryption: true, + homeserver: "http://127.0.0.1:28008/", + startupVerification: "off", + userId: "@driver:matrix-qa.test", + }); + expect(acceptVerification).toHaveBeenCalledWith("owner-request"); + expect(confirmVerificationSas).toHaveBeenCalledWith("owner-request"); + expect(deleteOwnDevices).not.toHaveBeenCalled(); + const [cliRunDir] = await readdir(path.join(outputDir, "cli-self-verification")); + const cliArtifactDir = path.join(outputDir, "cli-self-verification", cliRunDir ?? ""); + await expect( + readFile(path.join(cliArtifactDir, "verify-backup-restore.stdout.txt"), "utf8"), + ).resolves.toContain('"success":true'); + 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;