From b2753fd0dede9cf3918eec769cf3ab40558ffe5f Mon Sep 17 00:00:00 2001 From: saram ali <140950904+SARAMALI15792@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:48:29 +0500 Subject: [PATCH] fix(matrix): fix E2EE SSSS bootstrap for passwordless token-auth bots (#66228) Merged via squash. Prepared head SHA: c62cebf7c3f043cf4a950f222a3a3dc477ac5cad Co-authored-by: SARAMALI15792 <140950904+SARAMALI15792@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/channels/matrix.md | 3 +- extensions/matrix/src/matrix/sdk.test.ts | 60 ++++++++++++++++--- extensions/matrix/src/matrix/sdk.ts | 9 +-- .../src/matrix/sdk/crypto-bootstrap.test.ts | 43 +++++++++++++ .../matrix/src/matrix/sdk/crypto-bootstrap.ts | 17 ++++-- 6 files changed, 114 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d9eb4bc0c..d5b39aa830a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559. +- Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792. ## 2026.4.15-beta.1 diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 01c0ae6cd98..f540eced2ab 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -613,7 +613,8 @@ if you want a shorter or longer retry window. Startup also performs a conservative crypto bootstrap pass automatically. That pass tries to reuse the current secret storage and cross-signing identity first, and avoids resetting cross-signing unless you run an explicit bootstrap repair flow. -If startup finds broken bootstrap state and `channels.matrix.password` is configured, OpenClaw can attempt a stricter repair path. +If startup still finds broken bootstrap state, OpenClaw can attempt a guarded repair path even when `channels.matrix.password` is not configured. +If the homeserver requires password-based UIA for that repair, OpenClaw logs a warning and keeps startup non-fatal instead of aborting the bot. If the current device is already owner-signed, OpenClaw preserves that identity instead of resetting it automatically. See [Matrix migration](/install/migrating-matrix) for the full upgrade flow, limits, recovery commands, and common migration messages. diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 1bbcc197927..2f6531d07c0 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -1283,16 +1283,24 @@ describe("MatrixClient crypto bootstrapping", () => { }); }); - it("does not force-reset bootstrap when password is unavailable", async () => { + it("attempts repair bootstrap even when no password is configured", async () => { matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() })); const client = new MatrixClient("https://matrix.example.org", "token", { encryption: true, + // no password — passwordless token-auth bot }); - const bootstrapSpy = vi.fn().mockResolvedValue({ - crossSigningReady: false, - crossSigningPublished: false, - ownDeviceVerified: false, - }); + const bootstrapSpy = vi + .fn() + .mockResolvedValueOnce({ + crossSigningReady: false, + crossSigningPublished: false, + ownDeviceVerified: false, + }) + .mockResolvedValueOnce({ + crossSigningReady: true, + crossSigningPublished: true, + ownDeviceVerified: true, + }); await ( client as unknown as { ensureCryptoSupportInitialized: () => Promise; @@ -1306,7 +1314,45 @@ describe("MatrixClient crypto bootstrapping", () => { await client.start(); - expect(bootstrapSpy).toHaveBeenCalledTimes(1); + expect(bootstrapSpy).toHaveBeenCalledTimes(2); + expect((bootstrapSpy.mock.calls as unknown[][])[1]?.[1] ?? {}).toEqual({ + forceResetCrossSigning: true, + allowSecretStorageRecreateWithoutRecoveryKey: true, + strict: true, + }); + }); + + it("catches and logs repair bootstrap failure when UIA is unavailable without password", async () => { + matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() })); + const client = new MatrixClient("https://matrix.example.org", "token", { + encryption: true, + // no password + }); + const uiaError = new Error("Interactive auth required"); + const bootstrapSpy = vi + .fn() + .mockResolvedValueOnce({ + crossSigningReady: false, + crossSigningPublished: false, + ownDeviceVerified: false, + }) + .mockRejectedValueOnce(uiaError); + await ( + client as unknown as { + ensureCryptoSupportInitialized: () => Promise; + } + ).ensureCryptoSupportInitialized(); + ( + client as unknown as { + cryptoBootstrapper: { bootstrap: typeof bootstrapSpy }; + } + ).cryptoBootstrapper.bootstrap = bootstrapSpy; + + // start() must NOT throw even when the repair bootstrap fails + await expect(client.start()).resolves.not.toThrow(); + + // repair was attempted + expect(bootstrapSpy).toHaveBeenCalledTimes(2); }); it("provides secret storage callbacks and resolves stored recovery key", async () => { diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index eaceecef873..62771d14243 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -606,7 +606,9 @@ export class MatrixClient { "MatrixClientLite", "Cross-signing/bootstrap is incomplete for an already owner-signed device; skipping automatic reset and preserving the current identity. Restore the recovery key or run an explicit verification bootstrap if repair is needed.", ); - } else if (this.password?.trim()) { + } else { + // No password guard: passwordless token-auth bots should still attempt repair. + // UIA failures inside bootstrap() are caught below and logged as warnings. try { // The repair path already force-resets cross-signing; allow secret storage // recreation so the new keys can be persisted. Without this, a device that @@ -630,11 +632,6 @@ export class MatrixClient { err, ); } - } else { - LogService.warn( - "MatrixClientLite", - "Cross-signing/bootstrap incomplete and no password is configured for UIA fallback", - ); } } this.cryptoBootstrapped = true; diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts index 07ee889f6fd..a30a1405e0f 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts @@ -277,6 +277,49 @@ describe("MatrixCryptoBootstrapper", () => { expectSecretStorageRepairRetry(deps, crypto, bootstrapCrossSigning); }); + it("does not mutate secret storage before forced repair fails on password UIA without a password", async () => { + const deps = createBootstrapperDeps(); + deps.getPassword = vi.fn(() => undefined); + const bootstrapCrossSigning = vi.fn< + ({ + authUploadDeviceSigningKeys, + }: { + authUploadDeviceSigningKeys?: ( + makeRequest: (authData: Record | null) => Promise, + ) => Promise; + }) => Promise + >(async ({ authUploadDeviceSigningKeys }) => { + await authUploadDeviceSigningKeys?.(async (authData) => { + if (authData === null) { + throw new Error("need auth"); + } + if (authData.type === "m.login.dummy") { + throw new Error("dummy rejected"); + } + return undefined; + }); + }); + const crypto = createCryptoApi({ + bootstrapCrossSigning, + getDeviceVerificationStatus: vi.fn(async () => createVerifiedDeviceStatus()), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await expect( + bootstrapper.bootstrap(crypto, { + strict: true, + forceResetCrossSigning: true, + allowSecretStorageRecreateWithoutRecoveryKey: true, + }), + ).rejects.toThrow( + "Matrix cross-signing key upload requires UIA; provide matrix.password for m.login.password fallback", + ); + + expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled(); + }); + it("fails in strict mode when cross-signing keys are still unpublished", async () => { const deps = createBootstrapperDeps(); const crypto = createCryptoApi({ diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts index 4a1a03fa83b..d337b64dfe2 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts @@ -47,14 +47,18 @@ export class MatrixCryptoBootstrapper { options: MatrixCryptoBootstrapOptions = {}, ): Promise { const strict = options.strict === true; + const deferSecretStorageBootstrapUntilAfterCrossSigning = + options.forceResetCrossSigning === true; // Register verification listeners before expensive bootstrap work so incoming requests // are not missed during startup. this.registerVerificationRequestHandler(crypto); - await this.bootstrapSecretStorage(crypto, { - strict, - allowSecretStorageRecreateWithoutRecoveryKey: - options.allowSecretStorageRecreateWithoutRecoveryKey === true, - }); + if (!deferSecretStorageBootstrapUntilAfterCrossSigning) { + await this.bootstrapSecretStorage(crypto, { + strict, + allowSecretStorageRecreateWithoutRecoveryKey: + options.allowSecretStorageRecreateWithoutRecoveryKey === true, + }); + } const crossSigning = await this.bootstrapCrossSigning(crypto, { forceResetCrossSigning: options.forceResetCrossSigning === true, allowAutomaticCrossSigningReset: options.allowAutomaticCrossSigningReset !== false, @@ -62,6 +66,9 @@ export class MatrixCryptoBootstrapper { options.allowSecretStorageRecreateWithoutRecoveryKey === true, strict, }); + // Forced repair may need password UIA to upload new cross-signing keys. Delay any + // secret-storage repair/recreation until after that step succeeds so passwordless bots do + // not partially mutate SSSS on homeservers that require password-based UIA. await this.bootstrapSecretStorage(crypto, { strict, allowSecretStorageRecreateWithoutRecoveryKey: