diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index fd189a92b76..5fc474a6538 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const bootstrapMatrixVerificationMock = vi.fn(); const getMatrixRoomKeyBackupStatusMock = vi.fn(); const getMatrixVerificationStatusMock = vi.fn(); +const resolveMatrixAccountConfigMock = vi.fn(); +const resolveMatrixAccountMock = vi.fn(); const matrixSetupApplyAccountConfigMock = vi.fn(); const matrixSetupValidateInputMock = vi.fn(); const matrixRuntimeLoadConfigMock = vi.fn(); @@ -30,6 +32,11 @@ vi.mock("./matrix/actions/profile.js", () => ({ updateMatrixOwnProfile: (...args: unknown[]) => updateMatrixOwnProfileMock(...args), })); +vi.mock("./matrix/accounts.js", () => ({ + resolveMatrixAccount: (...args: unknown[]) => resolveMatrixAccountMock(...args), + resolveMatrixAccountConfig: (...args: unknown[]) => resolveMatrixAccountConfigMock(...args), +})); + vi.mock("./channel.js", () => ({ matrixPlugin: { setup: { @@ -72,6 +79,22 @@ describe("matrix CLI verification commands", () => { matrixSetupApplyAccountConfigMock.mockImplementation(({ cfg }: { cfg: unknown }) => cfg); matrixRuntimeLoadConfigMock.mockReturnValue({}); matrixRuntimeWriteConfigFileMock.mockResolvedValue(undefined); + resolveMatrixAccountMock.mockReturnValue({ + configured: false, + }); + resolveMatrixAccountConfigMock.mockReturnValue({ + encryption: false, + }); + bootstrapMatrixVerificationMock.mockResolvedValue({ + success: true, + verification: { + recoveryKeyCreatedAt: null, + backupVersion: null, + }, + crossSigning: {}, + pendingVerifications: 0, + cryptoBootstrap: {}, + }); updateMatrixOwnProfileMock.mockResolvedValue({ skipped: false, displayNameUpdated: true, @@ -206,6 +229,76 @@ describe("matrix CLI verification commands", () => { ); }); + it("bootstraps verification for newly added encrypted accounts", async () => { + resolveMatrixAccountConfigMock.mockReturnValue({ + encryption: true, + }); + bootstrapMatrixVerificationMock.mockResolvedValue({ + success: true, + verification: { + recoveryKeyCreatedAt: "2026-03-09T06:00:00.000Z", + backupVersion: "7", + }, + crossSigning: {}, + pendingVerifications: 0, + cryptoBootstrap: {}, + }); + const program = buildProgram(); + + await program.parseAsync( + [ + "matrix", + "account", + "add", + "--account", + "ops", + "--homeserver", + "https://matrix.example.org", + "--user-id", + "@ops:example.org", + "--password", + "secret", + ], + { from: "user" }, + ); + + expect(bootstrapMatrixVerificationMock).toHaveBeenCalledWith({ accountId: "ops" }); + expect(console.log).toHaveBeenCalledWith("Matrix verification bootstrap: complete"); + expect(console.log).toHaveBeenCalledWith( + `Recovery key created at: ${formatExpectedLocalTimestamp("2026-03-09T06:00:00.000Z")}`, + ); + expect(console.log).toHaveBeenCalledWith("Backup version: 7"); + }); + + it("does not bootstrap verification when updating an already configured account", async () => { + resolveMatrixAccountMock.mockReturnValue({ + configured: true, + }); + resolveMatrixAccountConfigMock.mockReturnValue({ + encryption: true, + }); + const program = buildProgram(); + + await program.parseAsync( + [ + "matrix", + "account", + "add", + "--account", + "ops", + "--homeserver", + "https://matrix.example.org", + "--user-id", + "@ops:example.org", + "--password", + "secret", + ], + { from: "user" }, + ); + + expect(bootstrapMatrixVerificationMock).not.toHaveBeenCalled(); + }); + it("uses --name as fallback account id and prints account-scoped config path", async () => { matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} }); const program = buildProgram(); diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index 325e7dc3d95..6a36082d8d5 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -5,6 +5,7 @@ import { type ChannelSetupInput, } from "openclaw/plugin-sdk/matrix"; import { matrixPlugin } from "./channel.js"; +import { resolveMatrixAccount, resolveMatrixAccountConfig } from "./matrix/accounts.js"; import { updateMatrixOwnProfile } from "./matrix/actions/profile.js"; import { bootstrapMatrixVerification, @@ -86,6 +87,13 @@ type MatrixCliAccountAddResult = { accountId: string; configPath: string; useEnv: boolean; + verificationBootstrap: { + attempted: boolean; + success: boolean; + recoveryKeyCreatedAt: string | null; + backupVersion: string | null; + error?: string; + }; profile: { attempted: boolean; displayNameUpdated: boolean; @@ -132,6 +140,7 @@ async function addMatrixAccount(params: { accountId: params.account, input, }) ?? normalizeAccountId(params.account?.trim() || params.name?.trim()); + const existingAccount = resolveMatrixAccount({ cfg, accountId }); const validationError = setup.validateInput?.({ cfg, @@ -148,6 +157,36 @@ async function addMatrixAccount(params: { input, }) as CoreConfig; await runtime.config.writeConfigFile(updated as never); + const accountConfig = resolveMatrixAccountConfig({ cfg: updated, accountId }); + + let verificationBootstrap: MatrixCliAccountAddResult["verificationBootstrap"] = { + attempted: false, + success: false, + recoveryKeyCreatedAt: null, + backupVersion: null, + }; + if (existingAccount.configured !== true && accountConfig.encryption === true) { + try { + const bootstrap = await bootstrapMatrixVerification({ accountId }); + verificationBootstrap = { + attempted: true, + success: bootstrap.success === true, + recoveryKeyCreatedAt: bootstrap.verification.recoveryKeyCreatedAt, + backupVersion: bootstrap.verification.backupVersion, + ...(bootstrap.success + ? {} + : { error: bootstrap.error ?? "Matrix verification bootstrap failed" }), + }; + } catch (err) { + verificationBootstrap = { + attempted: true, + success: false, + recoveryKeyCreatedAt: null, + backupVersion: null, + error: toErrorMessage(err), + }; + } + } const desiredDisplayName = input.name?.trim(); const desiredAvatarUrl = input.avatarUrl?.trim(); @@ -197,6 +236,7 @@ async function addMatrixAccount(params: { accountId, configPath: resolveMatrixConfigPath(updated, accountId), useEnv: input.useEnv === true, + verificationBootstrap, profile, }; } @@ -581,6 +621,22 @@ export function registerMatrixCli(params: { program: Command }): void { console.log( `Credentials source: ${result.useEnv ? "MATRIX_* / MATRIX__* env vars" : "inline config"}`, ); + if (result.verificationBootstrap.attempted) { + if (result.verificationBootstrap.success) { + console.log("Matrix verification bootstrap: complete"); + printTimestamp( + "Recovery key created at", + result.verificationBootstrap.recoveryKeyCreatedAt, + ); + if (result.verificationBootstrap.backupVersion) { + console.log(`Backup version: ${result.verificationBootstrap.backupVersion}`); + } + } else { + console.error( + `Matrix verification bootstrap warning: ${result.verificationBootstrap.error}`, + ); + } + } if (result.profile.attempted) { if (result.profile.error) { console.error(`Profile sync warning: ${result.profile.error}`); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index afdefb57c7e..78eb67f0a7d 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -946,6 +946,7 @@ export class MatrixClient { bootstrapSummary = await this.cryptoBootstrapper.bootstrap(crypto, { forceResetCrossSigning: params?.forceResetCrossSigning === true, + allowSecretStorageRecreateWithoutRecoveryKey: true, strict: true, }); await this.ensureRoomKeyBackupEnabled(crypto); diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts index 3299d52c856..fcf79616ffb 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts @@ -55,6 +55,9 @@ describe("MatrixCryptoBootstrapper", () => { ); expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith( crypto, + { + allowSecretStorageRecreateWithoutRecoveryKey: false, + }, ); expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledTimes(2); expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledWith(crypto); @@ -129,6 +132,33 @@ describe("MatrixCryptoBootstrapper", () => { ); }); + it("passes explicit secret-storage repair allowance only when requested", async () => { + const deps = createBootstrapperDeps(); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto, { + strict: true, + allowSecretStorageRecreateWithoutRecoveryKey: true, + }); + + expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith( + crypto, + { + allowSecretStorageRecreateWithoutRecoveryKey: true, + }, + ); + }); + 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 0829d1aa983..9c3d2655616 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts @@ -27,6 +27,7 @@ export type MatrixCryptoBootstrapperDeps = { export type MatrixCryptoBootstrapOptions = { forceResetCrossSigning?: boolean; allowAutomaticCrossSigningReset?: boolean; + allowSecretStorageRecreateWithoutRecoveryKey?: boolean; strict?: boolean; }; @@ -49,13 +50,21 @@ export class MatrixCryptoBootstrapper { // Register verification listeners before expensive bootstrap work so incoming requests // are not missed during startup. this.registerVerificationRequestHandler(crypto); - await this.bootstrapSecretStorage(crypto, strict); + await this.bootstrapSecretStorage(crypto, { + strict, + allowSecretStorageRecreateWithoutRecoveryKey: + options.allowSecretStorageRecreateWithoutRecoveryKey === true, + }); const crossSigning = await this.bootstrapCrossSigning(crypto, { forceResetCrossSigning: options.forceResetCrossSigning === true, allowAutomaticCrossSigningReset: options.allowAutomaticCrossSigningReset !== false, strict, }); - await this.bootstrapSecretStorage(crypto, strict); + await this.bootstrapSecretStorage(crypto, { + strict, + allowSecretStorageRecreateWithoutRecoveryKey: + options.allowSecretStorageRecreateWithoutRecoveryKey === true, + }); const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict); return { crossSigningReady: crossSigning.ready, @@ -219,14 +228,20 @@ export class MatrixCryptoBootstrapper { private async bootstrapSecretStorage( crypto: MatrixCryptoBootstrapApi, - strict = false, + options: { + strict: boolean; + allowSecretStorageRecreateWithoutRecoveryKey: boolean; + }, ): Promise { try { - await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto); + await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, { + allowSecretStorageRecreateWithoutRecoveryKey: + options.allowSecretStorageRecreateWithoutRecoveryKey, + }); LogService.info("MatrixClientLite", "Secret storage bootstrap complete"); } catch (err) { LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err); - if (strict) { + if (options.strict) { throw err instanceof Error ? err : new Error(String(err)); } } diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts index 060ad8d989d..d7c58d00f7d 100644 --- a/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts @@ -175,6 +175,58 @@ describe("MatrixRecoveryKeyStore", () => { }); }); + it("recreates secret storage during explicit bootstrap when the server key exists but no local recovery key is available", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const generated = { + keyId: "REPAIRED", + keyInfo: { name: "repaired" }, + privateKey: new Uint8Array([7, 7, 8, 9]), + encodedPrivateKey: "encoded-repaired-key", + }; + const createRecoveryKeyFromPassphrase = vi.fn(async () => generated); + const bootstrapSecretStorage = vi.fn( + async (opts?: { + setupNewSecretStorage?: boolean; + createSecretStorageKey?: () => Promise; + }) => { + if (opts?.setupNewSecretStorage) { + await opts.createSecretStorageKey?.(); + return; + } + throw new Error("getSecretStorageKey callback returned falsey"); + }, + ); + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + createRecoveryKeyFromPassphrase, + getSecretStorageStatus: vi.fn(async () => ({ + ready: true, + defaultKeyId: "LEGACY", + secretStorageKeyValidityMap: { LEGACY: true }, + })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto, { + allowSecretStorageRecreateWithoutRecoveryKey: true, + }); + + expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1); + expect(bootstrapSecretStorage).toHaveBeenCalledTimes(2); + expect(bootstrapSecretStorage).toHaveBeenLastCalledWith( + expect.objectContaining({ + setupNewSecretStorage: true, + }), + ); + expect(store.getRecoveryKeySummary()).toMatchObject({ + keyId: "REPAIRED", + encodedPrivateKey: "encoded-repaired-key", + }); + }); + it("stores an encoded recovery key and decodes its private key material", () => { const recoveryKeyPath = createTempRecoveryKeyPath(); const store = new MatrixRecoveryKeyStore(recoveryKeyPath); diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts index dab24e4075e..54dbdce4f55 100644 --- a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts @@ -128,7 +128,10 @@ export class MatrixRecoveryKeyStore { async bootstrapSecretStorageWithRecoveryKey( crypto: MatrixCryptoBootstrapApi, - options: { setupNewKeyBackup?: boolean } = {}, + options: { + setupNewKeyBackup?: boolean; + allowSecretStorageRecreateWithoutRecoveryKey?: boolean; + } = {}, ): Promise { let status: MatrixSecretStorageStatus | null = null; if (typeof crypto.getSecretStorageStatus === "function") { @@ -204,7 +207,29 @@ export class MatrixRecoveryKeyStore { secretStorageOptions.createSecretStorageKey = ensureRecoveryKey; } - await crypto.bootstrapSecretStorage(secretStorageOptions); + try { + await crypto.bootstrapSecretStorage(secretStorageOptions); + } catch (err) { + const shouldRecreateWithoutRecoveryKey = + options.allowSecretStorageRecreateWithoutRecoveryKey === true && + !recoveryKey && + hasDefaultSecretStorageKey && + err instanceof Error && + err.message.includes("getSecretStorageKey callback returned falsey"); + if (!shouldRecreateWithoutRecoveryKey) { + throw err; + } + + LogService.warn( + "MatrixClientLite", + "Secret storage exists on the server but no local recovery key is available; recreating secret storage and generating a new recovery key during explicit bootstrap.", + ); + await crypto.bootstrapSecretStorage({ + setupNewSecretStorage: true, + setupNewKeyBackup: options.setupNewKeyBackup === true, + createSecretStorageKey: ensureRecoveryKey, + }); + } if (generatedRecoveryKey && this.recoveryKeyPath) { LogService.warn(