diff --git a/CHANGELOG.md b/CHANGELOG.md index abd96e05273..1cd89f55131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. - State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy. - Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras. +- Matrix plugin: harden E2EE bootstrap and verification flows (cross-signing + secret storage + own-device trust), add bounded decrypt retry with crypto-key signals, enforce safe absolute-endpoint request handling, and split SDK internals into focused modules with coverage. (#11705) Thanks @gumadeiras. ## 2026.2.6 diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 771e5fd8fad..4509e624271 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -123,6 +123,10 @@ Enable with `channels.matrix.encryption: true`: - OpenClaw creates or reuses a recovery key for secret storage and stores it at: `~/.openclaw/credentials/matrix/accounts//__//recovery-key.json` - On startup, OpenClaw requests self-verification and can accept incoming verification requests. +- OpenClaw also marks and cross-signs its own device when crypto APIs are available, which improves + trust establishment on fresh sessions. +- Failed decryptions are retried with bounded backoff and retried immediately again when new room keys + arrive, so new key-sharing events recover without waiting for the next retry window. - Verify in another Matrix client (Element, etc.) to establish trust and improve key sharing. - If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt; OpenClaw logs a warning. @@ -251,6 +255,11 @@ Common failures: - Logged in but room messages ignored: room blocked by `groupPolicy` or room allowlist. - DMs ignored: sender pending approval when `channels.matrix.dm.policy="pairing"`. - Encrypted rooms fail: crypto support or encryption settings mismatch. +- "User verification unavailable" in Element for the bot profile: + - Ensure `channels.matrix.encryption: true` is set and restart. + - Ensure the bot logs in with a stable `channels.matrix.deviceId`. + - Send at least one new encrypted message after verification. Older messages from before + the current bot device login may remain undecryptable. For triage flow: [/channels/troubleshooting](/channels/troubleshooting). diff --git a/extensions/matrix/src/matrix/actions/types.ts b/extensions/matrix/src/matrix/actions/types.ts index f10e4223d30..f9fa2acd261 100644 --- a/extensions/matrix/src/matrix/actions/types.ts +++ b/extensions/matrix/src/matrix/actions/types.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "../sdk.js"; +import type { MatrixClient, MatrixRawEvent, MessageEventContent } from "../sdk.js"; export const MsgType = { Text: "m.text", @@ -16,7 +16,7 @@ export const EventType = { Reaction: "m.reaction", } as const; -export type RoomMessageEventContent = { +export type RoomMessageEventContent = MessageEventContent & { msgtype: string; body: string; "m.new_content"?: RoomMessageEventContent; @@ -43,17 +43,6 @@ export type RoomTopicEventContent = { topic?: string; }; -export type MatrixRawEvent = { - event_id: string; - sender: string; - type: string; - origin_server_ts: number; - content: Record; - unsigned?: { - redacted_because?: unknown; - }; -}; - export type MatrixActionClientOpts = { client?: MatrixClient; timeoutMs?: number; diff --git a/extensions/matrix/src/matrix/monitor/types.ts b/extensions/matrix/src/matrix/monitor/types.ts index 0a619d3b121..5d578868f3a 100644 --- a/extensions/matrix/src/matrix/monitor/types.ts +++ b/extensions/matrix/src/matrix/monitor/types.ts @@ -1,4 +1,4 @@ -import type { EncryptedFile, MessageEventContent } from "../sdk.js"; +import type { EncryptedFile, MatrixRawEvent, MessageEventContent } from "../sdk.js"; export const EventType = { RoomMessage: "m.room.message", @@ -12,18 +12,6 @@ export const RelationType = { Thread: "m.thread", } as const; -export type MatrixRawEvent = { - event_id: string; - sender: string; - type: string; - origin_server_ts: number; - content: Record; - unsigned?: { - age?: number; - redacted_because?: unknown; - }; -}; - export type RoomMessageEventContent = MessageEventContent & { url?: string; file?: EncryptedFile; diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index fcf7c874e97..d0dce7a0656 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -164,7 +164,23 @@ describe("MatrixClient request hardening", () => { vi.unstubAllGlobals(); }); - it("blocks cross-protocol redirects", async () => { + it("blocks absolute endpoints unless explicitly allowed", async () => { + const fetchMock = vi.fn(async () => { + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = new MatrixClient("https://matrix.example.org", "token"); + await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow( + "Absolute Matrix endpoint is blocked by default", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("blocks cross-protocol redirects when absolute endpoints are allowed", async () => { const fetchMock = vi.fn(async () => { return new Response("", { status: 302, @@ -177,9 +193,11 @@ describe("MatrixClient request hardening", () => { const client = new MatrixClient("https://matrix.example.org", "token"); - await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow( - "Blocked cross-protocol redirect", - ); + await expect( + client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, { + allowAbsoluteEndpoint: true, + }), + ).rejects.toThrow("Blocked cross-protocol redirect"); }); it("strips authorization when redirect crosses origin", async () => { @@ -203,7 +221,9 @@ describe("MatrixClient request hardening", () => { vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); const client = new MatrixClient("https://matrix.example.org", "token"); - await client.doRequest("GET", "https://matrix.example.org/start"); + await client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, { + allowAbsoluteEndpoint: true, + }); expect(calls).toHaveLength(2); expect(calls[0]?.url).toBe("https://matrix.example.org/start"); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index ba0e81d13da..a6c4e7febab 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -1,25 +1,21 @@ // Polyfill IndexedDB for WASM crypto in Node.js import "fake-indexeddb/auto"; -import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { ClientEvent, createClient as createMatrixJsClient, type MatrixClient as MatrixJsClient, type MatrixEvent, } from "matrix-js-sdk"; -import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js"; import { VerificationMethod } from "matrix-js-sdk/lib/types.js"; import { EventEmitter } from "node:events"; import type { - EncryptedFile, - LocationMessageEventContent, MatrixClientEventMap, MatrixCryptoBootstrapApi, - MatrixDeviceVerificationStatusLike, MatrixRawEvent, MessageEventContent, - TextualMessageEventContent, } from "./sdk/types.js"; +import { MatrixCryptoBootstrapper } 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"; import { MatrixAuthedHttpClient } from "./sdk/http-client.js"; @@ -27,13 +23,7 @@ import { persistIdbToDisk, restoreIdbFromDisk } from "./sdk/idb-persistence.js"; import { ConsoleLogger, LogService, noop } from "./sdk/logger.js"; import { MatrixRecoveryKeyStore } from "./sdk/recovery-key-store.js"; import { type HttpMethod, type QueryParams } from "./sdk/transport.js"; -import { - type MatrixVerificationCryptoApi, - MatrixVerificationManager, - type MatrixVerificationMethod, - type MatrixVerificationRequestLike, - type MatrixVerificationSummary, -} from "./sdk/verification-manager.js"; +import { MatrixVerificationManager } from "./sdk/verification-manager.js"; export { ConsoleLogger, LogService }; export type { @@ -49,50 +39,6 @@ export type { TextualMessageEventContent, } from "./sdk/types.js"; -type MatrixCryptoFacade = { - prepare: (joinedRooms: string[]) => Promise; - updateSyncData: ( - toDeviceMessages: unknown, - otkCounts: unknown, - unusedFallbackKeyAlgs: unknown, - changedDeviceLists: unknown, - leftDeviceLists: unknown, - ) => Promise; - isRoomEncrypted: (roomId: string) => Promise; - requestOwnUserVerification: () => Promise; - encryptMedia: (buffer: Buffer) => Promise<{ buffer: Buffer; file: Omit }>; - decryptMedia: (file: EncryptedFile) => Promise; - getRecoveryKey: () => Promise<{ - encodedPrivateKey?: string; - keyId?: string | null; - createdAt?: string; - } | null>; - listVerifications: () => Promise; - requestVerification: (params: { - ownUser?: boolean; - userId?: string; - deviceId?: string; - roomId?: string; - }) => Promise; - acceptVerification: (id: string) => Promise; - cancelVerification: ( - id: string, - params?: { reason?: string; code?: string }, - ) => Promise; - startVerification: ( - id: string, - method?: MatrixVerificationMethod, - ) => Promise; - generateVerificationQr: (id: string) => Promise<{ qrDataBase64: string }>; - scanVerificationQr: (id: string, qrDataBase64: string) => Promise; - confirmVerificationSas: (id: string) => Promise; - mismatchVerificationSas: (id: string) => Promise; - confirmVerificationReciprocateQr: (id: string) => Promise; - getVerificationSas: ( - id: string, - ) => Promise<{ decimal?: [number, number, number]; emoji?: Array<[string, string]> }>; -}; - export class MatrixClient { private readonly client: MatrixJsClient; private readonly emitter = new EventEmitter(); @@ -110,6 +56,7 @@ export class MatrixClient { private readonly decryptBridge: MatrixDecryptBridge; private readonly verificationManager = new MatrixVerificationManager(); private readonly recoveryKeyStore: MatrixRecoveryKeyStore; + private readonly cryptoBootstrapper: MatrixCryptoBootstrapper; readonly dms = { update: async (): Promise => { @@ -174,9 +121,23 @@ export class MatrixClient { this.emitter.emit("room.failed_decryption", roomId, event, error); }, }); + this.cryptoBootstrapper = new MatrixCryptoBootstrapper({ + getUserId: () => this.getUserId(), + getDeviceId: () => this.client.getDeviceId(), + verificationManager: this.verificationManager, + recoveryKeyStore: this.recoveryKeyStore, + decryptBridge: this.decryptBridge, + }); if (this.encryptionEnabled) { - this.crypto = this.createCryptoFacade(); + this.crypto = createMatrixCryptoFacade({ + client: this.client, + verificationManager: this.verificationManager, + recoveryKeyStore: this.recoveryKeyStore, + getRoomStateEvent: (roomId, eventType, stateKey = "") => + this.getRoomStateEvent(roomId, eventType, stateKey), + downloadContent: (mxcUrl) => this.downloadContent(mxcUrl), + }); } } @@ -248,8 +209,7 @@ export class MatrixClient { const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; if (crypto) { - await this.bootstrapCryptoIdentity(crypto); - this.registerVerificationRequestHandler(crypto); + await this.cryptoBootstrapper.bootstrap(crypto); } // Persist the crypto store after successful init (captures fresh keys on first run). @@ -270,102 +230,6 @@ export class MatrixClient { } } - private async bootstrapCryptoIdentity(crypto: MatrixCryptoBootstrapApi): Promise { - try { - await crypto.bootstrapCrossSigning({ setupNewCrossSigning: true }); - LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); - } catch (err) { - LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", err); - } - try { - await this.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto); - LogService.info("MatrixClientLite", "Secret storage bootstrap complete"); - } catch (err) { - LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err); - } - try { - await this.ensureOwnDeviceTrust(crypto); - } catch (err) { - LogService.warn("MatrixClientLite", "Failed to verify own Matrix device:", err); - } - } - - private registerVerificationRequestHandler(crypto: MatrixCryptoBootstrapApi): void { - // Auto-accept incoming verification requests from other users/devices. - crypto.on(CryptoEvent.VerificationRequestReceived, async (request) => { - const verificationRequest = request as MatrixVerificationRequestLike; - this.verificationManager.trackVerificationRequest(verificationRequest); - const otherUserId = verificationRequest.otherUserId; - const isSelfVerification = verificationRequest.isSelfVerification; - const initiatedByMe = verificationRequest.initiatedByMe; - - if (isSelfVerification || initiatedByMe) { - LogService.debug( - "MatrixClientLite", - `Ignoring ${isSelfVerification ? "self" : "initiated"} verification request from ${otherUserId}`, - ); - return; - } - - try { - LogService.info( - "MatrixClientLite", - `Auto-accepting verification request from ${otherUserId}`, - ); - await verificationRequest.accept(); - LogService.info( - "MatrixClientLite", - `Verification request from ${otherUserId} accepted, waiting for SAS...`, - ); - } catch (err) { - LogService.warn( - "MatrixClientLite", - `Failed to auto-accept verification from ${otherUserId}:`, - err, - ); - } - }); - - this.decryptBridge.bindCryptoRetrySignals(crypto); - LogService.info("MatrixClientLite", "Verification request handler registered"); - } - - private async ensureOwnDeviceTrust(crypto: MatrixCryptoBootstrapApi): Promise { - const deviceId = this.client.getDeviceId()?.trim(); - if (!deviceId) { - return; - } - const userId = await this.getUserId(); - - const deviceStatus = - typeof crypto.getDeviceVerificationStatus === "function" - ? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null) - : null; - const alreadyVerified = - deviceStatus?.isVerified?.() === true || - deviceStatus?.localVerified === true || - deviceStatus?.crossSigningVerified === true || - deviceStatus?.signedByOwner === true; - - if (alreadyVerified) { - return; - } - - if (typeof crypto.setDeviceVerified === "function") { - await crypto.setDeviceVerified(userId, deviceId, true); - } - - if (typeof crypto.crossSignDevice === "function") { - const crossSigningReady = - typeof crypto.isCrossSigningReady === "function" - ? await crypto.isCrossSigningReady() - : true; - if (crossSigningReady) { - await crypto.crossSignDevice(deviceId); - } - } - } - async getUserId(): Promise { const fromClient = this.client.getUserId(); if (fromClient) { @@ -478,13 +342,15 @@ export class MatrixClient { endpoint: string, qs?: QueryParams, body?: unknown, + opts?: { allowAbsoluteEndpoint?: boolean }, ): Promise { - return await this.requestJson({ + return await this.httpClient.requestJson({ method, endpoint, qs, body, timeoutMs: this.localTimeoutMs, + allowAbsoluteEndpoint: opts?.allowAbsoluteEndpoint, }); } @@ -506,7 +372,7 @@ export class MatrixClient { throw new Error(`Invalid Matrix content URI: ${mxcUrl}`); } const endpoint = `/_matrix/media/v3/download/${encodeURIComponent(parsed.server)}/${encodeURIComponent(parsed.mediaId)}`; - const response = await this.requestRaw({ + const response = await this.httpClient.requestRaw({ method: "GET", endpoint, qs: { allow_remote: allowRemote }, @@ -533,7 +399,7 @@ export class MatrixClient { } async sendReadReceipt(roomId: string, eventId: string): Promise { - await this.requestJson({ + await this.httpClient.requestJson({ method: "POST", endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/receipt/m.read/${encodeURIComponent( eventId, @@ -586,111 +452,6 @@ export class MatrixClient { }); } - private createCryptoFacade(): MatrixCryptoFacade { - return { - prepare: async (_joinedRooms: string[]) => { - // matrix-js-sdk performs crypto prep during startup; no extra work required here. - }, - updateSyncData: async ( - _toDeviceMessages: unknown, - _otkCounts: unknown, - _unusedFallbackKeyAlgs: unknown, - _changedDeviceLists: unknown, - _leftDeviceLists: unknown, - ) => { - // compatibility no-op - }, - isRoomEncrypted: async (roomId: string): Promise => { - const room = this.client.getRoom(roomId); - if (room?.hasEncryptionStateEvent()) { - return true; - } - try { - const event = await this.getRoomStateEvent(roomId, "m.room.encryption", ""); - return typeof event.algorithm === "string" && event.algorithm.length > 0; - } catch { - return false; - } - }, - requestOwnUserVerification: async (): Promise => { - const crypto = this.client.getCrypto() as MatrixVerificationCryptoApi | undefined; - return await this.verificationManager.requestOwnUserVerification(crypto); - }, - encryptMedia: async ( - buffer: Buffer, - ): Promise<{ buffer: Buffer; file: Omit }> => { - const encrypted = Attachment.encrypt(new Uint8Array(buffer)); - const mediaInfoJson = encrypted.mediaEncryptionInfo; - if (!mediaInfoJson) { - throw new Error("Matrix media encryption failed: missing media encryption info"); - } - const parsed = JSON.parse(mediaInfoJson) as EncryptedFile; - return { - buffer: Buffer.from(encrypted.encryptedData), - file: { - key: parsed.key, - iv: parsed.iv, - hashes: parsed.hashes, - v: parsed.v, - }, - }; - }, - decryptMedia: async (file: EncryptedFile): Promise => { - const encrypted = await this.downloadContent(file.url); - const metadata: EncryptedFile = { - url: file.url, - key: file.key, - iv: file.iv, - hashes: file.hashes, - v: file.v, - }; - const attachment = new EncryptedAttachment( - new Uint8Array(encrypted), - JSON.stringify(metadata), - ); - const decrypted = Attachment.decrypt(attachment); - return Buffer.from(decrypted); - }, - getRecoveryKey: async () => { - return this.recoveryKeyStore.getRecoveryKeySummary(); - }, - listVerifications: async () => { - return this.verificationManager.listVerifications(); - }, - requestVerification: async (params) => { - const crypto = this.client.getCrypto() as MatrixVerificationCryptoApi | undefined; - return await this.verificationManager.requestVerification(crypto, params); - }, - acceptVerification: async (id) => { - return await this.verificationManager.acceptVerification(id); - }, - cancelVerification: async (id, params) => { - return await this.verificationManager.cancelVerification(id, params); - }, - startVerification: async (id, method = "sas") => { - return await this.verificationManager.startVerification(id, method); - }, - generateVerificationQr: async (id) => { - return await this.verificationManager.generateVerificationQr(id); - }, - scanVerificationQr: async (id, qrDataBase64) => { - return await this.verificationManager.scanVerificationQr(id, qrDataBase64); - }, - confirmVerificationSas: async (id) => { - return await this.verificationManager.confirmVerificationSas(id); - }, - mismatchVerificationSas: async (id) => { - return this.verificationManager.mismatchVerificationSas(id); - }, - confirmVerificationReciprocateQr: async (id) => { - return this.verificationManager.confirmVerificationReciprocateQr(id); - }, - getVerificationSas: async (id) => { - return this.verificationManager.getVerificationSas(id); - }, - }; - } - private async refreshDmCache(): Promise { const direct = await this.getAccountData("m.direct"); this.dmRoomIds.clear(); @@ -708,23 +469,4 @@ export class MatrixClient { } } } - - private async requestJson(params: { - method: HttpMethod; - endpoint: string; - qs?: QueryParams; - body?: unknown; - timeoutMs: number; - }): Promise { - return await this.httpClient.requestJson(params); - } - - private async requestRaw(params: { - method: HttpMethod; - endpoint: string; - qs?: QueryParams; - timeoutMs: number; - }): Promise { - return await this.httpClient.requestRaw(params); - } } diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts new file mode 100644 index 00000000000..d60a72e9977 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js"; +import { MatrixCryptoBootstrapper, type MatrixCryptoBootstrapperDeps } from "./crypto-bootstrap.js"; + +function createBootstrapperDeps() { + return { + getUserId: vi.fn(async () => "@bot:example.org"), + getDeviceId: vi.fn(() => "DEVICE123"), + verificationManager: { + trackVerificationRequest: vi.fn(), + }, + recoveryKeyStore: { + bootstrapSecretStorageWithRecoveryKey: vi.fn(async () => {}), + }, + decryptBridge: { + bindCryptoRetrySignals: vi.fn(), + }, + }; +} + +function createCryptoApi(overrides?: Partial): MatrixCryptoBootstrapApi { + return { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + ...overrides, + }; +} + +describe("MatrixCryptoBootstrapper", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("bootstraps cross-signing/secret-storage and binds decrypt retry signals", async () => { + const deps = createBootstrapperDeps(); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(crypto.bootstrapCrossSigning).toHaveBeenCalledWith({ + setupNewCrossSigning: true, + }); + expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith( + crypto, + ); + expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledWith(crypto); + }); + + it("marks own device verified and cross-signs it when needed", async () => { + const deps = createBootstrapperDeps(); + const setDeviceVerified = vi.fn(async () => {}); + const crossSignDevice = vi.fn(async () => {}); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => false, + localVerified: false, + crossSigningVerified: false, + signedByOwner: false, + })), + setDeviceVerified, + crossSignDevice, + isCrossSigningReady: vi.fn(async () => true), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true); + expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123"); + }); + + it("auto-accepts incoming verification requests from other users", async () => { + const deps = createBootstrapperDeps(); + const listeners = new Map void>(); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + listeners.set(eventName, listener); + }), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + const verificationRequest = { + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: false, + accept: vi.fn(async () => {}), + }; + const listener = Array.from(listeners.entries()).find(([eventName]) => + eventName.toLowerCase().includes("verificationrequest"), + )?.[1]; + expect(listener).toBeTypeOf("function"); + await listener?.(verificationRequest); + + expect(deps.verificationManager.trackVerificationRequest).toHaveBeenCalledWith( + verificationRequest, + ); + expect(verificationRequest.accept).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts new file mode 100644 index 00000000000..beaef999c27 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts @@ -0,0 +1,122 @@ +import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js"; +import type { MatrixDecryptBridge } from "./decrypt-bridge.js"; +import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; +import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js"; +import type { + MatrixVerificationManager, + MatrixVerificationRequestLike, +} from "./verification-manager.js"; +import { LogService } from "./logger.js"; + +export type MatrixCryptoBootstrapperDeps = { + getUserId: () => Promise; + getDeviceId: () => string | null | undefined; + verificationManager: MatrixVerificationManager; + recoveryKeyStore: MatrixRecoveryKeyStore; + decryptBridge: Pick, "bindCryptoRetrySignals">; +}; + +export class MatrixCryptoBootstrapper { + constructor(private readonly deps: MatrixCryptoBootstrapperDeps) {} + + async bootstrap(crypto: MatrixCryptoBootstrapApi): Promise { + await this.bootstrapCrossSigning(crypto); + await this.bootstrapSecretStorage(crypto); + await this.ensureOwnDeviceTrust(crypto); + this.registerVerificationRequestHandler(crypto); + } + + private async bootstrapCrossSigning(crypto: MatrixCryptoBootstrapApi): Promise { + try { + await crypto.bootstrapCrossSigning({ setupNewCrossSigning: true }); + LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", err); + } + } + + private async bootstrapSecretStorage(crypto: MatrixCryptoBootstrapApi): Promise { + try { + await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto); + LogService.info("MatrixClientLite", "Secret storage bootstrap complete"); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err); + } + } + + private registerVerificationRequestHandler(crypto: MatrixCryptoBootstrapApi): void { + // Auto-accept incoming verification requests from other users/devices. + crypto.on(CryptoEvent.VerificationRequestReceived, async (request) => { + const verificationRequest = request as MatrixVerificationRequestLike; + this.deps.verificationManager.trackVerificationRequest(verificationRequest); + const otherUserId = verificationRequest.otherUserId; + const isSelfVerification = verificationRequest.isSelfVerification; + const initiatedByMe = verificationRequest.initiatedByMe; + + if (isSelfVerification || initiatedByMe) { + LogService.debug( + "MatrixClientLite", + `Ignoring ${isSelfVerification ? "self" : "initiated"} verification request from ${otherUserId}`, + ); + return; + } + + try { + LogService.info( + "MatrixClientLite", + `Auto-accepting verification request from ${otherUserId}`, + ); + await verificationRequest.accept(); + LogService.info( + "MatrixClientLite", + `Verification request from ${otherUserId} accepted, waiting for SAS...`, + ); + } catch (err) { + LogService.warn( + "MatrixClientLite", + `Failed to auto-accept verification from ${otherUserId}:`, + err, + ); + } + }); + + this.deps.decryptBridge.bindCryptoRetrySignals(crypto); + LogService.info("MatrixClientLite", "Verification request handler registered"); + } + + private async ensureOwnDeviceTrust(crypto: MatrixCryptoBootstrapApi): Promise { + const deviceId = this.deps.getDeviceId()?.trim(); + if (!deviceId) { + return; + } + const userId = await this.deps.getUserId(); + + const deviceStatus = + typeof crypto.getDeviceVerificationStatus === "function" + ? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null) + : null; + const alreadyVerified = + deviceStatus?.isVerified?.() === true || + deviceStatus?.localVerified === true || + deviceStatus?.crossSigningVerified === true || + deviceStatus?.signedByOwner === true; + + if (alreadyVerified) { + return; + } + + if (typeof crypto.setDeviceVerified === "function") { + await crypto.setDeviceVerified(userId, deviceId, true); + } + + if (typeof crypto.crossSignDevice === "function") { + const crossSigningReady = + typeof crypto.isCrossSigningReady === "function" + ? await crypto.isCrossSigningReady() + : true; + if (crossSigningReady) { + await crypto.crossSignDevice(deviceId); + } + } + } +} diff --git a/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts b/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts new file mode 100644 index 00000000000..e6494adc4bf --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; +import type { MatrixVerificationManager } from "./verification-manager.js"; +import { createMatrixCryptoFacade } from "./crypto-facade.js"; + +describe("createMatrixCryptoFacade", () => { + it("detects encrypted rooms from cached room state", async () => { + const facade = createMatrixCryptoFacade({ + client: { + getRoom: () => ({ + hasEncryptionStateEvent: () => true, + }), + getCrypto: () => undefined, + }, + verificationManager: { + requestOwnUserVerification: vi.fn(), + listVerifications: vi.fn(async () => []), + requestVerification: vi.fn(), + acceptVerification: vi.fn(), + cancelVerification: vi.fn(), + startVerification: vi.fn(), + generateVerificationQr: vi.fn(), + scanVerificationQr: vi.fn(), + confirmVerificationSas: vi.fn(), + mismatchVerificationSas: vi.fn(), + confirmVerificationReciprocateQr: vi.fn(), + getVerificationSas: vi.fn(), + } as unknown as MatrixVerificationManager, + recoveryKeyStore: { + getRecoveryKeySummary: vi.fn(() => null), + } as unknown as MatrixRecoveryKeyStore, + getRoomStateEvent: vi.fn(async () => ({ algorithm: "m.megolm.v1.aes-sha2" })), + downloadContent: vi.fn(async () => Buffer.alloc(0)), + }); + + await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true); + }); + + it("falls back to server room state when room cache has no encryption event", async () => { + const getRoomStateEvent = vi.fn(async () => ({ + algorithm: "m.megolm.v1.aes-sha2", + })); + const facade = createMatrixCryptoFacade({ + client: { + getRoom: () => ({ + hasEncryptionStateEvent: () => false, + }), + getCrypto: () => undefined, + }, + verificationManager: { + requestOwnUserVerification: vi.fn(), + listVerifications: vi.fn(async () => []), + requestVerification: vi.fn(), + acceptVerification: vi.fn(), + cancelVerification: vi.fn(), + startVerification: vi.fn(), + generateVerificationQr: vi.fn(), + scanVerificationQr: vi.fn(), + confirmVerificationSas: vi.fn(), + mismatchVerificationSas: vi.fn(), + confirmVerificationReciprocateQr: vi.fn(), + getVerificationSas: vi.fn(), + } as unknown as MatrixVerificationManager, + recoveryKeyStore: { + getRecoveryKeySummary: vi.fn(() => null), + } as unknown as MatrixRecoveryKeyStore, + getRoomStateEvent, + downloadContent: vi.fn(async () => Buffer.alloc(0)), + }); + + await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true); + expect(getRoomStateEvent).toHaveBeenCalledWith("!room:example.org", "m.room.encryption", ""); + }); + + it("forwards verification requests and uses client crypto API", async () => { + const crypto = { requestOwnUserVerification: vi.fn(async () => null) }; + const requestVerification = vi.fn(async () => ({ + id: "verification-1", + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: true, + phase: 2, + phaseName: "ready", + pending: true, + methods: ["m.sas.v1"], + canAccept: false, + hasSas: false, + hasReciprocateQr: false, + completed: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + const facade = createMatrixCryptoFacade({ + client: { + getRoom: () => null, + getCrypto: () => crypto, + }, + verificationManager: { + requestOwnUserVerification: vi.fn(async () => null), + listVerifications: vi.fn(async () => []), + requestVerification, + acceptVerification: vi.fn(), + cancelVerification: vi.fn(), + startVerification: vi.fn(), + generateVerificationQr: vi.fn(), + scanVerificationQr: vi.fn(), + confirmVerificationSas: vi.fn(), + mismatchVerificationSas: vi.fn(), + confirmVerificationReciprocateQr: vi.fn(), + getVerificationSas: vi.fn(), + } as unknown as MatrixVerificationManager, + recoveryKeyStore: { + getRecoveryKeySummary: vi.fn(() => ({ keyId: "KEY" })), + } as unknown as MatrixRecoveryKeyStore, + getRoomStateEvent: vi.fn(async () => ({})), + downloadContent: vi.fn(async () => Buffer.alloc(0)), + }); + + const result = await facade.requestVerification({ + userId: "@alice:example.org", + deviceId: "DEVICE", + }); + + expect(requestVerification).toHaveBeenCalledWith(crypto, { + userId: "@alice:example.org", + deviceId: "DEVICE", + }); + expect(result.id).toBe("verification-1"); + await expect(facade.getRecoveryKey()).resolves.toMatchObject({ keyId: "KEY" }); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/crypto-facade.ts b/extensions/matrix/src/matrix/sdk/crypto-facade.ts new file mode 100644 index 00000000000..e31131415cb --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/crypto-facade.ts @@ -0,0 +1,173 @@ +import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs"; +import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; +import type { EncryptedFile } from "./types.js"; +import type { + MatrixVerificationCryptoApi, + MatrixVerificationManager, + MatrixVerificationMethod, + MatrixVerificationSummary, +} from "./verification-manager.js"; + +type MatrixCryptoFacadeClient = { + getRoom: (roomId: string) => { hasEncryptionStateEvent: () => boolean } | null; + getCrypto: () => unknown; +}; + +export type MatrixCryptoFacade = { + prepare: (joinedRooms: string[]) => Promise; + updateSyncData: ( + toDeviceMessages: unknown, + otkCounts: unknown, + unusedFallbackKeyAlgs: unknown, + changedDeviceLists: unknown, + leftDeviceLists: unknown, + ) => Promise; + isRoomEncrypted: (roomId: string) => Promise; + requestOwnUserVerification: () => Promise; + encryptMedia: (buffer: Buffer) => Promise<{ buffer: Buffer; file: Omit }>; + decryptMedia: (file: EncryptedFile) => Promise; + getRecoveryKey: () => Promise<{ + encodedPrivateKey?: string; + keyId?: string | null; + createdAt?: string; + } | null>; + listVerifications: () => Promise; + requestVerification: (params: { + ownUser?: boolean; + userId?: string; + deviceId?: string; + roomId?: string; + }) => Promise; + acceptVerification: (id: string) => Promise; + cancelVerification: ( + id: string, + params?: { reason?: string; code?: string }, + ) => Promise; + startVerification: ( + id: string, + method?: MatrixVerificationMethod, + ) => Promise; + generateVerificationQr: (id: string) => Promise<{ qrDataBase64: string }>; + scanVerificationQr: (id: string, qrDataBase64: string) => Promise; + confirmVerificationSas: (id: string) => Promise; + mismatchVerificationSas: (id: string) => Promise; + confirmVerificationReciprocateQr: (id: string) => Promise; + getVerificationSas: ( + id: string, + ) => Promise<{ decimal?: [number, number, number]; emoji?: Array<[string, string]> }>; +}; + +export function createMatrixCryptoFacade(deps: { + client: MatrixCryptoFacadeClient; + verificationManager: MatrixVerificationManager; + recoveryKeyStore: MatrixRecoveryKeyStore; + getRoomStateEvent: ( + roomId: string, + eventType: string, + stateKey?: string, + ) => Promise>; + downloadContent: (mxcUrl: string) => Promise; +}): MatrixCryptoFacade { + return { + prepare: async (_joinedRooms: string[]) => { + // matrix-js-sdk performs crypto prep during startup; no extra work required here. + }, + updateSyncData: async ( + _toDeviceMessages: unknown, + _otkCounts: unknown, + _unusedFallbackKeyAlgs: unknown, + _changedDeviceLists: unknown, + _leftDeviceLists: unknown, + ) => { + // compatibility no-op + }, + isRoomEncrypted: async (roomId: string): Promise => { + const room = deps.client.getRoom(roomId); + if (room?.hasEncryptionStateEvent()) { + return true; + } + try { + const event = await deps.getRoomStateEvent(roomId, "m.room.encryption", ""); + return typeof event.algorithm === "string" && event.algorithm.length > 0; + } catch { + return false; + } + }, + requestOwnUserVerification: async (): Promise => { + const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined; + return await deps.verificationManager.requestOwnUserVerification(crypto); + }, + encryptMedia: async ( + buffer: Buffer, + ): Promise<{ buffer: Buffer; file: Omit }> => { + const encrypted = Attachment.encrypt(new Uint8Array(buffer)); + const mediaInfoJson = encrypted.mediaEncryptionInfo; + if (!mediaInfoJson) { + throw new Error("Matrix media encryption failed: missing media encryption info"); + } + const parsed = JSON.parse(mediaInfoJson) as EncryptedFile; + return { + buffer: Buffer.from(encrypted.encryptedData), + file: { + key: parsed.key, + iv: parsed.iv, + hashes: parsed.hashes, + v: parsed.v, + }, + }; + }, + decryptMedia: async (file: EncryptedFile): Promise => { + const encrypted = await deps.downloadContent(file.url); + const metadata: EncryptedFile = { + url: file.url, + key: file.key, + iv: file.iv, + hashes: file.hashes, + v: file.v, + }; + const attachment = new EncryptedAttachment( + new Uint8Array(encrypted), + JSON.stringify(metadata), + ); + const decrypted = Attachment.decrypt(attachment); + return Buffer.from(decrypted); + }, + getRecoveryKey: async () => { + return deps.recoveryKeyStore.getRecoveryKeySummary(); + }, + listVerifications: async () => { + return deps.verificationManager.listVerifications(); + }, + requestVerification: async (params) => { + const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined; + return await deps.verificationManager.requestVerification(crypto, params); + }, + acceptVerification: async (id) => { + return await deps.verificationManager.acceptVerification(id); + }, + cancelVerification: async (id, params) => { + return await deps.verificationManager.cancelVerification(id, params); + }, + startVerification: async (id, method = "sas") => { + return await deps.verificationManager.startVerification(id, method); + }, + generateVerificationQr: async (id) => { + return await deps.verificationManager.generateVerificationQr(id); + }, + scanVerificationQr: async (id, qrDataBase64) => { + return await deps.verificationManager.scanVerificationQr(id, qrDataBase64); + }, + confirmVerificationSas: async (id) => { + return await deps.verificationManager.confirmVerificationSas(id); + }, + mismatchVerificationSas: async (id) => { + return deps.verificationManager.mismatchVerificationSas(id); + }, + confirmVerificationReciprocateQr: async (id) => { + return deps.verificationManager.confirmVerificationReciprocateQr(id); + }, + getVerificationSas: async (id) => { + return deps.verificationManager.getVerificationSas(id); + }, + }; +} diff --git a/extensions/matrix/src/matrix/sdk/event-helpers.test.ts b/extensions/matrix/src/matrix/sdk/event-helpers.test.ts new file mode 100644 index 00000000000..b3fff8fc52b --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/event-helpers.test.ts @@ -0,0 +1,60 @@ +import type { MatrixEvent } from "matrix-js-sdk"; +import { describe, expect, it } from "vitest"; +import { buildHttpError, matrixEventToRaw, parseMxc } from "./event-helpers.js"; + +describe("event-helpers", () => { + it("parses mxc URIs", () => { + expect(parseMxc("mxc://server.example/media-id")).toEqual({ + server: "server.example", + mediaId: "media-id", + }); + expect(parseMxc("not-mxc")).toBeNull(); + }); + + it("builds HTTP errors from JSON and plain text payloads", () => { + const fromJson = buildHttpError(403, JSON.stringify({ error: "forbidden" })); + expect(fromJson.message).toBe("forbidden"); + expect(fromJson.statusCode).toBe(403); + + const fromText = buildHttpError(500, "internal failure"); + expect(fromText.message).toBe("internal failure"); + expect(fromText.statusCode).toBe(500); + }); + + it("serializes Matrix events and resolves state key from available sources", () => { + const viaGetter = { + getId: () => "$1", + getSender: () => "@alice:example.org", + getType: () => "m.room.member", + getTs: () => 1000, + getContent: () => ({ membership: "join" }), + getUnsigned: () => ({ age: 1 }), + getStateKey: () => "@alice:example.org", + } as unknown as MatrixEvent; + expect(matrixEventToRaw(viaGetter).state_key).toBe("@alice:example.org"); + + const viaWire = { + getId: () => "$2", + getSender: () => "@bob:example.org", + getType: () => "m.room.member", + getTs: () => 2000, + getContent: () => ({ membership: "join" }), + getUnsigned: () => ({}), + getStateKey: () => undefined, + getWireContent: () => ({ state_key: "@bob:example.org" }), + } as unknown as MatrixEvent; + expect(matrixEventToRaw(viaWire).state_key).toBe("@bob:example.org"); + + const viaRaw = { + getId: () => "$3", + getSender: () => "@carol:example.org", + getType: () => "m.room.member", + getTs: () => 3000, + getContent: () => ({ membership: "join" }), + getUnsigned: () => ({}), + getStateKey: () => undefined, + event: { state_key: "@carol:example.org" }, + } as unknown as MatrixEvent; + expect(matrixEventToRaw(viaRaw).state_key).toBe("@carol:example.org"); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/http-client.test.ts b/extensions/matrix/src/matrix/sdk/http-client.test.ts new file mode 100644 index 00000000000..f2b7ed59ee6 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/http-client.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { performMatrixRequestMock } = vi.hoisted(() => ({ + performMatrixRequestMock: vi.fn(), +})); + +vi.mock("./transport.js", () => ({ + performMatrixRequest: performMatrixRequestMock, +})); + +import { MatrixAuthedHttpClient } from "./http-client.js"; + +describe("MatrixAuthedHttpClient", () => { + beforeEach(() => { + performMatrixRequestMock.mockReset(); + }); + + it("parses JSON responses and forwards absolute-endpoint opt-in", async () => { + performMatrixRequestMock.mockResolvedValue({ + response: new Response('{"ok":true}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + text: '{"ok":true}', + buffer: Buffer.from('{"ok":true}', "utf8"), + }); + + const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); + const result = await client.requestJson({ + method: "GET", + endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami", + timeoutMs: 5000, + allowAbsoluteEndpoint: true, + }); + + expect(result).toEqual({ ok: true }); + expect(performMatrixRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami", + allowAbsoluteEndpoint: true, + }), + ); + }); + + it("returns plain text when response is not JSON", async () => { + performMatrixRequestMock.mockResolvedValue({ + response: new Response("pong", { + status: 200, + headers: { "content-type": "text/plain" }, + }), + text: "pong", + buffer: Buffer.from("pong", "utf8"), + }); + + const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); + const result = await client.requestJson({ + method: "GET", + endpoint: "/_matrix/client/v3/ping", + timeoutMs: 5000, + }); + + expect(result).toBe("pong"); + }); + + it("returns raw buffers for media requests", async () => { + const payload = Buffer.from([1, 2, 3, 4]); + performMatrixRequestMock.mockResolvedValue({ + response: new Response(payload, { status: 200 }), + text: payload.toString("utf8"), + buffer: payload, + }); + + const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); + const result = await client.requestRaw({ + method: "GET", + endpoint: "/_matrix/media/v3/download/example/id", + timeoutMs: 5000, + }); + + expect(result).toEqual(payload); + }); + + it("raises HTTP errors with status code metadata", async () => { + performMatrixRequestMock.mockResolvedValue({ + response: new Response(JSON.stringify({ error: "forbidden" }), { + status: 403, + headers: { "content-type": "application/json" }, + }), + text: JSON.stringify({ error: "forbidden" }), + buffer: Buffer.from(JSON.stringify({ error: "forbidden" }), "utf8"), + }); + + const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); + await expect( + client.requestJson({ + method: "GET", + endpoint: "/_matrix/client/v3/rooms", + timeoutMs: 5000, + }), + ).rejects.toMatchObject({ + message: "forbidden", + statusCode: 403, + }); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/http-client.ts b/extensions/matrix/src/matrix/sdk/http-client.ts index 70cce29c62a..d047bcc9c77 100644 --- a/extensions/matrix/src/matrix/sdk/http-client.ts +++ b/extensions/matrix/src/matrix/sdk/http-client.ts @@ -13,6 +13,7 @@ export class MatrixAuthedHttpClient { qs?: QueryParams; body?: unknown; timeoutMs: number; + allowAbsoluteEndpoint?: boolean; }): Promise { const { response, text } = await performMatrixRequest({ homeserver: this.homeserver, @@ -22,6 +23,7 @@ export class MatrixAuthedHttpClient { qs: params.qs, body: params.body, timeoutMs: params.timeoutMs, + allowAbsoluteEndpoint: params.allowAbsoluteEndpoint, }); if (!response.ok) { throw buildHttpError(response.status, text); @@ -41,6 +43,7 @@ export class MatrixAuthedHttpClient { endpoint: string; qs?: QueryParams; timeoutMs: number; + allowAbsoluteEndpoint?: boolean; }): Promise { const { response, buffer } = await performMatrixRequest({ homeserver: this.homeserver, @@ -50,6 +53,7 @@ export class MatrixAuthedHttpClient { qs: params.qs, timeoutMs: params.timeoutMs, raw: true, + allowAbsoluteEndpoint: params.allowAbsoluteEndpoint, }); if (!response.ok) { throw buildHttpError(response.status, buffer.toString("utf8")); diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts new file mode 100644 index 00000000000..939134dd959 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts @@ -0,0 +1,138 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MatrixCryptoBootstrapApi } from "./types.js"; +import { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; + +function createTempRecoveryKeyPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-recovery-key-store-")); + return path.join(dir, "recovery-key.json"); +} + +describe("MatrixRecoveryKeyStore", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("loads a stored recovery key for requested secret-storage keys", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: new Date().toISOString(), + keyId: "SSSS", + privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"), + }), + "utf8", + ); + + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const callbacks = store.buildCryptoCallbacks(); + const resolved = await callbacks.getSecretStorageKey?.( + { keys: { SSSS: { name: "test" } } }, + "m.cross_signing.master", + ); + + expect(resolved?.[0]).toBe("SSSS"); + expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]); + }); + + it("persists cached secret-storage keys with secure file permissions", () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const callbacks = store.buildCryptoCallbacks(); + + callbacks.cacheSecretStorageKey?.( + "KEY123", + { + name: "openclaw", + }, + new Uint8Array([9, 8, 7]), + ); + + const saved = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as { + keyId?: string; + privateKeyBase64?: string; + }; + expect(saved.keyId).toBe("KEY123"); + expect(saved.privateKeyBase64).toBe(Buffer.from([9, 8, 7]).toString("base64")); + + const mode = fs.statSync(recoveryKeyPath).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it("creates and persists a recovery key when secret storage is missing", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const generated = { + keyId: "GENERATED", + keyInfo: { name: "generated" }, + privateKey: new Uint8Array([5, 6, 7, 8]), + encodedPrivateKey: "encoded-generated-key", + }; + const createRecoveryKeyFromPassphrase = vi.fn(async () => generated); + const bootstrapSecretStorage = vi.fn( + async (opts?: { createSecretStorageKey?: () => Promise }) => { + await opts?.createSecretStorageKey?.(); + }, + ); + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + createRecoveryKeyFromPassphrase, + getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: null })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto); + + expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1); + expect(bootstrapSecretStorage).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewSecretStorage: true, + }), + ); + expect(store.getRecoveryKeySummary()).toMatchObject({ + keyId: "GENERATED", + encodedPrivateKey: "encoded-generated-key", + }); + }); + + it("rebinds stored recovery key to server default key id when it changes", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: new Date().toISOString(), + keyId: "OLD", + privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"), + }), + "utf8", + ); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + + const bootstrapSecretStorage = vi.fn(async () => {}); + const createRecoveryKeyFromPassphrase = vi.fn(async () => { + throw new Error("should not be called"); + }); + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + createRecoveryKeyFromPassphrase, + getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "NEW" })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto); + + expect(createRecoveryKeyFromPassphrase).not.toHaveBeenCalled(); + expect(store.getRecoveryKeySummary()).toMatchObject({ + keyId: "NEW", + }); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/transport.ts b/extensions/matrix/src/matrix/sdk/transport.ts index 5b793e085e2..8b2a7f8899d 100644 --- a/extensions/matrix/src/matrix/sdk/transport.ts +++ b/extensions/matrix/src/matrix/sdk/transport.ts @@ -107,11 +107,19 @@ export async function performMatrixRequest(params: { body?: unknown; timeoutMs: number; raw?: boolean; + allowAbsoluteEndpoint?: boolean; }): Promise<{ response: Response; text: string; buffer: Buffer }> { - const baseUrl = - params.endpoint.startsWith("http://") || params.endpoint.startsWith("https://") - ? new URL(params.endpoint) - : new URL(normalizeEndpoint(params.endpoint), params.homeserver); + const isAbsoluteEndpoint = + params.endpoint.startsWith("http://") || params.endpoint.startsWith("https://"); + if (isAbsoluteEndpoint && params.allowAbsoluteEndpoint !== true) { + throw new Error( + `Absolute Matrix endpoint is blocked by default: ${params.endpoint}. Set allowAbsoluteEndpoint=true to opt in.`, + ); + } + + const baseUrl = isAbsoluteEndpoint + ? new URL(params.endpoint) + : new URL(normalizeEndpoint(params.endpoint), params.homeserver); applyQuery(baseUrl, params.qs); const headers = new Headers();