diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index ae292c1a685..1fa20a85a65 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -1639,6 +1639,58 @@ describe("MatrixClient crypto bootstrapping", () => { expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1); }); + 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))); + + 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(consumeMatrixSecretStorageKey), + requestOwnUserVerification: vi.fn(async () => null), + getSecretStorageStatus: vi.fn(async () => ({ + ready: true, + defaultKeyId: "SSSSKEY", + secretStorageKeyValidityMap: {}, + })), + 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: true, + matchesDecryptionKey: true, + })), + })); + + const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-used-key-")); + const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json"); + const client = new MatrixClient("https://matrix.example.org", "token", { + encryption: true, + recoveryKeyPath, + }); + + const result = await client.verifyWithRecoveryKey(encoded as string); + + expect(result.success).toBe(true); + expect(result.recoveryKeyAccepted).toBe(true); + expect(result.backupUsable).toBe(true); + expect(result.deviceOwnerVerified).toBe(true); + expect(result.recoveryKeyStored).toBe(true); + expect(fs.existsSync(recoveryKeyPath)).toBe(true); + }); + it("fails recovery-key verification when the device lacks full cross-signing identity trust", async () => { const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1))); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index d68245460b2..8c68cec2a5e 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -1237,11 +1237,12 @@ export class MatrixClient { typeof crypto.getSecretStorageStatus === "function" ? await crypto.getSecretStorageStatus().catch(() => null) : null; - const stagedRecoveryKeyValidated = Boolean( + const stagedRecoveryKeyConfirmedBySecretStorage = + Boolean(stagedKeyId) && + secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId ?? ""] === true; + const stagedRecoveryKeyValidated = stagedRecoveryKeyUsed && - stagedKeyId && - secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId] === true, - ); + (stagedRecoveryKeyConfirmedBySecretStorage || (status.verified && backupUsable)); 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 42639807c0d..835cdb6ddb3 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts @@ -461,6 +461,47 @@ describe("MatrixCryptoBootstrapper", () => { }); }); + it("trusts the fresh own identity after a forced cross-signing reset", async () => { + const verifyOwnIdentity = vi.fn(async () => ({})); + const freeOwnIdentity = vi.fn(); + const { crypto, bootstrapper } = createForcedResetHarness(vi.fn(async () => {})); + crypto.getOwnIdentity = vi.fn(async () => ({ + free: freeOwnIdentity, + isVerified: () => false, + verify: verifyOwnIdentity, + })); + + await bootstrapper.bootstrap(crypto, { + strict: true, + forceResetCrossSigning: true, + }); + + expect(verifyOwnIdentity).toHaveBeenCalledTimes(1); + expect(freeOwnIdentity).toHaveBeenCalledTimes(1); + }); + + it("does not trust an existing unpublished identity without a reset", async () => { + const verifyOwnIdentity = vi.fn(async () => ({})); + const { crypto, bootstrapper } = createBootstrapperHarness({ + bootstrapCrossSigning: vi.fn(async () => {}), + getDeviceVerificationStatus: vi.fn(async () => createVerifiedDeviceStatus()), + getOwnIdentity: vi.fn(async () => ({ + isVerified: () => false, + verify: verifyOwnIdentity, + })), + isCrossSigningReady: vi.fn(async () => false), + userHasCrossSigningKeys: vi.fn(async () => false), + }); + + const result = await bootstrapper.bootstrap(crypto, { + allowAutomaticCrossSigningReset: false, + strict: false, + }); + + expect(result.crossSigningPublished).toBe(false); + expect(verifyOwnIdentity).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 1c040e1ce29..330fb0c0b2f 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts @@ -189,6 +189,7 @@ export class MatrixCryptoBootstrapper { }; try { await resetCrossSigning(); + await this.trustFreshOwnIdentity(crypto); } catch (err) { const shouldRepairSecretStorage = options.allowSecretStorageRecreateWithoutRecoveryKey && @@ -204,6 +205,7 @@ export class MatrixCryptoBootstrapper { forceNewSecretStorage: true, }); await resetCrossSigning(); + await this.trustFreshOwnIdentity(crypto); } catch (repairErr) { LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", repairErr); if (options.strict) { @@ -289,6 +291,7 @@ export class MatrixCryptoBootstrapper { setupNewCrossSigning: true, authUploadDeviceSigningKeys, }); + await this.trustFreshOwnIdentity(crypto); } catch (err) { LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err); if (options.strict) { @@ -300,6 +303,25 @@ export class MatrixCryptoBootstrapper { return await finalize(); } + private async trustFreshOwnIdentity(crypto: MatrixCryptoBootstrapApi): Promise { + const ownIdentity = + typeof crypto.getOwnIdentity === "function" + ? await crypto.getOwnIdentity().catch(() => undefined) + : undefined; + if (!ownIdentity) { + return; + } + + try { + if (typeof ownIdentity.isVerified === "function" && ownIdentity.isVerified()) { + return; + } + await ownIdentity.verify?.(); + } finally { + ownIdentity.free?.(); + } + } + private async bootstrapSecretStorage( crypto: MatrixCryptoBootstrapApi, options: {