diff --git a/extensions/matrix-js/src/matrix/sdk.test.ts b/extensions/matrix-js/src/matrix/sdk.test.ts index e2c6bdcfdf0..2119d689a26 100644 --- a/extensions/matrix-js/src/matrix/sdk.test.ts +++ b/extensions/matrix-js/src/matrix/sdk.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { encodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; class FakeMatrixEvent extends EventEmitter { @@ -691,6 +692,60 @@ describe("MatrixClient crypto bootstrapping", () => { ); }); + it("retries bootstrap with forced reset when initial publish/verification is incomplete", async () => { + matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() })); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + password: "secret-password", + }); + const bootstrapSpy = vi + .fn() + .mockResolvedValueOnce({ + crossSigningReady: false, + crossSigningPublished: false, + ownDeviceVerified: false, + }) + .mockResolvedValueOnce({ + crossSigningReady: true, + crossSigningPublished: true, + ownDeviceVerified: true, + }); + ( + client as unknown as { + cryptoBootstrapper: { bootstrap: typeof bootstrapSpy }; + } + ).cryptoBootstrapper.bootstrap = bootstrapSpy; + + await client.start(); + + expect(bootstrapSpy).toHaveBeenCalledTimes(2); + expect(bootstrapSpy.mock.calls[1]?.[1]).toEqual({ + forceResetCrossSigning: true, + strict: true, + }); + }); + + it("does not force-reset bootstrap when password is unavailable", async () => { + matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() })); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + const bootstrapSpy = vi.fn().mockResolvedValue({ + crossSigningReady: false, + crossSigningPublished: false, + ownDeviceVerified: false, + }); + ( + client as unknown as { + cryptoBootstrapper: { bootstrap: typeof bootstrapSpy }; + } + ).cryptoBootstrapper.bootstrap = bootstrapSpy; + + await client.start(); + + expect(bootstrapSpy).toHaveBeenCalledTimes(1); + }); + it("provides secret storage callbacks and resolves stored recovery key", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-")); const recoveryKeyPath = path.join(tmpDir, "recovery-key.json"); @@ -748,4 +803,148 @@ describe("MatrixClient crypto bootstrapping", () => { await vi.advanceTimersByTimeAsync(120_000); expect(databasesSpy.mock.calls.length).toBe(callsAfterStop); }); + + it("reports own verification status when crypto marks device as verified", async () => { + 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), + 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, + }); + await client.start(); + + const status = await client.getOwnDeviceVerificationStatus(); + expect(status.encryptionEnabled).toBe(true); + expect(status.verified).toBe(true); + expect(status.userId).toBe("@bot:example.org"); + expect(status.deviceId).toBe("DEVICE123"); + }); + + it("verifies with a provided recovery key and reports success", async () => { + const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1))); + expect(encoded).toBeTypeOf("string"); + + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + const bootstrapSecretStorage = vi.fn(async () => {}); + const bootstrapCrossSigning = vi.fn(async () => {}); + const getSecretStorageStatus = vi.fn(async () => ({ + ready: true, + defaultKeyId: "SSSSKEY", + secretStorageKeyValidityMap: { SSSSKEY: true }, + })); + const getDeviceVerificationStatus = vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning, + bootstrapSecretStorage, + requestOwnUserVerification: vi.fn(async () => null), + getSecretStorageStatus, + getDeviceVerificationStatus, + })); + + const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-key-")); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"), + }); + await client.start(); + + const result = await client.verifyWithRecoveryKey(encoded as string); + expect(result.success).toBe(true); + expect(result.verified).toBe(true); + expect(result.recoveryKeyStored).toBe(true); + expect(result.deviceId).toBe("DEVICE123"); + expect(bootstrapSecretStorage).toHaveBeenCalled(); + expect(bootstrapCrossSigning).toHaveBeenCalled(); + }); + + it("reports bootstrap failure when cross-signing keys are not published", async () => { + 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), + isCrossSigningReady: vi.fn(async () => false), + userHasCrossSigningKeys: vi.fn(async () => false), + 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: false, + selfSigningKeyPublished: false, + userSigningKeyPublished: false, + published: false, + }); + + const result = await client.bootstrapOwnDeviceVerification(); + expect(result.success).toBe(false); + expect(result.error).toContain( + "Cross-signing bootstrap finished but server keys are still not published", + ); + }); + + it("reports bootstrap success when own device is verified and keys are published", async () => { + 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), + 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, + }); + + const result = await client.bootstrapOwnDeviceVerification(); + expect(result.success).toBe(true); + expect(result.verification.verified).toBe(true); + expect(result.crossSigning.published).toBe(true); + expect(result.cryptoBootstrap).not.toBeNull(); + }); }); diff --git a/extensions/matrix-js/src/matrix/sdk.ts b/extensions/matrix-js/src/matrix/sdk.ts index 058ab9d77fc..8cb04da6689 100644 --- a/extensions/matrix-js/src/matrix/sdk.ts +++ b/extensions/matrix-js/src/matrix/sdk.ts @@ -9,6 +9,7 @@ import { } from "matrix-js-sdk"; import { VerificationMethod } from "matrix-js-sdk/lib/types.js"; import { MatrixCryptoBootstrapper } from "./sdk/crypto-bootstrap.js"; +import type { MatrixCryptoBootstrapResult } from "./sdk/crypto-bootstrap.js"; import { createMatrixCryptoFacade, type MatrixCryptoFacade } from "./sdk/crypto-facade.js"; import { MatrixDecryptBridge } from "./sdk/decrypt-bridge.js"; import { matrixEventToRaw, parseMxc } from "./sdk/event-helpers.js"; @@ -20,6 +21,7 @@ import { type HttpMethod, type QueryParams } from "./sdk/transport.js"; import type { MatrixClientEventMap, MatrixCryptoBootstrapApi, + MatrixDeviceVerificationStatusLike, MatrixRawEvent, MessageEventContent, } from "./sdk/types.js"; @@ -39,6 +41,54 @@ export type { TextualMessageEventContent, } from "./sdk/types.js"; +export type MatrixOwnDeviceVerificationStatus = { + encryptionEnabled: boolean; + userId: string | null; + deviceId: string | null; + verified: boolean; + localVerified: boolean; + crossSigningVerified: boolean; + signedByOwner: boolean; + recoveryKeyStored: boolean; + recoveryKeyCreatedAt: string | null; + recoveryKeyId: string | null; + backupVersion: string | null; +}; + +export type MatrixRecoveryKeyVerificationResult = MatrixOwnDeviceVerificationStatus & { + success: boolean; + verifiedAt?: string; + error?: string; +}; + +export type MatrixOwnCrossSigningPublicationStatus = { + userId: string | null; + masterKeyPublished: boolean; + selfSigningKeyPublished: boolean; + userSigningKeyPublished: boolean; + published: boolean; +}; + +export type MatrixVerificationBootstrapResult = { + success: boolean; + error?: string; + verification: MatrixOwnDeviceVerificationStatus; + crossSigning: MatrixOwnCrossSigningPublicationStatus; + pendingVerifications: number; + cryptoBootstrap: MatrixCryptoBootstrapResult | null; +}; + +function isMatrixDeviceVerified( + status: MatrixDeviceVerificationStatusLike | null | undefined, +): boolean { + return ( + status?.isVerified?.() === true || + status?.localVerified === true || + status?.crossSigningVerified === true || + status?.signedByOwner === true + ); +} + export class MatrixClient { private readonly client: MatrixJsClient; private readonly emitter = new EventEmitter(); @@ -46,10 +96,12 @@ export class MatrixClient { private readonly localTimeoutMs: number; private readonly initialSyncLimit?: number; private readonly encryptionEnabled: boolean; + private readonly password?: string; private readonly idbSnapshotPath?: string; private readonly cryptoDatabasePrefix?: string; private bridgeRegistered = false; private started = false; + private cryptoBootstrapped = false; private selfUserId: string | null; private readonly dmRoomIds = new Set(); private cryptoInitialized = false; @@ -88,6 +140,7 @@ export class MatrixClient { this.localTimeoutMs = Math.max(1, opts.localTimeoutMs ?? 60_000); this.initialSyncLimit = opts.initialSyncLimit; this.encryptionEnabled = opts.encryption === true; + this.password = opts.password; this.idbSnapshotPath = opts.idbSnapshotPath; this.cryptoDatabasePrefix = opts.cryptoDatabasePrefix; this.selfUserId = opts.userId?.trim() || null; @@ -176,6 +229,7 @@ export class MatrixClient { await this.client.startClient({ initialSyncLimit: this.initialSyncLimit, }); + await this.bootstrapCryptoIfNeeded(); this.started = true; this.emitOutstandingInviteEvents(); await this.refreshDmCache().catch(noop); @@ -196,6 +250,45 @@ export class MatrixClient { this.started = false; } + private async bootstrapCryptoIfNeeded(): Promise { + if (!this.encryptionEnabled || !this.cryptoInitialized || this.cryptoBootstrapped) { + return; + } + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (!crypto) { + return; + } + const initial = await this.cryptoBootstrapper.bootstrap(crypto); + if (!initial.crossSigningPublished || initial.ownDeviceVerified === false) { + if (this.password?.trim()) { + try { + const repaired = await this.cryptoBootstrapper.bootstrap(crypto, { + forceResetCrossSigning: true, + strict: true, + }); + if (repaired.crossSigningPublished && repaired.ownDeviceVerified !== false) { + LogService.info( + "MatrixClientLite", + "Cross-signing/bootstrap recovered after forced reset", + ); + } + } catch (err) { + LogService.warn( + "MatrixClientLite", + "Failed to recover cross-signing/bootstrap with forced reset:", + err, + ); + } + } else { + LogService.warn( + "MatrixClientLite", + "Cross-signing/bootstrap incomplete and no password is configured for UIA fallback", + ); + } + } + this.cryptoBootstrapped = true; + } + private async initializeCryptoIfNeeded(): Promise { if (!this.encryptionEnabled || this.cryptoInitialized) { return; @@ -210,11 +303,6 @@ export class MatrixClient { }); this.cryptoInitialized = true; - const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; - if (crypto) { - await this.cryptoBootstrapper.bootstrap(crypto); - } - // Persist the crypto store after successful init (captures fresh keys on first run). await persistIdbToDisk({ snapshotPath: this.idbSnapshotPath, @@ -412,6 +500,241 @@ export class MatrixClient { }); } + async getOwnDeviceVerificationStatus(): Promise { + const recoveryKey = this.recoveryKeyStore.getRecoveryKeySummary(); + const userId = this.client.getUserId() ?? this.selfUserId ?? null; + const deviceId = this.client.getDeviceId()?.trim() || null; + + if (!this.encryptionEnabled) { + return { + encryptionEnabled: false, + userId, + deviceId, + verified: false, + localVerified: false, + crossSigningVerified: false, + signedByOwner: false, + recoveryKeyStored: Boolean(recoveryKey), + recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null, + recoveryKeyId: recoveryKey?.keyId ?? null, + backupVersion: null, + }; + } + + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + let deviceStatus: MatrixDeviceVerificationStatusLike | null = null; + if (crypto && userId && deviceId && typeof crypto.getDeviceVerificationStatus === "function") { + deviceStatus = await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null); + } + + return { + encryptionEnabled: true, + userId, + deviceId, + verified: isMatrixDeviceVerified(deviceStatus), + localVerified: deviceStatus?.localVerified === true, + crossSigningVerified: deviceStatus?.crossSigningVerified === true, + signedByOwner: deviceStatus?.signedByOwner === true, + recoveryKeyStored: Boolean(recoveryKey), + recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null, + recoveryKeyId: recoveryKey?.keyId ?? null, + backupVersion: await this.resolveRoomKeyBackupVersion(), + }; + } + + async verifyWithRecoveryKey( + rawRecoveryKey: string, + ): Promise { + if (!this.encryptionEnabled) { + return { + success: false, + error: "Matrix encryption is disabled for this client", + encryptionEnabled: false, + userId: this.client.getUserId() ?? this.selfUserId ?? null, + deviceId: this.client.getDeviceId()?.trim() || null, + verified: false, + localVerified: false, + crossSigningVerified: false, + signedByOwner: false, + recoveryKeyStored: false, + recoveryKeyCreatedAt: null, + recoveryKeyId: null, + backupVersion: null, + }; + } + + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (!crypto) { + return { + success: false, + error: "Matrix crypto is not available (start client with encryption enabled)", + ...(await this.getOwnDeviceVerificationStatus()), + }; + } + + const trimmedRecoveryKey = rawRecoveryKey.trim(); + if (!trimmedRecoveryKey) { + return { + success: false, + error: "Matrix recovery key is required", + ...(await this.getOwnDeviceVerificationStatus()), + }; + } + + let defaultKeyId: string | null | undefined = undefined; + if (typeof crypto.getSecretStorageStatus === "function") { + const status = await crypto.getSecretStorageStatus().catch(() => null); + defaultKeyId = status?.defaultKeyId; + } + + try { + this.recoveryKeyStore.storeEncodedRecoveryKey({ + encodedPrivateKey: trimmedRecoveryKey, + keyId: defaultKeyId, + }); + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + ...(await this.getOwnDeviceVerificationStatus()), + }; + } + + await this.cryptoBootstrapper.bootstrap(crypto); + const status = await this.getOwnDeviceVerificationStatus(); + if (!status.verified) { + return { + success: false, + error: + "Matrix device is still unverified after applying recovery key. Verify your recovery key and ensure cross-signing is available.", + ...status, + }; + } + + return { + success: true, + verifiedAt: new Date().toISOString(), + ...status, + }; + } + + async getOwnCrossSigningPublicationStatus(): Promise { + const userId = this.client.getUserId() ?? this.selfUserId ?? null; + if (!userId) { + return { + userId: null, + masterKeyPublished: false, + selfSigningKeyPublished: false, + userSigningKeyPublished: false, + published: false, + }; + } + + try { + const response = (await this.doRequest("POST", "/_matrix/client/v3/keys/query", undefined, { + device_keys: { [userId]: [] as string[] }, + })) as { + master_keys?: Record; + self_signing_keys?: Record; + user_signing_keys?: Record; + }; + const masterKeyPublished = Boolean(response.master_keys?.[userId]); + const selfSigningKeyPublished = Boolean(response.self_signing_keys?.[userId]); + const userSigningKeyPublished = Boolean(response.user_signing_keys?.[userId]); + return { + userId, + masterKeyPublished, + selfSigningKeyPublished, + userSigningKeyPublished, + published: masterKeyPublished && selfSigningKeyPublished && userSigningKeyPublished, + }; + } catch { + return { + userId, + masterKeyPublished: false, + selfSigningKeyPublished: false, + userSigningKeyPublished: false, + published: false, + }; + } + } + + async bootstrapOwnDeviceVerification(params?: { + recoveryKey?: string; + forceResetCrossSigning?: boolean; + }): Promise { + const pendingVerifications = async (): Promise => + this.crypto ? (await this.crypto.listVerifications()).length : 0; + if (!this.encryptionEnabled) { + return { + success: false, + error: "Matrix encryption is disabled for this client", + verification: await this.getOwnDeviceVerificationStatus(), + crossSigning: await this.getOwnCrossSigningPublicationStatus(), + pendingVerifications: await pendingVerifications(), + cryptoBootstrap: null, + }; + } + + let bootstrapError: string | undefined; + let bootstrapSummary: MatrixCryptoBootstrapResult | null = null; + try { + await this.initializeCryptoIfNeeded(); + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (!crypto) { + throw new Error("Matrix crypto is not available (start client with encryption enabled)"); + } + + const rawRecoveryKey = params?.recoveryKey?.trim(); + if (rawRecoveryKey) { + let defaultKeyId: string | null | undefined = undefined; + if (typeof crypto.getSecretStorageStatus === "function") { + const status = await crypto.getSecretStorageStatus().catch(() => null); + defaultKeyId = status?.defaultKeyId; + } + this.recoveryKeyStore.storeEncodedRecoveryKey({ + encodedPrivateKey: rawRecoveryKey, + keyId: defaultKeyId, + }); + } + + bootstrapSummary = await this.cryptoBootstrapper.bootstrap(crypto, { + forceResetCrossSigning: params?.forceResetCrossSigning === true, + strict: true, + }); + } catch (err) { + 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 = + bootstrapError ?? + (success + ? undefined + : "Matrix verification bootstrap did not produce a verified device with published cross-signing keys"); + return { + success, + error, + verification, + crossSigning, + pendingVerifications: await pendingVerifications(), + cryptoBootstrap: bootstrapSummary, + }; + } + + private async resolveRoomKeyBackupVersion(): Promise { + try { + const response = (await this.doRequest("GET", "/_matrix/client/v3/room_keys/version")) as { + version?: string; + }; + return response.version?.trim() || null; + } catch { + return null; + } + } + private registerBridge(): void { if (this.bridgeRegistered) { return;