From 38e9d9084eaa63b977f704ca1989392c02a8f1b4 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 23 Apr 2026 16:51:29 -0400 Subject: [PATCH] fix(matrix): harden self-verification trust --- CHANGELOG.md | 2 +- extensions/matrix/src/cli.test.ts | 52 ++++++-- extensions/matrix/src/cli.ts | 9 +- .../src/matrix/actions/verification.test.ts | 13 +- .../matrix/src/matrix/actions/verification.ts | 10 +- .../matrix/src/matrix/client/logging.test.ts | 44 ------- .../matrix/src/matrix/client/logging.ts | 122 ++++-------------- extensions/matrix/src/matrix/sdk.test.ts | 46 +++++-- extensions/matrix/src/matrix/sdk.ts | 27 +++- .../src/matrix/sdk/crypto-bootstrap.test.ts | 3 +- .../matrix/src/matrix/sdk/crypto-bootstrap.ts | 18 ++- .../matrix/sdk/verification-manager.test.ts | 4 +- .../src/matrix/sdk/verification-manager.ts | 7 +- src/logging/redact.test.ts | 26 ---- src/logging/redact.ts | 6 +- 15 files changed, 181 insertions(+), 208 deletions(-) delete mode 100644 extensions/matrix/src/matrix/client/logging.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fabff70079..fd26208175f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Meet: let realtime Meet sessions consult the full OpenClaw agent for deeper answers while staying in the live voice loop. - Gateway/VoiceClaw: add a realtime brain WebSocket endpoint backed by Gemini Live, with owner-auth gating and async OpenClaw tool handoff. (#70938) Thanks @yagudaev. - Providers/DeepSeek: add DeepSeek V4 Flash and V4 Pro to the bundled catalog and make V4 Flash the onboarding default. +- Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras. ### Fixes @@ -219,7 +220,6 @@ Docs: https://docs.openclaw.ai - QA/Telegram: record per-scenario reply RTT in the live Telegram QA report and summary, starting with the canary response. (#70550) Thanks @obviyus. - Status: add an explicit `Runner:` field to `/status` so sessions now report whether they are running on embedded Pi, a CLI-backed provider, or an ACP harness agent/backend such as `codex (acp/acpx)` or `gemini (acp/acpx)`. (#70595) - Gateway/diagnostics: enable payload-free stability recording by default and add a support-ready diagnostics export with sanitized logs, status, health, config, and stability snapshots for bug reports. (#70324) Thanks @gumadeiras. -- Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras. ### Fixes diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index da5ae86cc33..12fcd328304 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -378,13 +378,13 @@ describe("matrix CLI verification commands", () => { "- Accept the verification request in another Matrix client for this account.", ); expect(consoleLogMock).toHaveBeenCalledWith( - "- Then run openclaw matrix verify start txn-1 --account ops to start SAS verification.", + "- Then run openclaw matrix verify start --account ops -- txn-1 to start SAS verification.", ); expect(consoleLogMock).toHaveBeenCalledWith( - "- Run openclaw matrix verify sas txn-1 --account ops to display the SAS emoji or decimals.", + "- Run openclaw matrix verify sas --account ops -- txn-1 to display the SAS emoji or decimals.", ); expect(consoleLogMock).toHaveBeenCalledWith( - "- When the SAS matches, run openclaw matrix verify confirm-sas txn-1 --account ops.", + "- When the SAS matches, run openclaw matrix verify confirm-sas --account ops -- txn-1.", ); }); @@ -425,13 +425,39 @@ describe("matrix CLI verification commands", () => { }); expect(consoleLogMock).toHaveBeenCalledWith("Room id: !room-'$(x):example.org"); expect(consoleLogMock).toHaveBeenCalledWith( - "- Then run openclaw matrix verify start txn-dm --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' to start SAS verification.", + "- Then run openclaw matrix verify start --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm to start SAS verification.", ); expect(consoleLogMock).toHaveBeenCalledWith( - "- Run openclaw matrix verify sas txn-dm --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' to display the SAS emoji or decimals.", + "- Run openclaw matrix verify sas --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm to display the SAS emoji or decimals.", ); expect(consoleLogMock).toHaveBeenCalledWith( - "- When the SAS matches, run openclaw matrix verify confirm-sas txn-dm --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org'.", + "- When the SAS matches, run openclaw matrix verify confirm-sas --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm.", + ); + }); + + it("terminates options before remote Matrix verification ids in follow-up commands", async () => { + requestMatrixVerificationMock.mockResolvedValue( + mockMatrixVerificationSummary({ + id: "local-id", + transactionId: "--account=evil", + hasSas: false, + sas: undefined, + }), + ); + const program = buildProgram(); + + await program.parseAsync(["matrix", "verify", "request", "--own-user", "--account", "ops"], { + from: "user", + }); + + expect(consoleLogMock).toHaveBeenCalledWith( + "- Then run openclaw matrix verify start --account ops -- --account=evil to start SAS verification.", + ); + expect(consoleLogMock).toHaveBeenCalledWith( + "- Run openclaw matrix verify sas --account ops -- --account=evil to display the SAS emoji or decimals.", + ); + expect(consoleLogMock).toHaveBeenCalledWith( + "- When the SAS matches, run openclaw matrix verify confirm-sas --account ops -- --account=evil.", ); }); @@ -545,13 +571,13 @@ describe("matrix CLI verification commands", () => { }); expect(consoleLogMock).toHaveBeenCalledWith( - "- Then run openclaw matrix verify start 'txn-'\\''$(touch /tmp/pwn)' to start SAS verification.", + "- Then run openclaw matrix verify start -- 'txn-'\\''$(touch /tmp/pwn)' to start SAS verification.", ); expect(consoleLogMock).toHaveBeenCalledWith( - "- Run openclaw matrix verify sas 'txn-'\\''$(touch /tmp/pwn)' to display the SAS emoji or decimals.", + "- Run openclaw matrix verify sas -- 'txn-'\\''$(touch /tmp/pwn)' to display the SAS emoji or decimals.", ); expect(consoleLogMock).toHaveBeenCalledWith( - "- When the SAS matches, run openclaw matrix verify confirm-sas 'txn-'\\''$(touch /tmp/pwn)'.", + "- When the SAS matches, run openclaw matrix verify confirm-sas -- 'txn-'\\''$(touch /tmp/pwn)'.", ); }); @@ -569,10 +595,10 @@ describe("matrix CLI verification commands", () => { }); expect(consoleLogMock).toHaveBeenCalledWith("SAS decimals: 1234 5678 9012"); expect(consoleLogMock).toHaveBeenCalledWith( - "- If they match, run openclaw matrix verify confirm-sas self-1.", + "- 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.", + "- If they do not match, run openclaw matrix verify mismatch-sas -- self-1.", ); }); @@ -611,7 +637,7 @@ describe("matrix CLI verification commands", () => { verificationDmRoomId: "!dm:example.org", }); expect(consoleLogMock).toHaveBeenCalledWith( - "- If they match, run openclaw matrix verify confirm-sas txn-dm --user-id @alice:example.org --room-id '!dm:example.org' --account ops.", + "- If they match, run openclaw matrix verify confirm-sas --user-id @alice:example.org --room-id '!dm:example.org' --account ops -- txn-dm.", ); }); @@ -627,7 +653,7 @@ describe("matrix CLI verification commands", () => { await program.parseAsync(["matrix", "verify", "accept", "verification-1"], { from: "user" }); expect(consoleLogMock).toHaveBeenCalledWith( - "- Run openclaw matrix verify start txn-stable to start SAS verification.", + "- Run openclaw matrix verify start -- txn-stable to start SAS verification.", ); }); diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index 28123f1d75c..c67e0990c11 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -127,7 +127,12 @@ function formatMatrixCliCommandParts(parts: string[], accountId?: string): strin const normalizedAccountId = normalizeAccountId(accountId); const command = ["openclaw", "matrix", ...parts]; if (normalizedAccountId !== "default") { - command.push("--account", normalizedAccountId); + const optionTerminatorIndex = command.indexOf("--"); + if (optionTerminatorIndex >= 0) { + command.splice(optionTerminatorIndex, 0, "--account", normalizedAccountId); + } else { + command.push("--account", normalizedAccountId); + } } return command.map(formatMatrixCliShellArg).join(" "); } @@ -910,7 +915,7 @@ function formatMatrixVerificationFollowupCommand(params: { dmParts?: string[]; }): string { return formatMatrixCliCommandParts( - ["verify", params.action, params.requestId, ...(params.dmParts ?? [])], + ["verify", params.action, ...(params.dmParts ?? []), "--", params.requestId], params.accountId, ); } diff --git a/extensions/matrix/src/matrix/actions/verification.test.ts b/extensions/matrix/src/matrix/actions/verification.test.ts index d33d017736a..8665221240f 100644 --- a/extensions/matrix/src/matrix/actions/verification.test.ts +++ b/extensions/matrix/src/matrix/actions/verification.test.ts @@ -422,10 +422,11 @@ describe("matrix verification actions", () => { requestVerification: vi.fn(async () => requested), startVerification: vi.fn(async () => sas), }; - const getOwnDeviceVerificationStatus = vi + const getOwnDeviceIdentityVerificationStatus = vi .fn() .mockResolvedValueOnce(mockUnverifiedOwnerStatus()) .mockResolvedValueOnce(mockVerifiedOwnerStatus()); + const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus()); const getOwnCrossSigningPublicationStatus = vi.fn(async () => mockCrossSigningPublicationStatus(), ); @@ -440,6 +441,7 @@ describe("matrix verification actions", () => { bootstrapOwnDeviceVerification, crypto, getOwnCrossSigningPublicationStatus, + getOwnDeviceIdentityVerificationStatus, getOwnDeviceVerificationStatus, trustOwnIdentityAfterSelfVerification, }); @@ -455,7 +457,8 @@ describe("matrix verification actions", () => { }, }); - expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2); + expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2); + expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1); expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2); expect(trustOwnIdentityAfterSelfVerification).toHaveBeenCalledTimes(1); }); @@ -487,6 +490,7 @@ describe("matrix verification actions", () => { requestVerification: vi.fn(async () => requested), startVerification: vi.fn(async () => sas), }; + const getOwnDeviceIdentityVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus()); const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus()); const getOwnCrossSigningPublicationStatus = vi .fn() @@ -503,6 +507,7 @@ describe("matrix verification actions", () => { bootstrapOwnDeviceVerification, crypto, getOwnCrossSigningPublicationStatus, + getOwnDeviceIdentityVerificationStatus, getOwnDeviceVerificationStatus, trustOwnIdentityAfterSelfVerification, }); @@ -518,7 +523,8 @@ describe("matrix verification actions", () => { }, }); - expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2); + expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2); + expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1); expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2); expect(trustOwnIdentityAfterSelfVerification).not.toHaveBeenCalled(); }); @@ -749,6 +755,7 @@ describe("matrix verification actions", () => { getOwnCrossSigningPublicationStatus: vi.fn(async () => mockCrossSigningPublicationStatus(false), ), + getOwnDeviceIdentityVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()), getOwnDeviceVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()), }); }); diff --git a/extensions/matrix/src/matrix/actions/verification.ts b/extensions/matrix/src/matrix/actions/verification.ts index 2fc44470cbb..3f1dee60622 100644 --- a/extensions/matrix/src/matrix/actions/verification.ts +++ b/extensions/matrix/src/matrix/actions/verification.ts @@ -3,7 +3,7 @@ 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 { MatrixDeviceVerificationStatus, MatrixOwnDeviceVerificationStatus } from "../sdk.js"; import type { MatrixVerificationSummary } from "../sdk/verification-manager.js"; import { withResolvedActionClient, withStartedActionClient } from "./client.js"; import type { MatrixActionClientOpts } from "./types.js"; @@ -158,7 +158,7 @@ async function waitForMatrixVerificationSummary(params: { } function formatMatrixOwnerVerificationDiagnostics( - status: MatrixOwnDeviceVerificationStatus | undefined, + status: MatrixDeviceVerificationStatus | MatrixOwnDeviceVerificationStatus | undefined, ): string { if (!status) { return "Matrix identity trust status was unavailable"; @@ -173,17 +173,17 @@ async function waitForMatrixSelfVerificationTrustStatus(params: { timeoutMs: number; }): Promise { const startedAt = Date.now(); - let last: MatrixOwnDeviceVerificationStatus | undefined; + let last: MatrixDeviceVerificationStatus | undefined; let crossSigningPublished = false; while (Date.now() - startedAt < params.timeoutMs) { const [status, crossSigning] = await Promise.all([ - params.client.getOwnDeviceVerificationStatus(), + params.client.getOwnDeviceIdentityVerificationStatus(), params.client.getOwnCrossSigningPublicationStatus(), ]); last = status; crossSigningPublished = crossSigning.published; if (last.verified && crossSigningPublished) { - return last; + return await params.client.getOwnDeviceVerificationStatus(); } await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt)))); } diff --git a/extensions/matrix/src/matrix/client/logging.test.ts b/extensions/matrix/src/matrix/client/logging.test.ts deleted file mode 100644 index 8ebe1950f5d..00000000000 --- a/extensions/matrix/src/matrix/client/logging.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { logger as matrixJsSdkLogger } from "matrix-js-sdk/lib/logger.js"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { LogService } from "../sdk/logger.js"; -import { - createMatrixJsSdkClientLogger, - ensureMatrixSdkLoggingConfigured, - setMatrixSdkConsoleLogging, - setMatrixSdkLogMode, -} from "./logging.js"; - -describe("Matrix SDK logging", () => { - afterEach(() => { - setMatrixSdkLogMode("default"); - setMatrixSdkConsoleLogging(false); - vi.restoreAllMocks(); - delete process.env.MATRIX_SDK_DEBUG; - delete process.env.OPENCLAW_MATRIX_SDK_DEBUG; - }); - - it("suppresses Matrix SDK client logs in quiet mode", () => { - setMatrixSdkConsoleLogging(true); - setMatrixSdkLogMode("quiet"); - ensureMatrixSdkLoggingConfigured(); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - - createMatrixJsSdkClientLogger("MatrixClient").info("should be quiet"); - matrixJsSdkLogger.info("global logger should be quiet"); - LogService.info("MatrixClient", "should also be quiet"); - - expect(info).not.toHaveBeenCalled(); - }); - - it("does not force Matrix JS SDK debug logs by default", () => { - const loglevelLogger = matrixJsSdkLogger as unknown as { - levels: { INFO: number }; - setLevel: (level: number | string, persist?: boolean) => void; - }; - const setLevel = vi.spyOn(loglevelLogger, "setLevel").mockImplementation(() => undefined); - - ensureMatrixSdkLoggingConfigured(); - - expect(setLevel).toHaveBeenLastCalledWith(loglevelLogger.levels.INFO, false); - }); -}); diff --git a/extensions/matrix/src/matrix/client/logging.ts b/extensions/matrix/src/matrix/client/logging.ts index 7970c1e46b7..386ca295eb6 100644 --- a/extensions/matrix/src/matrix/client/logging.ts +++ b/extensions/matrix/src/matrix/client/logging.ts @@ -1,12 +1,9 @@ -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 MatrixLogMethod = "trace" | "debug" | "info" | "warn" | "error"; - type MatrixJsSdkLogger = { trace: (...messageOrObject: unknown[]) => void; debug: (...messageOrObject: unknown[]) => void; @@ -16,24 +13,17 @@ type MatrixJsSdkLogger = { getChild: (namespace: string) => MatrixJsSdkLogger; }; -type MatrixJsSdkLoglevelLogger = MatrixJsSdkLogger & { - levels?: { DEBUG?: number; ERROR?: number; INFO?: number }; - methodFactory?: ( - methodName: string, - logLevel: number, - loggerName: string | symbol, - ) => (...args: unknown[]) => void; - rebuild?: () => void; - setLevel?: (level: number | string, persist?: boolean) => void; -}; - -const quietMatrixSdkLogger = { - trace: () => {}, - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, -}; +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"; + }); +} export function ensureMatrixSdkLoggingConfigured(): void { if (!matrixSdkLoggingConfigured) { @@ -58,97 +48,41 @@ export function createMatrixJsSdkClientLogger(prefix = "matrix"): MatrixJsSdkLog return createMatrixJsSdkLoggerInstance(prefix); } -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"; - }); -} - -function writeMatrixSdkLog( - method: MatrixLogMethod, - module: string, - messageOrObject: unknown[], -): void { - matrixSdkBaseLogger[method](module, ...messageOrObject); -} - function applyMatrixSdkLogger(): void { if (matrixSdkLogMode === "quiet") { - LogService.setLogger(quietMatrixSdkLogger); - applyMatrixJsSdkLogger(); + LogService.setLogger({ + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }); return; } LogService.setLogger({ - trace: (module, ...messageOrObject) => writeMatrixSdkLog("trace", module, messageOrObject), - debug: (module, ...messageOrObject) => writeMatrixSdkLog("debug", module, messageOrObject), - info: (module, ...messageOrObject) => writeMatrixSdkLog("info", module, messageOrObject), - warn: (module, ...messageOrObject) => writeMatrixSdkLog("warn", module, messageOrObject), + 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; } - writeMatrixSdkLog("error", module, messageOrObject); + matrixSdkBaseLogger.error(module, ...messageOrObject); }, }); - applyMatrixJsSdkLogger(); -} - -function normalizeMatrixJsSdkLogMethod(methodName: string): MatrixLogMethod { - 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) => { - if (matrixSdkLogMode === "quiet") { - return; - } - if (method === "error" && shouldSuppressMatrixHttpNotFound(module, messageOrObject)) { - return; - } - writeMatrixSdkLog(method, module, messageOrObject); - }; - }; - logger.setLevel?.(resolveMatrixJsSdkLogLevel(logger), false); - logger.rebuild?.(); -} - -function resolveMatrixJsSdkLogLevel(logger: MatrixJsSdkLoglevelLogger): number | string { - if (matrixSdkLogMode === "quiet") { - return logger.levels?.ERROR ?? "error"; - } - if (process.env.OPENCLAW_MATRIX_SDK_DEBUG === "1" || process.env.MATRIX_SDK_DEBUG === "1") { - return logger.levels?.DEBUG ?? "debug"; - } - return logger.levels?.INFO ?? "info"; } function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger { - const log = (method: MatrixLogMethod, ...messageOrObject: unknown[]): void => { + const log = (method: keyof ConsoleLogger, ...messageOrObject: unknown[]): void => { if (matrixSdkLogMode === "quiet") { return; } - writeMatrixSdkLog(method, prefix, messageOrObject); + (matrixSdkBaseLogger[method] as (module: string, ...args: unknown[]) => void)( + prefix, + ...messageOrObject, + ); }; return { diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 1fa20a85a65..fecaa83b828 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -33,17 +33,18 @@ function stubRuntimeFetch(fetchImpl: typeof fetch): void { }; } -async function consumeMatrixSecretStorageKey(keyId = "SSSSKEY"): Promise { +async function consumeMatrixSecretStorageKey(keyId = "SSSSKEY"): Promise { const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as { getSecretStorageKey?: ( params: { keys: Record }, name: string, ) => Promise<[string, Uint8Array] | null>; } | null; - await callbacks?.getSecretStorageKey?.( + const result = await callbacks?.getSecretStorageKey?.( { keys: { [keyId]: { algorithm: "m.secret_storage.v1.aes-hmac-sha2" } } }, "m.cross_signing.master", ); + return Boolean(result); } class FakeMatrixEvent extends EventEmitter { @@ -1245,6 +1246,25 @@ describe("MatrixClient crypto bootstrapping", () => { expect(freeOwnIdentity).toHaveBeenCalledTimes(1); }); + it("does not fail self-verification cleanup when own identity verify is unavailable", async () => { + const freeOwnIdentity = vi.fn(); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + getOwnIdentity: vi.fn(async () => ({ + free: freeOwnIdentity, + isVerified: () => false, + })), + requestOwnUserVerification: vi.fn(async () => null), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", { + encryption: true, + }); + + await expect(client.trustOwnIdentityAfterSelfVerification()).resolves.toBeUndefined(); + expect(freeOwnIdentity).toHaveBeenCalledTimes(1); + }); + it("retries bootstrap with forced reset when initial publish/verification is incomplete", async () => { matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() })); const client = new MatrixClient("https://matrix.example.org", "token", { @@ -1640,10 +1660,12 @@ describe("MatrixClient crypto bootstrapping", () => { }); it("accepts a staged recovery key when it establishes identity trust and backup usability", async () => { - const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1))); + const privateKey = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)); + const encoded = encodeRecoveryKey(privateKey); matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + let backupKeyLoaded = false; matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn(), bootstrapCrossSigning: vi.fn(async () => {}), @@ -1661,8 +1683,11 @@ describe("MatrixClient crypto bootstrapping", () => { signedByOwner: true, })), checkKeyBackupAndEnable: vi.fn(async () => {}), - getActiveSessionBackupVersion: vi.fn(async () => "11"), - getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + loadSessionBackupPrivateKeyFromSecretStorage: vi.fn(async () => { + backupKeyLoaded = await consumeMatrixSecretStorageKey(); + }), + getActiveSessionBackupVersion: vi.fn(async () => (backupKeyLoaded ? "11" : null)), + getSessionBackupPrivateKey: vi.fn(async () => (backupKeyLoaded ? privateKey : null)), getKeyBackupInfo: vi.fn(async () => ({ algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", auth_data: {}, @@ -1731,10 +1756,12 @@ describe("MatrixClient crypto bootstrapping", () => { }); it("keeps a usable recovery key distinct from owner device verification", async () => { - const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1))); + const privateKey = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)); + const encoded = encodeRecoveryKey(privateKey); matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + let backupKeyLoaded = false; matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn(), bootstrapCrossSigning: vi.fn(async () => {}), @@ -1752,8 +1779,11 @@ describe("MatrixClient crypto bootstrapping", () => { signedByOwner: false, })), checkKeyBackupAndEnable: vi.fn(async () => {}), - getActiveSessionBackupVersion: vi.fn(async () => "11"), - getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + loadSessionBackupPrivateKeyFromSecretStorage: vi.fn(async () => { + backupKeyLoaded = await consumeMatrixSecretStorageKey(); + }), + getActiveSessionBackupVersion: vi.fn(async () => (backupKeyLoaded ? "11" : null)), + getSessionBackupPrivateKey: vi.fn(async () => (backupKeyLoaded ? privateKey : null)), getKeyBackupInfo: vi.fn(async () => ({ algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", auth_data: {}, diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index 8c68cec2a5e..36e197098a6 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -1136,6 +1136,16 @@ export class MatrixClient { }; } + async getOwnDeviceIdentityVerificationStatus(): Promise { + const userId = this.client.getUserId() ?? this.selfUserId ?? null; + const deviceId = this.client.getDeviceId()?.trim() || null; + const deviceVerification = await this.getDeviceVerificationStatus(userId, deviceId); + return { + ...deviceVerification, + verified: deviceVerification.crossSigningVerified, + }; + } + async trustOwnIdentityAfterSelfVerification(): Promise { if (!this.encryptionEnabled) { return; @@ -1157,7 +1167,7 @@ export class MatrixClient { return; } if (typeof ownIdentity.verify !== "function") { - throw new Error("Matrix crypto backend does not support trusting own identity"); + return; } await ownIdentity.verify(); } finally { @@ -1199,6 +1209,10 @@ export class MatrixClient { return await fail("Matrix crypto is not available (start client with encryption enabled)"); } + const backupUsableBeforeStagedRecovery = + resolveMatrixRoomKeyBackupReadinessError(await this.getRoomKeyBackupStatus(), { + requireServerBackup: true, + }) === null; const trimmedRecoveryKey = rawRecoveryKey.trim(); if (!trimmedRecoveryKey) { return await fail("Matrix recovery key is required"); @@ -1240,9 +1254,18 @@ export class MatrixClient { const stagedRecoveryKeyConfirmedBySecretStorage = Boolean(stagedKeyId) && secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId ?? ""] === true; + const stagedRecoveryKeyRejectedBySecretStorage = + Boolean(stagedKeyId) && + secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId ?? ""] === false; + const stagedRecoveryKeyUnlockedBackup = + stagedRecoveryKeyUsed && + !stagedRecoveryKeyRejectedBySecretStorage && + !stagedRecoveryKeyConfirmedBySecretStorage && + !backupUsableBeforeStagedRecovery && + backupUsable; const stagedRecoveryKeyValidated = stagedRecoveryKeyUsed && - (stagedRecoveryKeyConfirmedBySecretStorage || (status.verified && backupUsable)); + (stagedRecoveryKeyConfirmedBySecretStorage || stagedRecoveryKeyUnlockedBackup); const recoveryKeyAccepted = stagedRecoveryKeyValidated && (status.verified || backupUsable); if (!status.verified) { if (backupUsable && stagedRecoveryKeyValidated) { diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts index 835cdb6ddb3..e2e5d2064b2 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts @@ -196,7 +196,8 @@ describe("MatrixCryptoBootstrapper", () => { userHasCrossSigningKeys: vi .fn<() => Promise>() .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true), + .mockResolvedValueOnce(true) + .mockResolvedValue(true), getDeviceVerificationStatus: vi.fn(async () => ({ isVerified: () => true, })), diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts index 330fb0c0b2f..257c76b9c35 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts @@ -1,3 +1,4 @@ +import { setTimeout as sleep } from "node:timers/promises"; import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js"; import type { MatrixDecryptBridge } from "./decrypt-bridge.js"; import { LogService } from "./logger.js"; @@ -37,6 +38,8 @@ export type MatrixCryptoBootstrapResult = { ownDeviceVerified: boolean | null; }; +const CROSS_SIGNING_PUBLICATION_WAIT_MS = 5_000; + export class MatrixCryptoBootstrapper { private verificationHandlerRegistered = false; @@ -167,7 +170,9 @@ export class MatrixCryptoBootstrapper { const finalize = async (): Promise<{ ready: boolean; published: boolean }> => { const ready = await isCrossSigningReady(); - const published = await hasPublishedCrossSigningKeys(); + const published = ready + ? await waitForPublishedCrossSigningKeys() + : await hasPublishedCrossSigningKeys(); if (ready && published) { LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); return { ready, published }; @@ -180,6 +185,17 @@ export class MatrixCryptoBootstrapper { return { ready, published }; }; + const waitForPublishedCrossSigningKeys = async (): Promise => { + const startedAt = Date.now(); + do { + if (await hasPublishedCrossSigningKeys()) { + return true; + } + await sleep(250); + } while (Date.now() - startedAt < CROSS_SIGNING_PUBLICATION_WAIT_MS); + return false; + }; + if (options.forceResetCrossSigning) { const resetCrossSigning = async (): Promise => { await crypto.bootstrapCrossSigning({ diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts index b39e0ee7deb..65b56777930 100644 --- a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts +++ b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts @@ -520,7 +520,7 @@ describe("MatrixVerificationManager", () => { } }); - it("cross-signs the other own device after auto-confirmed self-verification SAS", async () => { + it("does not cross-sign the other own device after auto-confirmed self-verification SAS", async () => { vi.useFakeTimers(); const { confirm, verifier } = createSasVerifierFixture({ decimal: [6158, 1986, 3513], @@ -541,7 +541,7 @@ describe("MatrixVerificationManager", () => { await vi.advanceTimersByTimeAsync(30_100); expect(confirm).toHaveBeenCalledTimes(1); - expect(trustOwnDeviceAfterSas).toHaveBeenCalledWith("OTHERDEVICE"); + expect(trustOwnDeviceAfterSas).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.ts b/extensions/matrix/src/matrix/sdk/verification-manager.ts index 1e5ff531ba3..08698a61cf8 100644 --- a/extensions/matrix/src/matrix/sdk/verification-manager.ts +++ b/extensions/matrix/src/matrix/sdk/verification-manager.ts @@ -504,7 +504,7 @@ export class MatrixVerificationManager { return; } session.sasAutoConfirmStarted = true; - void this.confirmSasForSession(session, callbacks) + void this.confirmSasForSession(session, callbacks, { trustOwnDevice: false }) .then(() => { this.touchVerificationSession(session); }) @@ -518,9 +518,12 @@ export class MatrixVerificationManager { private async confirmSasForSession( session: MatrixVerificationSession, callbacks: MatrixShowSasCallbacks, + opts: { trustOwnDevice: boolean } = { trustOwnDevice: true }, ): Promise { await callbacks.confirm(); - await this.trustOwnDeviceAfterConfirmedSas(session); + if (opts.trustOwnDevice) { + await this.trustOwnDeviceAfterConfirmedSas(session); + } } private ensureVerificationStarted(session: MatrixVerificationSession): void { diff --git a/src/logging/redact.test.ts b/src/logging/redact.test.ts index df5e74dc3a4..3e3d0449850 100644 --- a/src/logging/redact.test.ts +++ b/src/logging/redact.test.ts @@ -18,21 +18,6 @@ describe("redactSensitiveText", () => { expect(output).toBe("OPENAI_API_KEY=sk-123…cdef"); }); - it("masks env assignments after punctuation delimiters", () => { - expect( - redactSensitiveText("(OPENAI_API_KEY=sk-1234567890abcdef)", { - mode: "tools", - patterns: defaults, - }), - ).toBe("(OPENAI_API_KEY=sk-123…cdef)"); - expect( - redactSensitiveText("[MATRIX_ACCESS_TOKEN=abcdef1234567890ghij]", { - mode: "tools", - patterns: defaults, - }), - ).toBe("[MATRIX_ACCESS_TOKEN=abcdef…ghij]"); - }); - it("masks CLI flags", () => { const input = "curl --token abcdef1234567890ghij https://api.test"; const output = redactSensitiveText(input, { @@ -60,17 +45,6 @@ describe("redactSensitiveText", () => { expect(output).toBe('{"token":"abcdef…ghij"}'); }); - it("masks Matrix access token fields and query parameters", () => { - const json = '{"access_token":"abcdef1234567890ghij"}'; - const url = "https://matrix.example/_matrix/client/v3/sync?access_token=zyxwv9876543210token"; - expect(redactSensitiveText(json, { mode: "tools", patterns: defaults })).toBe( - '{"access_token":"abcdef…ghij"}', - ); - expect(redactSensitiveText(url, { mode: "tools", patterns: defaults })).toBe( - "https://matrix.example/_matrix/client/v3/sync?access_token=zyxwv9…oken", - ); - }); - it("masks bearer tokens", () => { const input = "Authorization: Bearer abcdef1234567890ghij"; const output = redactSensitiveText(input, { diff --git a/src/logging/redact.ts b/src/logging/redact.ts index 08cb799dcea..2be750805e2 100644 --- a/src/logging/redact.ts +++ b/src/logging/redact.ts @@ -15,11 +15,9 @@ const DEFAULT_REDACT_KEEP_END = 4; const DEFAULT_REDACT_PATTERNS: string[] = [ // ENV-style assignments. - String.raw`(?:^|[\s([{,;])\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)\b\s*[=:]\s*(["']?)([^\s"'\\)\]\},;]+)\1`, + String.raw`\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1`, // JSON fields. - String.raw`"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken|access_token|refresh_token)"\s*:\s*"([^"]+)"`, - // URL query parameters. - String.raw`([?&](?:access_token|refresh_token)=)([^&#\s]+)`, + String.raw`"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken)"\s*:\s*"([^"]+)"`, // CLI flags. String.raw`--(?:api[-_]?key|hook[-_]?token|token|secret|password|passwd)\s+(["']?)([^\s"']+)\1`, // Authorization headers.