diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index 137e4bd72d7..101ae091ecf 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -16,6 +16,7 @@ import { restoreMatrixRoomKeyBackup, verifyMatrixRecoveryKey, } from "./matrix/actions/verification.js"; +import { resolveMatrixRoomKeyBackupIssue } from "./matrix/backup-health.js"; import { resolveMatrixAuthContext } from "./matrix/client.js"; import { setMatrixSdkConsoleLogging, setMatrixSdkLogMode } from "./matrix/client/logging.js"; import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js"; @@ -400,22 +401,6 @@ function resolveBackupStatus(status: { }; } -type MatrixCliBackupIssueCode = - | "missing-server-backup" - | "key-load-failed" - | "key-not-loaded" - | "key-mismatch" - | "untrusted-signature" - | "inactive" - | "indeterminate" - | "ok"; - -type MatrixCliBackupIssue = { - code: MatrixCliBackupIssueCode; - summary: string; - message: string | null; -}; - function yesNoUnknown(value: boolean | null): string { if (value === true) { return "yes"; @@ -474,77 +459,8 @@ function printVerificationGuidance(status: MatrixCliVerificationStatus, accountI printGuidance(buildVerificationGuidance(status, accountId)); } -function resolveBackupIssue(backup: MatrixCliBackupStatus): MatrixCliBackupIssue { - if (!backup.serverVersion) { - return { - code: "missing-server-backup", - summary: "missing on server", - message: "no room-key backup exists on the homeserver", - }; - } - if (backup.decryptionKeyCached === false) { - if (backup.keyLoadError) { - return { - code: "key-load-failed", - summary: "present but backup key unavailable on this device", - message: `backup decryption key could not be loaded from secret storage (${backup.keyLoadError})`, - }; - } - if (backup.keyLoadAttempted) { - return { - code: "key-not-loaded", - summary: "present but backup key unavailable on this device", - message: - "backup decryption key is not loaded on this device (secret storage did not return a key)", - }; - } - return { - code: "key-not-loaded", - summary: "present but backup key unavailable on this device", - message: "backup decryption key is not loaded on this device", - }; - } - if (backup.matchesDecryptionKey === false) { - return { - code: "key-mismatch", - summary: "present but backup key mismatch on this device", - message: "backup key mismatch (this device does not have the matching backup decryption key)", - }; - } - if (backup.trusted === false) { - return { - code: "untrusted-signature", - summary: "present but not trusted on this device", - message: "backup signature chain is not trusted by this device", - }; - } - if (!backup.activeVersion) { - return { - code: "inactive", - summary: "present on server but inactive on this device", - message: "backup exists but is not active on this device", - }; - } - if ( - backup.trusted === null || - backup.matchesDecryptionKey === null || - backup.decryptionKeyCached === null - ) { - return { - code: "indeterminate", - summary: "present but trust state unknown", - message: "backup trust state could not be fully determined", - }; - } - return { - code: "ok", - summary: "active and trusted on this device", - message: null, - }; -} - function printBackupSummary(backup: MatrixCliBackupStatus): void { - const issue = resolveBackupIssue(backup); + const issue = resolveMatrixRoomKeyBackupIssue(backup); console.log(`Backup: ${issue.summary}`); if (backup.serverVersion) { console.log(`Backup version: ${backup.serverVersion}`); @@ -556,7 +472,7 @@ function buildVerificationGuidance( accountId?: string, ): string[] { const backup = resolveBackupStatus(status); - const backupIssue = resolveBackupIssue(backup); + const backupIssue = resolveMatrixRoomKeyBackupIssue(backup); const nextSteps = new Set(); if (!status.verified) { nextSteps.add( @@ -590,7 +506,7 @@ function buildVerificationGuidance( ); } else if (backupIssue.code === "untrusted-signature") { nextSteps.add( - `Backup trust chain is not verified on this device. Re-run '${formatMatrixCliCommand("verify device ", accountId)}'.`, + `Backup trust chain is not verified on this device. Re-run '${formatMatrixCliCommand("verify device ", accountId)}' if you have the correct recovery key.`, ); nextSteps.add( `If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'.`, @@ -623,7 +539,7 @@ function printVerificationStatus( ): void { console.log(`Verified by owner: ${status.verified ? "yes" : "no"}`); const backup = resolveBackupStatus(status); - const backupIssue = resolveBackupIssue(backup); + const backupIssue = resolveMatrixRoomKeyBackupIssue(backup); printVerificationBackupSummary(status); if (backupIssue.message) { console.log(`Backup issue: ${backupIssue.message}`); diff --git a/extensions/matrix/src/matrix/backup-health.ts b/extensions/matrix/src/matrix/backup-health.ts new file mode 100644 index 00000000000..041de1f75c0 --- /dev/null +++ b/extensions/matrix/src/matrix/backup-health.ts @@ -0,0 +1,115 @@ +export type MatrixRoomKeyBackupStatusLike = { + serverVersion: string | null; + activeVersion: string | null; + trusted: boolean | null; + matchesDecryptionKey: boolean | null; + decryptionKeyCached: boolean | null; + keyLoadAttempted: boolean; + keyLoadError: string | null; +}; + +export type MatrixRoomKeyBackupIssueCode = + | "missing-server-backup" + | "key-load-failed" + | "key-not-loaded" + | "key-mismatch" + | "untrusted-signature" + | "inactive" + | "indeterminate" + | "ok"; + +export type MatrixRoomKeyBackupIssue = { + code: MatrixRoomKeyBackupIssueCode; + summary: string; + message: string | null; +}; + +export function resolveMatrixRoomKeyBackupIssue( + backup: MatrixRoomKeyBackupStatusLike, +): MatrixRoomKeyBackupIssue { + if (!backup.serverVersion) { + return { + code: "missing-server-backup", + summary: "missing on server", + message: "no room-key backup exists on the homeserver", + }; + } + if (backup.decryptionKeyCached === false) { + if (backup.keyLoadError) { + return { + code: "key-load-failed", + summary: "present but backup key unavailable on this device", + message: `backup decryption key could not be loaded from secret storage (${backup.keyLoadError})`, + }; + } + if (backup.keyLoadAttempted) { + return { + code: "key-not-loaded", + summary: "present but backup key unavailable on this device", + message: + "backup decryption key is not loaded on this device (secret storage did not return a key)", + }; + } + return { + code: "key-not-loaded", + summary: "present but backup key unavailable on this device", + message: "backup decryption key is not loaded on this device", + }; + } + if (backup.matchesDecryptionKey === false) { + return { + code: "key-mismatch", + summary: "present but backup key mismatch on this device", + message: "backup key mismatch (this device does not have the matching backup decryption key)", + }; + } + if (backup.trusted === false) { + return { + code: "untrusted-signature", + summary: "present but not trusted on this device", + message: "backup signature chain is not trusted by this device", + }; + } + if (!backup.activeVersion) { + return { + code: "inactive", + summary: "present on server but inactive on this device", + message: "backup exists but is not active on this device", + }; + } + if ( + backup.trusted === null || + backup.matchesDecryptionKey === null || + backup.decryptionKeyCached === null + ) { + return { + code: "indeterminate", + summary: "present but trust state unknown", + message: "backup trust state could not be fully determined", + }; + } + return { + code: "ok", + summary: "active and trusted on this device", + message: null, + }; +} + +export function resolveMatrixRoomKeyBackupReadinessError( + backup: MatrixRoomKeyBackupStatusLike, + opts: { + requireServerBackup: boolean; + }, +): string | null { + const issue = resolveMatrixRoomKeyBackupIssue(backup); + if (issue.code === "missing-server-backup") { + return opts.requireServerBackup ? "Matrix room key backup is missing on the homeserver." : null; + } + if (issue.code === "ok") { + return null; + } + if (issue.message) { + return `Matrix room key backup is not usable: ${issue.message}.`; + } + return "Matrix room key backup is not usable on this device."; +} diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 44798c46c7a..d76dcbdb2c3 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -1118,6 +1118,116 @@ describe("MatrixClient crypto bootstrapping", () => { expect(result.error).toContain("not verified by its owner"); }); + it("fails recovery-key verification when backup remains untrusted after device verification", 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: true, + signedByOwner: true, + })), + 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: false, + matchesDecryptionKey: true, + })), + })); + + const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-untrusted-")); + const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json"); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + recoveryKeyPath, + }); + + const result = await client.verifyWithRecoveryKey(encoded as string); + expect(result.success).toBe(false); + expect(result.verified).toBe(true); + expect(result.error).toContain("backup signature chain is not trusted"); + expect(result.recoveryKeyStored).toBe(false); + expect(fs.existsSync(recoveryKeyPath)).toBe(false); + }); + + it("does not overwrite the stored recovery key when recovery-key verification fails", async () => { + const previousEncoded = encodeRecoveryKey( + new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)), + ); + const attemptedEncoded = encodeRecoveryKey( + new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 55)), + ); + + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => { + throw new Error("secret storage rejected recovery key"); + }), + 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: () => false, + localVerified: false, + crossSigningVerified: false, + signedByOwner: false, + })), + })); + + const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-preserve-")); + const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json"); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: new Date().toISOString(), + keyId: "SSSSKEY", + encodedPrivateKey: previousEncoded, + privateKeyBase64: Buffer.from( + new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)), + ).toString("base64"), + }), + "utf8", + ); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + recoveryKeyPath, + }); + + const result = await client.verifyWithRecoveryKey(attemptedEncoded as string); + + expect(result.success).toBe(false); + expect(result.error).toContain("not verified by its owner"); + const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as { + encodedPrivateKey?: string; + }; + expect(persisted.encodedPrivateKey).toBe(previousEncoded); + }); + it("reports detailed room-key backup health", async () => { matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); @@ -1287,13 +1397,16 @@ describe("MatrixClient crypto bootstrapping", () => { const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {}); const checkKeyBackupAndEnable = vi.fn(async () => {}); const restoreKeyBackup = vi.fn(async () => ({ imported: 4, total: 10 })); - matrixJsClient.getCrypto = vi.fn(() => ({ + const crypto = { on: vi.fn(), getActiveSessionBackupVersion, loadSessionBackupPrivateKeyFromSecretStorage, checkKeyBackupAndEnable, restoreKeyBackup, - getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getSessionBackupPrivateKey: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValue(new Uint8Array([1])), getKeyBackupInfo: vi.fn(async () => ({ algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", auth_data: {}, @@ -1303,11 +1416,13 @@ describe("MatrixClient crypto bootstrapping", () => { trusted: true, matchesDecryptionKey: true, })), - })); + }; + matrixJsClient.getCrypto = vi.fn(() => crypto); const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { encryption: true, }); + vi.spyOn(client, "doRequest").mockResolvedValue({ version: "9" }); const result = await client.restoreRoomKeyBackup(); expect(result.success).toBe(true); @@ -1330,10 +1445,13 @@ describe("MatrixClient crypto bootstrapping", () => { const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {}); const checkKeyBackupAndEnable = vi.fn(async () => {}); const restoreKeyBackup = vi.fn(async () => ({ imported: 0, total: 0 })); - matrixJsClient.getCrypto = vi.fn(() => ({ + const crypto = { on: vi.fn(), getActiveSessionBackupVersion, - getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getSessionBackupPrivateKey: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValue(new Uint8Array([1])), loadSessionBackupPrivateKeyFromSecretStorage, checkKeyBackupAndEnable, restoreKeyBackup, @@ -1346,11 +1464,13 @@ describe("MatrixClient crypto bootstrapping", () => { trusted: true, matchesDecryptionKey: true, })), - })); + }; + matrixJsClient.getCrypto = vi.fn(() => crypto); const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { encryption: true, }); + vi.spyOn(client, "doRequest").mockResolvedValue({ version: "5256" }); const result = await client.restoreRoomKeyBackup(); expect(result.success).toBe(true); @@ -1379,14 +1499,62 @@ describe("MatrixClient crypto bootstrapping", () => { const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { encryption: true, }); + vi.spyOn(client, "doRequest").mockResolvedValue({ version: "3" }); const result = await client.restoreRoomKeyBackup(); expect(result.success).toBe(false); - expect(result.error).toContain("cannot load backup keys from secret storage"); + expect(result.error).toContain("backup decryption key could not be loaded from secret storage"); expect(result.backupVersion).toBe("3"); expect(result.backup.matchesDecryptionKey).toBe(false); }); + it("reloads the matching backup key before restore when the cached key mismatches", async () => { + const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {}); + const restoreKeyBackup = vi.fn(async () => ({ imported: 6, total: 9 })); + const isKeyBackupTrusted = vi + .fn() + .mockResolvedValueOnce({ + trusted: true, + matchesDecryptionKey: false, + }) + .mockResolvedValueOnce({ + trusted: true, + matchesDecryptionKey: true, + }) + .mockResolvedValueOnce({ + trusted: true, + matchesDecryptionKey: true, + }); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + getActiveSessionBackupVersion: vi.fn(async () => "49262"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + loadSessionBackupPrivateKeyFromSecretStorage, + checkKeyBackupAndEnable: vi.fn(async () => {}), + restoreKeyBackup, + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "49262", + })), + isKeyBackupTrusted, + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + + const result = await client.restoreRoomKeyBackup(); + + expect(result.success).toBe(true); + expect(result.backupVersion).toBe("49262"); + expect(result.imported).toBe(6); + expect(result.total).toBe(9); + expect(result.loadedFromSecretStorage).toBe(true); + expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1); + expect(restoreKeyBackup).toHaveBeenCalledTimes(1); + }); + it("resets the current room-key backup and creates a fresh trusted version", async () => { const checkKeyBackupAndEnable = vi.fn(async () => {}); const bootstrapSecretStorage = vi.fn(async () => {}); @@ -1575,6 +1743,17 @@ describe("MatrixClient crypto bootstrapping", () => { crossSigningVerified: true, signedByOwner: true, })), + getActiveSessionBackupVersion: vi.fn(async () => "9"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "9", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), })); const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { @@ -1587,6 +1766,7 @@ describe("MatrixClient crypto bootstrapping", () => { userSigningKeyPublished: true, published: true, }); + vi.spyOn(client, "doRequest").mockResolvedValue({ version: "9" }); const result = await client.bootstrapOwnDeviceVerification(); expect(result.success).toBe(true); @@ -1648,6 +1828,17 @@ describe("MatrixClient crypto bootstrapping", () => { crossSigningVerified: true, signedByOwner: true, })), + getActiveSessionBackupVersion: vi.fn(async () => "7"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "7", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), })); const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { @@ -1695,6 +1886,17 @@ describe("MatrixClient crypto bootstrapping", () => { crossSigningVerified: true, signedByOwner: true, })), + getActiveSessionBackupVersion: vi.fn(async () => "9"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "9", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), })); const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { @@ -1727,6 +1929,7 @@ describe("MatrixClient crypto bootstrapping", () => { }); it("does not report bootstrap errors when final verification state is healthy", async () => { + const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 90))); matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); matrixJsClient.getCrypto = vi.fn(() => ({ @@ -1747,6 +1950,17 @@ describe("MatrixClient crypto bootstrapping", () => { crossSigningVerified: true, signedByOwner: true, })), + getActiveSessionBackupVersion: vi.fn(async () => "12"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "12", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), })); const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { @@ -1759,9 +1973,10 @@ describe("MatrixClient crypto bootstrapping", () => { userSigningKeyPublished: true, published: true, }); + vi.spyOn(client, "doRequest").mockResolvedValue({ version: "12" }); const result = await client.bootstrapOwnDeviceVerification({ - recoveryKey: "not-a-valid-recovery-key", + recoveryKey: encoded as string, }); expect(result.success).toBe(true); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index 3874ca0d336..0152e7999e5 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -9,6 +9,7 @@ import { type MatrixEvent, } from "matrix-js-sdk"; import { VerificationMethod } from "matrix-js-sdk/lib/types.js"; +import { resolveMatrixRoomKeyBackupReadinessError } from "./backup-health.js"; import { createMatrixJsSdkClientLogger } from "./client/logging.js"; import { MatrixCryptoBootstrapper } from "./sdk/crypto-bootstrap.js"; import type { MatrixCryptoBootstrapResult } from "./sdk/crypto-bootstrap.js"; @@ -706,28 +707,36 @@ export class MatrixClient { let { activeVersion, decryptionKeyCached } = await this.resolveRoomKeyBackupLocalState(crypto); let { serverVersion, trusted, matchesDecryptionKey } = await this.resolveRoomKeyBackupTrustState(crypto, serverVersionFallback); + const shouldLoadBackupKey = + Boolean(serverVersion) && (decryptionKeyCached === false || matchesDecryptionKey === false); + const shouldActivateBackup = Boolean(serverVersion) && !activeVersion; let keyLoadAttempted = false; let keyLoadError: string | null = null; - if (serverVersion && (decryptionKeyCached === false || matchesDecryptionKey === false)) { - if ( - typeof crypto.loadSessionBackupPrivateKeyFromSecretStorage === - "function" /* pragma: allowlist secret */ - ) { - keyLoadAttempted = true; - try { - await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); // pragma: allowlist secret - await this.enableTrustedRoomKeyBackupIfPossible(crypto); - } catch (err) { - keyLoadError = err instanceof Error ? err.message : String(err); + if (serverVersion && (shouldLoadBackupKey || shouldActivateBackup)) { + if (shouldLoadBackupKey) { + if ( + typeof crypto.loadSessionBackupPrivateKeyFromSecretStorage === + "function" /* pragma: allowlist secret */ + ) { + keyLoadAttempted = true; + try { + await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); // pragma: allowlist secret + } catch (err) { + keyLoadError = err instanceof Error ? err.message : String(err); + } + } else { + keyLoadError = + "Matrix crypto backend does not support loading backup keys from secret storage"; } - ({ activeVersion, decryptionKeyCached } = - await this.resolveRoomKeyBackupLocalState(crypto)); - ({ serverVersion, trusted, matchesDecryptionKey } = - await this.resolveRoomKeyBackupTrustState(crypto, serverVersion)); - } else { - keyLoadError = - "Matrix crypto backend does not support loading backup keys from secret storage"; } + if (!keyLoadError) { + await this.enableTrustedRoomKeyBackupIfPossible(crypto); + } + ({ activeVersion, decryptionKeyCached } = await this.resolveRoomKeyBackupLocalState(crypto)); + ({ serverVersion, trusted, matchesDecryptionKey } = await this.resolveRoomKeyBackupTrustState( + crypto, + serverVersion, + )); } return { @@ -810,41 +819,55 @@ export class MatrixClient { return await fail("Matrix recovery key is required"); } - let defaultKeyId: string | null | undefined = undefined; - const getSecretStorageStatus = crypto.getSecretStorageStatus; // pragma: allowlist secret - if (typeof getSecretStorageStatus === "function") { - const status = await getSecretStorageStatus.call(crypto).catch(() => null); // pragma: allowlist secret - defaultKeyId = status?.defaultKeyId; - } - try { - this.recoveryKeyStore.storeEncodedRecoveryKey({ + this.recoveryKeyStore.stageEncodedRecoveryKey({ encodedPrivateKey: trimmedRecoveryKey, - keyId: defaultKeyId, + keyId: await this.resolveDefaultSecretStorageKeyId(crypto), }); } catch (err) { return await fail(err instanceof Error ? err.message : String(err)); } - await this.cryptoBootstrapper.bootstrap(crypto, { - allowAutomaticCrossSigningReset: false, - }); - await this.enableTrustedRoomKeyBackupIfPossible(crypto); - const status = await this.getOwnDeviceVerificationStatus(); - if (!status.verified) { - 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, - }; - } + try { + await this.cryptoBootstrapper.bootstrap(crypto, { + allowAutomaticCrossSigningReset: false, + }); + 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, + }); + if (backupError) { + this.recoveryKeyStore.discardStagedRecoveryKey(); + return { + success: false, + error: backupError, + ...status, + }; + } - return { - success: true, - verifiedAt: new Date().toISOString(), - ...status, - }; + this.recoveryKeyStore.commitStagedRecoveryKey({ + keyId: await this.resolveDefaultSecretStorageKeyId(crypto), + }); + const committedStatus = await this.getOwnDeviceVerificationStatus(); + return { + success: true, + verifiedAt: new Date().toISOString(), + ...committedStatus, + }; + } catch (err) { + this.recoveryKeyStore.discardStagedRecoveryKey(); + return await fail(err instanceof Error ? err.message : String(err)); + } } async restoreRoomKeyBackup( @@ -879,54 +902,44 @@ export class MatrixClient { try { const rawRecoveryKey = params.recoveryKey?.trim(); if (rawRecoveryKey) { - let defaultKeyId: string | null | undefined = undefined; - const getSecretStorageStatus = crypto.getSecretStorageStatus; // pragma: allowlist secret - if (typeof getSecretStorageStatus === "function") { - const status = await getSecretStorageStatus.call(crypto).catch(() => null); // pragma: allowlist secret - defaultKeyId = status?.defaultKeyId; - } - this.recoveryKeyStore.storeEncodedRecoveryKey({ + this.recoveryKeyStore.stageEncodedRecoveryKey({ encodedPrivateKey: rawRecoveryKey, - keyId: defaultKeyId, + keyId: await this.resolveDefaultSecretStorageKeyId(crypto), }); } - let activeVersion = await this.resolveActiveRoomKeyBackupVersion(crypto); - if (!activeVersion) { - if ( - typeof crypto.loadSessionBackupPrivateKeyFromSecretStorage !== - "function" /* pragma: allowlist secret */ - ) { - return await fail( - "Matrix crypto backend cannot load backup keys from secret storage. Verify this device with 'openclaw matrix verify device ' first.", - ); - } - await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); // pragma: allowlist secret - loadedFromSecretStorage = true; - await this.enableTrustedRoomKeyBackupIfPossible(crypto); - activeVersion = await this.resolveActiveRoomKeyBackupVersion(crypto); - } - if (!activeVersion) { - return await fail( - "Matrix key backup is not active on this device after loading from secret storage.", - ); + const backup = await this.getRoomKeyBackupStatus(); + loadedFromSecretStorage = backup.keyLoadAttempted && !backup.keyLoadError; + const backupError = resolveMatrixRoomKeyBackupReadinessError(backup, { + requireServerBackup: true, + }); + if (backupError) { + this.recoveryKeyStore.discardStagedRecoveryKey(); + return await fail(backupError); } if (typeof crypto.restoreKeyBackup !== "function") { + this.recoveryKeyStore.discardStagedRecoveryKey(); return await fail("Matrix crypto backend does not support full key backup restore"); } const restore = await crypto.restoreKeyBackup(); - const backup = await this.getRoomKeyBackupStatus(); + if (rawRecoveryKey) { + this.recoveryKeyStore.commitStagedRecoveryKey({ + keyId: await this.resolveDefaultSecretStorageKeyId(crypto), + }); + } + const finalBackup = await this.getRoomKeyBackupStatus(); return { success: true, - backupVersion: activeVersion, + backupVersion: backup.serverVersion, imported: typeof restore.imported === "number" ? restore.imported : 0, total: typeof restore.total === "number" ? restore.total : 0, loadedFromSecretStorage, restoredAt: new Date().toISOString(), - backup, + backup: finalBackup, }; } catch (err) { + this.recoveryKeyStore.discardStagedRecoveryKey(); return await fail(err instanceof Error ? err.message : String(err)); } } @@ -1086,15 +1099,9 @@ export class MatrixClient { const rawRecoveryKey = params?.recoveryKey?.trim(); if (rawRecoveryKey) { - let defaultKeyId: string | null | undefined = undefined; - const getSecretStorageStatus = crypto.getSecretStorageStatus; // pragma: allowlist secret - if (typeof getSecretStorageStatus === "function") { - const status = await getSecretStorageStatus.call(crypto).catch(() => null); // pragma: allowlist secret - defaultKeyId = status?.defaultKeyId; - } - this.recoveryKeyStore.storeEncodedRecoveryKey({ + this.recoveryKeyStore.stageEncodedRecoveryKey({ encodedPrivateKey: rawRecoveryKey, - keyId: defaultKeyId, + keyId: await this.resolveDefaultSecretStorageKeyId(crypto), }); } @@ -1105,20 +1112,38 @@ export class MatrixClient { }); await this.ensureRoomKeyBackupEnabled(crypto); } catch (err) { + this.recoveryKeyStore.discardStagedRecoveryKey(); bootstrapError = err instanceof Error ? err.message : String(err); } const verification = await this.getOwnDeviceVerificationStatus(); const crossSigning = await this.getOwnCrossSigningPublicationStatus(); - const success = verification.verified && crossSigning.published; - const error = success - ? undefined - : (bootstrapError ?? - "Matrix verification bootstrap did not produce a device verified by its owner with published cross-signing keys"); + const verificationError = + verification.verified && crossSigning.published + ? null + : (bootstrapError ?? + "Matrix verification bootstrap did not produce a device verified by its owner with published cross-signing keys"); + const backupError = + verificationError === null + ? resolveMatrixRoomKeyBackupReadinessError(verification.backup, { + requireServerBackup: true, + }) + : null; + const success = verificationError === null && backupError === null; + if (success) { + this.recoveryKeyStore.commitStagedRecoveryKey({ + keyId: await this.resolveDefaultSecretStorageKeyId( + this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined, + ), + }); + } else { + this.recoveryKeyStore.discardStagedRecoveryKey(); + } + const error = success ? undefined : (backupError ?? verificationError ?? undefined); return { success, error, - verification, + verification: success ? await this.getOwnDeviceVerificationStatus() : verification, crossSigning, pendingVerifications: await pendingVerifications(), cryptoBootstrap: bootstrapSummary, @@ -1244,6 +1269,17 @@ export class MatrixClient { return { serverVersion, trusted, matchesDecryptionKey }; } + private async resolveDefaultSecretStorageKeyId( + crypto: MatrixCryptoBootstrapApi | undefined, + ): Promise { + const getSecretStorageStatus = crypto?.getSecretStorageStatus; // pragma: allowlist secret + if (typeof getSecretStorageStatus !== "function") { + return undefined; + } + const status = await getSecretStorageStatus.call(crypto).catch(() => null); // pragma: allowlist secret + return status?.defaultKeyId; + } + private async resolveRoomKeyBackupVersion(): Promise { try { const response = (await this.doRequest("GET", "/_matrix/client/v3/room_keys/version")) as { 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 8808a66aa8e..79d41b0e36b 100644 --- a/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts @@ -299,4 +299,85 @@ describe("MatrixRecoveryKeyStore", () => { ), ).toBe(true); }); + + it("stages a recovery key for secret storage without persisting it until commit", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + fs.rmSync(recoveryKeyPath, { force: true }); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const encoded = encodeRecoveryKey( + new Uint8Array(Array.from({ length: 32 }, (_, i) => (i + 11) % 255)), + ); + expect(encoded).toBeTypeOf("string"); + + store.stageEncodedRecoveryKey({ + encodedPrivateKey: encoded as string, + keyId: "SSSSKEY", + }); + + expect(fs.existsSync(recoveryKeyPath)).toBe(false); + const callbacks = store.buildCryptoCallbacks(); + const resolved = await callbacks.getSecretStorageKey?.( + { keys: { SSSSKEY: { name: "test" } } }, + "m.cross_signing.master", + ); + expect(resolved?.[0]).toBe("SSSSKEY"); + + store.commitStagedRecoveryKey({ keyId: "SSSSKEY" }); + + const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as { + keyId?: string; + encodedPrivateKey?: string; + }; + expect(persisted.keyId).toBe("SSSSKEY"); + expect(persisted.encodedPrivateKey).toBe(encoded); + }); + + it("does not overwrite the stored recovery key while a staged key is only being validated", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const storedEncoded = encodeRecoveryKey( + new Uint8Array(Array.from({ length: 32 }, (_, i) => (i + 1) % 255)), + ); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: "2026-03-12T00:00:00.000Z", + keyId: "OLD", + encodedPrivateKey: storedEncoded, + privateKeyBase64: Buffer.from( + new Uint8Array(Array.from({ length: 32 }, (_, i) => (i + 1) % 255)), + ).toString("base64"), + }), + "utf8", + ); + + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const stagedEncoded = encodeRecoveryKey( + new Uint8Array(Array.from({ length: 32 }, (_, i) => (i + 101) % 255)), + ); + store.stageEncodedRecoveryKey({ + encodedPrivateKey: stagedEncoded as string, + keyId: "NEW", + }); + + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + createRecoveryKeyFromPassphrase: vi.fn(async () => { + throw new Error("should not be called"); + }), + getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "NEW" })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto); + + const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as { + keyId?: string; + encodedPrivateKey?: string; + }; + expect(persisted.keyId).toBe("OLD"); + expect(persisted.encodedPrivateKey).toBe(storedEncoded); + }); }); diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts index 7e88df923c7..f12a4a0ae29 100644 --- a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts @@ -32,6 +32,8 @@ export class MatrixRecoveryKeyStore { string, { key: Uint8Array; keyInfo?: MatrixStoredRecoveryKey["keyInfo"] } >(); + private stagedRecoveryKey: MatrixStoredRecoveryKey | null = null; + private readonly stagedCacheKeyIds = new Set(); constructor(private readonly recoveryKeyPath?: string) {} @@ -50,8 +52,24 @@ export class MatrixRecoveryKeyStore { } } + const staged = this.stagedRecoveryKey; + if (staged?.privateKeyBase64) { + const privateKey = new Uint8Array(Buffer.from(staged.privateKeyBase64, "base64")); + if (privateKey.length > 0) { + const stagedKeyId = + staged.keyId && requestedKeyIds.includes(staged.keyId) + ? staged.keyId + : requestedKeyIds[0]; + if (stagedKeyId) { + this.rememberSecretStorageKey(stagedKeyId, privateKey, staged.keyInfo); + this.stagedCacheKeyIds.add(stagedKeyId); + return [stagedKeyId, privateKey]; + } + } + } + const stored = this.loadStoredRecoveryKey(); - if (!stored || !stored.privateKeyBase64) { + if (!stored?.privateKeyBase64) { return null; } const privateKey = new Uint8Array(Buffer.from(stored.privateKeyBase64, "base64")); @@ -143,6 +161,69 @@ export class MatrixRecoveryKeyStore { return this.getRecoveryKeySummary() ?? {}; } + stageEncodedRecoveryKey(params: { + encodedPrivateKey: string; + keyId?: string | null; + keyInfo?: MatrixStoredRecoveryKey["keyInfo"]; + }): void { + const encodedPrivateKey = params.encodedPrivateKey.trim(); + if (!encodedPrivateKey) { + throw new Error("Matrix recovery key is required"); + } + let privateKey: Uint8Array; + try { + privateKey = decodeRecoveryKey(encodedPrivateKey); + } catch (err) { + throw new Error( + `Invalid Matrix recovery key: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const normalizedKeyId = + typeof params.keyId === "string" && params.keyId.trim() ? params.keyId.trim() : null; + this.discardStagedRecoveryKey(); + this.stagedRecoveryKey = { + version: 1, + createdAt: new Date().toISOString(), + keyId: normalizedKeyId, + encodedPrivateKey, + privateKeyBase64: Buffer.from(privateKey).toString("base64"), + keyInfo: params.keyInfo ?? this.loadStoredRecoveryKey()?.keyInfo, + }; + } + + commitStagedRecoveryKey(params?: { + keyId?: string | null; + keyInfo?: MatrixStoredRecoveryKey["keyInfo"]; + }): { + encodedPrivateKey?: string; + keyId?: string | null; + createdAt?: string; + } | null { + if (!this.stagedRecoveryKey) { + return this.getRecoveryKeySummary(); + } + const staged = this.stagedRecoveryKey; + const privateKey = new Uint8Array(Buffer.from(staged.privateKeyBase64, "base64")); + const keyId = + typeof params?.keyId === "string" && params.keyId.trim() ? params.keyId.trim() : staged.keyId; + this.saveRecoveryKeyToDisk({ + keyId, + keyInfo: params?.keyInfo ?? staged.keyInfo, + privateKey, + encodedPrivateKey: staged.encodedPrivateKey, + }); + this.clearStagedRecoveryKeyTracking(); + return this.getRecoveryKeySummary(); + } + + discardStagedRecoveryKey(): void { + for (const keyId of this.stagedCacheKeyIds) { + this.secretStorageKeyCache.delete(keyId); + } + this.clearStagedRecoveryKeyTracking(); + } + async bootstrapSecretStorageWithRecoveryKey( crypto: MatrixCryptoBootstrapApi, options: { @@ -167,18 +248,20 @@ export class MatrixRecoveryKeyStore { ); let generatedRecoveryKey = false; const storedRecovery = this.loadStoredRecoveryKey(); - let recoveryKey: MatrixGeneratedSecretStorageKey | null = storedRecovery + const stagedRecovery = this.stagedRecoveryKey; + const sourceRecovery = stagedRecovery ?? storedRecovery; + let recoveryKey: MatrixGeneratedSecretStorageKey | null = sourceRecovery ? { - keyInfo: storedRecovery.keyInfo, - privateKey: new Uint8Array(Buffer.from(storedRecovery.privateKeyBase64, "base64")), - encodedPrivateKey: storedRecovery.encodedPrivateKey, + keyInfo: sourceRecovery.keyInfo, + privateKey: new Uint8Array(Buffer.from(sourceRecovery.privateKeyBase64, "base64")), + encodedPrivateKey: sourceRecovery.encodedPrivateKey, } : null; if (recoveryKey && status?.defaultKeyId) { const defaultKeyId = status.defaultKeyId; this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo); - if (storedRecovery?.keyId !== defaultKeyId) { + if (!stagedRecovery && storedRecovery && storedRecovery.keyId !== defaultKeyId) { this.saveRecoveryKeyToDisk({ keyId: defaultKeyId, keyInfo: recoveryKey.keyInfo, @@ -258,6 +341,11 @@ export class MatrixRecoveryKeyStore { } } + private clearStagedRecoveryKeyTracking(): void { + this.stagedRecoveryKey = null; + this.stagedCacheKeyIds.clear(); + } + private rememberSecretStorageKey( keyId: string, key: Uint8Array,