From 5598535aa9786747e55e9d35a6f066e01b897c5f Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 25 Feb 2026 15:34:41 -0500 Subject: [PATCH] matrix-js: enable key backup creation in verify bootstrap --- extensions/matrix-js/src/cli.ts | 9 +- extensions/matrix-js/src/matrix/sdk.test.ts | 94 +++++++++++++++++++ extensions/matrix-js/src/matrix/sdk.ts | 20 ++++ .../src/matrix/sdk/recovery-key-store.ts | 7 +- 4 files changed, 122 insertions(+), 8 deletions(-) diff --git a/extensions/matrix-js/src/cli.ts b/extensions/matrix-js/src/cli.ts index e2544f7ecd8..27d4ba9f864 100644 --- a/extensions/matrix-js/src/cli.ts +++ b/extensions/matrix-js/src/cli.ts @@ -54,15 +54,13 @@ function printVerificationStatus(status: { console.log("Verified: yes"); console.log(`User: ${status.userId ?? "unknown"}`); console.log(`Device: ${status.deviceId ?? "unknown"}`); - if (status.backupVersion) { - console.log(`Backup version: ${status.backupVersion}`); - } } else { console.log("Verified: no"); console.log(`User: ${status.userId ?? "unknown"}`); console.log(`Device: ${status.deviceId ?? "unknown"}`); console.log("Run 'openclaw matrix-js verify device ' to verify this device."); } + console.log(`Backup version: ${status.backupVersion ?? "none"}`); console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`); printTimestamp("Recovery key created at", status.recoveryKeyCreatedAt); console.log(`Pending verifications: ${status.pendingVerifications}`); @@ -143,6 +141,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { console.log( `Cross-signing published: ${result.crossSigning.published ? "yes" : "no"} (master=${result.crossSigning.masterKeyPublished ? "yes" : "no"}, self=${result.crossSigning.selfSigningKeyPublished ? "yes" : "no"}, user=${result.crossSigning.userSigningKeyPublished ? "yes" : "no"})`, ); + console.log(`Backup version: ${result.verification.backupVersion ?? "none"}`); printTimestamp("Recovery key created at", result.verification.recoveryKeyCreatedAt); console.log(`Pending verifications: ${result.pendingVerifications}`); if (!result.success) { @@ -179,9 +178,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { console.log("Device verification completed successfully."); console.log(`User: ${result.userId ?? "unknown"}`); console.log(`Device: ${result.deviceId ?? "unknown"}`); - if (result.backupVersion) { - console.log(`Backup version: ${result.backupVersion}`); - } + console.log(`Backup version: ${result.backupVersion ?? "none"}`); printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt); printTimestamp("Verified at", result.verifiedAt); } else { diff --git a/extensions/matrix-js/src/matrix/sdk.test.ts b/extensions/matrix-js/src/matrix/sdk.test.ts index 60f9c817a21..de2dbd5b9ce 100644 --- a/extensions/matrix-js/src/matrix/sdk.test.ts +++ b/extensions/matrix-js/src/matrix/sdk.test.ts @@ -948,6 +948,100 @@ describe("MatrixClient crypto bootstrapping", () => { expect(result.cryptoBootstrap).not.toBeNull(); }); + it("creates a key backup during bootstrap when none exists on the server", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + const bootstrapSecretStorage = vi.fn(async () => {}); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: true, + selfSigningKeyPublished: true, + userSigningKeyPublished: true, + published: true, + }); + let backupChecks = 0; + vi.spyOn(client, "doRequest").mockImplementation(async (_method, endpoint) => { + if (String(endpoint).includes("/room_keys/version")) { + backupChecks += 1; + return backupChecks >= 2 ? { version: "7" } : {}; + } + return {}; + }); + + const result = await client.bootstrapOwnDeviceVerification(); + + expect(result.success).toBe(true); + expect(result.verification.backupVersion).toBe("7"); + expect(bootstrapSecretStorage).toHaveBeenCalledWith( + expect.objectContaining({ setupNewKeyBackup: true }), + ); + }); + + it("does not recreate key backup during bootstrap when one already exists", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + const bootstrapSecretStorage = vi.fn(async () => {}); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: true, + selfSigningKeyPublished: true, + userSigningKeyPublished: true, + published: true, + }); + vi.spyOn(client, "doRequest").mockImplementation(async (_method, endpoint) => { + if (String(endpoint).includes("/room_keys/version")) { + return { version: "9" }; + } + return {}; + }); + + const result = await client.bootstrapOwnDeviceVerification(); + + expect(result.success).toBe(true); + expect(result.verification.backupVersion).toBe("9"); + expect( + bootstrapSecretStorage.mock.calls.some(([opts]) => + Boolean((opts as { setupNewKeyBackup?: boolean } | undefined)?.setupNewKeyBackup), + ), + ).toBe(false); + }); + it("does not report bootstrap errors when final verification state is healthy", async () => { matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); diff --git a/extensions/matrix-js/src/matrix/sdk.ts b/extensions/matrix-js/src/matrix/sdk.ts index abd635195e9..84edf5736dd 100644 --- a/extensions/matrix-js/src/matrix/sdk.ts +++ b/extensions/matrix-js/src/matrix/sdk.ts @@ -723,6 +723,7 @@ export class MatrixClient { forceResetCrossSigning: params?.forceResetCrossSigning === true, strict: true, }); + await this.ensureRoomKeyBackupEnabled(crypto); } catch (err) { bootstrapError = err instanceof Error ? err.message : String(err); } @@ -755,6 +756,25 @@ export class MatrixClient { } } + private async ensureRoomKeyBackupEnabled(crypto: MatrixCryptoBootstrapApi): Promise { + const existingVersion = await this.resolveRoomKeyBackupVersion(); + if (existingVersion) { + return; + } + LogService.info( + "MatrixClientLite", + "No room key backup version found on server, creating one via secret storage bootstrap", + ); + await this.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, { + setupNewKeyBackup: true, + }); + const createdVersion = await this.resolveRoomKeyBackupVersion(); + if (!createdVersion) { + throw new Error("Matrix room key backup is still missing after bootstrap"); + } + LogService.info("MatrixClientLite", `Room key backup enabled (version ${createdVersion})`); + } + private registerBridge(): void { if (this.bridgeRegistered) { return; diff --git a/extensions/matrix-js/src/matrix/sdk/recovery-key-store.ts b/extensions/matrix-js/src/matrix/sdk/recovery-key-store.ts index ab04600c5b5..e41dbbafd01 100644 --- a/extensions/matrix-js/src/matrix/sdk/recovery-key-store.ts +++ b/extensions/matrix-js/src/matrix/sdk/recovery-key-store.ts @@ -126,7 +126,10 @@ export class MatrixRecoveryKeyStore { return this.getRecoveryKeySummary() ?? {}; } - async bootstrapSecretStorageWithRecoveryKey(crypto: MatrixCryptoBootstrapApi): Promise { + async bootstrapSecretStorageWithRecoveryKey( + crypto: MatrixCryptoBootstrapApi, + options: { setupNewKeyBackup?: boolean } = {}, + ): Promise { let status: MatrixSecretStorageStatus | null = null; if (typeof crypto.getSecretStorageStatus === "function") { try { @@ -193,7 +196,7 @@ export class MatrixRecoveryKeyStore { setupNewSecretStorage?: boolean; setupNewKeyBackup?: boolean; } = { - setupNewKeyBackup: false, + setupNewKeyBackup: options.setupNewKeyBackup === true, }; if (shouldRecreateSecretStorage) {