From afd46ce9b854ca7e21ba32049cb7dc3dca4a5688 Mon Sep 17 00:00:00 2001 From: gustavo Date: Sun, 8 Feb 2026 15:20:29 -0500 Subject: [PATCH] Matrix: stabilize E2EE verification and modularize SDK --- docs/channels/matrix.md | 39 +- extensions/matrix/package.json | 1 + extensions/matrix/src/actions.ts | 42 + extensions/matrix/src/channel.ts | 3 + extensions/matrix/src/config-schema.ts | 3 + extensions/matrix/src/matrix/actions.ts | 14 + .../matrix/src/matrix/actions/client.ts | 1 + .../matrix/src/matrix/actions/verification.ts | 220 +++++ extensions/matrix/src/matrix/client.test.ts | 177 ++++ extensions/matrix/src/matrix/client/config.ts | 169 +++- .../matrix/src/matrix/client/create-client.ts | 5 + .../matrix/src/matrix/client/logging.ts | 2 +- extensions/matrix/src/matrix/client/shared.ts | 2 +- .../matrix/src/matrix/client/storage.ts | 7 +- extensions/matrix/src/matrix/client/types.ts | 3 + extensions/matrix/src/matrix/sdk.test.ts | 242 ++++- extensions/matrix/src/matrix/sdk.ts | 873 ++++++++++++------ .../matrix/src/matrix/sdk/decrypt-bridge.ts | 277 ++++++ .../matrix/src/matrix/sdk/idb-persistence.ts | 164 ++++ extensions/matrix/src/matrix/sdk/logger.ts | 57 ++ extensions/matrix/src/matrix/sdk/transport.ts | 163 ++++ .../src/matrix/sdk/verification-manager.ts | 434 +++++++++ extensions/matrix/src/matrix/send/client.ts | 1 + extensions/matrix/src/onboarding.ts | 14 +- extensions/matrix/src/tool-actions.ts | 130 +++ extensions/matrix/src/types.ts | 5 + pnpm-lock.yaml | 9 + 27 files changed, 2734 insertions(+), 323 deletions(-) create mode 100644 extensions/matrix/src/matrix/actions/verification.ts create mode 100644 extensions/matrix/src/matrix/sdk/decrypt-bridge.ts create mode 100644 extensions/matrix/src/matrix/sdk/idb-persistence.ts create mode 100644 extensions/matrix/src/matrix/sdk/logger.ts create mode 100644 extensions/matrix/src/matrix/sdk/transport.ts create mode 100644 extensions/matrix/src/matrix/sdk/verification-manager.ts diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 0aeb9429c99..771e5fd8fad 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -65,13 +65,16 @@ Details: [Plugins](/tools/plugin) - Or set `channels.matrix.userId` + `channels.matrix.password`: OpenClaw calls the same login endpoint, stores the access token in `~/.openclaw/credentials/matrix/credentials.json`, and reuses it on next start. + - Optional registration mode: set `channels.matrix.register: true` to attempt account creation + when password login fails (for homeservers that allow open registration). 4. Configure credentials: - Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`) - Or config: `channels.matrix.*` - If both are set, config takes precedence. - - With access token: user ID is fetched automatically via `/whoami`. + - With access token: user ID and device ID are fetched automatically via `/whoami` if missing. - When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`). + - Optional: set `channels.matrix.deviceId` (or `MATRIX_DEVICE_ID`) to pin to a known device ID. 5. Restart the gateway (or finish onboarding). 6. Start a DM with the bot or invite it to a room from any Matrix client (Element, Beeper, etc.; see [https://matrix.org/ecosystem/clients/](https://matrix.org/ecosystem/clients/)). Beeper requires E2EE, @@ -116,8 +119,11 @@ Enable with `channels.matrix.encryption: true`: - If the crypto module loads, encrypted rooms are decrypted automatically. - Outbound media is encrypted when sending to encrypted rooms. -- On first connection, OpenClaw requests device verification from your other sessions. -- Verify the device in another Matrix client (Element, etc.) to enable key sharing. +- Cross-signing and secret storage are bootstrapped at startup when possible. +- 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. +- 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. - If you see missing crypto module errors (for example, `@matrix-org/matrix-sdk-crypto-nodejs-*`), @@ -126,8 +132,9 @@ Enable with `channels.matrix.encryption: true`: `node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`. Crypto state is stored per account + access token in -`~/.openclaw/matrix/accounts//__//crypto/` -(SQLite database). Sync state lives alongside it in `bot-storage.json`. +`~/.openclaw/credentials/matrix/accounts//__//`. +Crypto data lives in IndexedDB plus a persisted snapshot (`crypto-idb-snapshot.json`), +with sync state in `bot-storage.json`. If the access token (device) changes, a new store is created and the bot must be re-verified for encrypted rooms. @@ -136,6 +143,25 @@ When E2EE is enabled, the bot will request verification from your other sessions Open Element (or another client) and approve the verification request to establish trust. Once verified, the bot can decrypt messages in encrypted rooms. +## Verification operations + +When E2EE is enabled and `channels.matrix.actions.verification` is on, the Matrix +`permissions` action exposes verification operations: + +- `encryption-status`: report encryption and recovery key status. +- `verification-list`: list tracked verification requests. +- `verification-request`: start verification (`ownUser`, `userId+deviceId`, or `userId+roomId`). +- `verification-accept`, `verification-cancel`: accept or cancel a request. +- `verification-start`: start SAS verification. +- `verification-sas`, `verification-confirm`, `verification-mismatch`: read and confirm or reject SAS. +- `verification-generate-qr`, `verification-scan-qr`, `verification-confirm-qr`: QR-based flows. + +Use these via the `permissions` action by setting `operation` (or `mode`) to one of: +`encryption-status`, `verification-list`, `verification-request`, `verification-accept`, +`verification-cancel`, `verification-start`, `verification-generate-qr`, +`verification-scan-qr`, `verification-sas`, `verification-confirm`, +`verification-mismatch`, `verification-confirm-qr`. + ## Routing model - Replies always go back to Matrix. @@ -239,6 +265,8 @@ Provider options: - `channels.matrix.userId`: Matrix user ID (optional with access token). - `channels.matrix.accessToken`: access token. - `channels.matrix.password`: password for login (token stored). +- `channels.matrix.register`: try account registration if password login fails. +- `channels.matrix.deviceId`: preferred device ID (used for E2EE initialization). - `channels.matrix.deviceName`: device display name. - `channels.matrix.encryption`: enable E2EE (default: false). - `channels.matrix.initialSyncLimit`: initial sync limit. @@ -257,3 +285,4 @@ Provider options: - `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always). - `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join. - `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo). +- `channels.matrix.actions.verification`: enable verification action operations. diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index adf4a947fc3..89d5ce2b038 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -5,6 +5,7 @@ "type": "module", "dependencies": { "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", + "fake-indexeddb": "^6.2.5", "markdown-it": "14.1.0", "matrix-js-sdk": "^40.1.0", "music-metadata": "^11.11.2", diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index a7c219536f4..5092c4f782d 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -39,6 +39,9 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { if (gate("channelInfo")) { actions.add("channel-info"); } + if (account.config.encryption === true && gate("verification")) { + actions.add("permissions"); + } return Array.from(actions); }, supportsAction: ({ action }) => action !== "poll", @@ -190,6 +193,45 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { ); } + if (action === "permissions") { + const operation = ( + readStringParam(params, "operation") ?? + readStringParam(params, "mode") ?? + "verification-list" + ) + .trim() + .toLowerCase(); + const operationToAction: Record = { + "encryption-status": "encryptionStatus", + "verification-list": "verificationList", + "verification-request": "verificationRequest", + "verification-accept": "verificationAccept", + "verification-cancel": "verificationCancel", + "verification-start": "verificationStart", + "verification-generate-qr": "verificationGenerateQr", + "verification-scan-qr": "verificationScanQr", + "verification-sas": "verificationSas", + "verification-confirm": "verificationConfirm", + "verification-mismatch": "verificationMismatch", + "verification-confirm-qr": "verificationConfirmQr", + }; + const resolvedAction = operationToAction[operation]; + if (!resolvedAction) { + throw new Error( + `Unsupported Matrix permissions operation: ${operation}. Supported values: ${Object.keys( + operationToAction, + ).join(", ")}`, + ); + } + return await handleMatrixAction( + { + ...params, + action: resolvedAction, + }, + cfg, + ); + } + throw new Error(`Action ${action} is not supported for provider matrix.`); }, }; diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 366f74ade09..18e32149669 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -62,6 +62,7 @@ function buildMatrixConfigUpdate( userId?: string; accessToken?: string; password?: string; + register?: boolean; deviceName?: string; initialSyncLimit?: number; }, @@ -78,6 +79,7 @@ function buildMatrixConfigUpdate( ...(input.userId ? { userId: input.userId } : {}), ...(input.accessToken ? { accessToken: input.accessToken } : {}), ...(input.password ? { password: input.password } : {}), + ...(typeof input.register === "boolean" ? { register: input.register } : {}), ...(input.deviceName ? { deviceName: input.deviceName } : {}), ...(typeof input.initialSyncLimit === "number" ? { initialSyncLimit: input.initialSyncLimit } @@ -130,6 +132,7 @@ export const matrixPlugin: ChannelPlugin = { "userId", "accessToken", "password", + "register", "deviceName", "initialSyncLimit", ], diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 4fa99e882f6..ea684060a70 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -10,6 +10,7 @@ const matrixActionSchema = z pins: z.boolean().optional(), memberInfo: z.boolean().optional(), channelInfo: z.boolean().optional(), + verification: z.boolean().optional(), }) .optional(); @@ -42,6 +43,8 @@ export const MatrixConfigSchema = z.object({ userId: z.string().optional(), accessToken: z.string().optional(), password: z.string().optional(), + register: z.boolean().optional(), + deviceId: z.string().optional(), deviceName: z.string().optional(), initialSyncLimit: z.number().optional(), encryption: z.boolean().optional(), diff --git a/extensions/matrix/src/matrix/actions.ts b/extensions/matrix/src/matrix/actions.ts index 34d24b6dd39..5614ff92f9d 100644 --- a/extensions/matrix/src/matrix/actions.ts +++ b/extensions/matrix/src/matrix/actions.ts @@ -12,4 +12,18 @@ export { export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js"; export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js"; export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js"; +export { + acceptMatrixVerification, + cancelMatrixVerification, + confirmMatrixVerificationReciprocateQr, + confirmMatrixVerificationSas, + generateMatrixVerificationQr, + getMatrixEncryptionStatus, + getMatrixVerificationSas, + listMatrixVerifications, + mismatchMatrixVerificationSas, + requestMatrixVerification, + scanMatrixVerificationQr, + startMatrixVerification, +} from "./actions/verification.js"; export { reactMatrixMessage } from "./send.js"; diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts index d9fe477db85..d96c48c05b9 100644 --- a/extensions/matrix/src/matrix/actions/client.ts +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -41,6 +41,7 @@ export async function resolveActionClient( homeserver: auth.homeserver, userId: auth.userId, accessToken: auth.accessToken, + deviceId: auth.deviceId, encryption: auth.encryption, localTimeoutMs: opts.timeoutMs, }); diff --git a/extensions/matrix/src/matrix/actions/verification.ts b/extensions/matrix/src/matrix/actions/verification.ts new file mode 100644 index 00000000000..1045f217ca3 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/verification.ts @@ -0,0 +1,220 @@ +import type { MatrixActionClientOpts } from "./types.js"; +import { resolveActionClient } from "./client.js"; + +function requireCrypto( + client: import("../sdk.js").MatrixClient, +): NonNullable { + if (!client.crypto) { + throw new Error("Matrix encryption is not available (enable channels.matrix.encryption=true)"); + } + return client.crypto; +} + +function resolveVerificationId(input: string): string { + const normalized = input.trim(); + if (!normalized) { + throw new Error("Matrix verification request id is required"); + } + return normalized; +} + +export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.listVerifications(); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function requestMatrixVerification( + params: MatrixActionClientOpts & { + ownUser?: boolean; + userId?: string; + deviceId?: string; + roomId?: string; + } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(params); + try { + const crypto = requireCrypto(client); + const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId); + return await crypto.requestVerification({ + ownUser, + userId: params.userId?.trim() || undefined, + deviceId: params.deviceId?.trim() || undefined, + roomId: params.roomId?.trim() || undefined, + }); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function acceptMatrixVerification( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.acceptVerification(resolveVerificationId(requestId)); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function cancelMatrixVerification( + requestId: string, + opts: MatrixActionClientOpts & { reason?: string; code?: string } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.cancelVerification(resolveVerificationId(requestId), { + reason: opts.reason?.trim() || undefined, + code: opts.code?.trim() || undefined, + }); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function startMatrixVerification( + requestId: string, + opts: MatrixActionClientOpts & { method?: "sas" } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas"); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function generateMatrixVerificationQr( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.generateVerificationQr(resolveVerificationId(requestId)); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function scanMatrixVerificationQr( + requestId: string, + qrDataBase64: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + const payload = qrDataBase64.trim(); + if (!payload) { + throw new Error("Matrix QR data is required"); + } + return await crypto.scanVerificationQr(resolveVerificationId(requestId), payload); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function getMatrixVerificationSas( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.getVerificationSas(resolveVerificationId(requestId)); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function confirmMatrixVerificationSas( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.confirmVerificationSas(resolveVerificationId(requestId)); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function mismatchMatrixVerificationSas( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.mismatchVerificationSas(resolveVerificationId(requestId)); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function confirmMatrixVerificationReciprocateQr( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId)); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function getMatrixEncryptionStatus( + opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + const recoveryKey = await crypto.getRecoveryKey(); + return { + encryptionEnabled: true, + recoveryKeyStored: Boolean(recoveryKey), + recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null, + ...(opts.includeRecoveryKey ? { recoveryKey: recoveryKey?.encodedPrivateKey ?? null } : {}), + pendingVerifications: (await crypto.listVerifications()).length, + }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index 0a28dd65638..b2480f8541e 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { CoreConfig } from "../types.js"; import { resolveMatrixAuth, resolveMatrixConfig } from "./client.js"; +import * as credentialsModule from "./credentials.js"; import * as sdkModule from "./sdk.js"; const saveMatrixCredentialsMock = vi.fn(); @@ -39,6 +40,8 @@ describe("resolveMatrixConfig", () => { userId: "@cfg:example.org", accessToken: "cfg-token", password: "cfg-pass", + register: false, + deviceId: undefined, deviceName: "CfgDevice", initialSyncLimit: 5, encryption: false, @@ -52,6 +55,7 @@ describe("resolveMatrixConfig", () => { MATRIX_USER_ID: "@env:example.org", MATRIX_ACCESS_TOKEN: "env-token", MATRIX_PASSWORD: "env-pass", + MATRIX_DEVICE_ID: "ENVDEVICE", MATRIX_DEVICE_NAME: "EnvDevice", } as NodeJS.ProcessEnv; const resolved = resolveMatrixConfig(cfg, env); @@ -59,10 +63,32 @@ describe("resolveMatrixConfig", () => { expect(resolved.userId).toBe("@env:example.org"); expect(resolved.accessToken).toBe("env-token"); expect(resolved.password).toBe("env-pass"); + expect(resolved.register).toBe(false); + expect(resolved.deviceId).toBe("ENVDEVICE"); expect(resolved.deviceName).toBe("EnvDevice"); expect(resolved.initialSyncLimit).toBeUndefined(); expect(resolved.encryption).toBe(false); }); + + it("reads register flag from config and env", () => { + const cfg = { + channels: { + matrix: { + register: true, + }, + }, + } as CoreConfig; + const resolvedFromCfg = resolveMatrixConfig(cfg, {} as NodeJS.ProcessEnv); + expect(resolvedFromCfg.register).toBe(true); + + const resolvedFromEnv = resolveMatrixConfig( + {} as CoreConfig, + { + MATRIX_REGISTER: "1", + } as NodeJS.ProcessEnv, + ); + expect(resolvedFromEnv.register).toBe(true); + }); }); describe("resolveMatrixAuth", () => { @@ -119,4 +145,155 @@ describe("resolveMatrixAuth", () => { }), ); }); + + it("can register account when password login fails and register mode is enabled", async () => { + const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest"); + doRequestSpy + .mockRejectedValueOnce(new Error("Invalid username or password")) + .mockResolvedValueOnce({ + access_token: "tok-registered", + user_id: "@newbot:example.org", + device_id: "REGDEVICE123", + }); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@newbot:example.org", + password: "secret", + register: true, + encryption: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + }); + + expect(doRequestSpy).toHaveBeenNthCalledWith( + 1, + "POST", + "/_matrix/client/v3/login", + undefined, + expect.objectContaining({ + type: "m.login.password", + }), + ); + expect(doRequestSpy).toHaveBeenNthCalledWith( + 2, + "POST", + "/_matrix/client/v3/register", + undefined, + expect.objectContaining({ + username: "newbot", + auth: { type: "m.login.dummy" }, + }), + ); + expect(auth).toMatchObject({ + homeserver: "https://matrix.example.org", + userId: "@newbot:example.org", + accessToken: "tok-registered", + deviceId: "REGDEVICE123", + encryption: true, + }); + }); + + it("falls back to config deviceId when cached credentials are missing it", async () => { + vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + createdAt: "2026-01-01T00:00:00.000Z", + }); + vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + encryption: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(auth.deviceId).toBe("DEVICE123"); + expect(saveMatrixCredentialsMock).toHaveBeenCalledWith( + expect.objectContaining({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + }), + ); + }); + + it("resolves missing whoami identity fields for token auth", async () => { + const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({ + user_id: "@bot:example.org", + device_id: "DEVICE123", + }); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + accessToken: "tok-123", + encryption: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + }); + + expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami"); + expect(auth).toMatchObject({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + encryption: true, + }); + }); + + it("uses config deviceId with cached credentials when token is loaded from cache", async () => { + vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + createdAt: "2026-01-01T00:00:00.000Z", + }); + vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + deviceId: "DEVICE123", + encryption: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(auth).toMatchObject({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + encryption: true, + }); + }); }); diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index ea595f5414f..96461784161 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -8,6 +8,88 @@ function clean(value?: string): string { return value?.trim() ?? ""; } +function parseOptionalBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") { + return value; + } + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + if (["1", "true", "yes", "on"].includes(normalized)) { + return true; + } + if (["0", "false", "no", "off"].includes(normalized)) { + return false; + } + return undefined; +} + +function resolveMatrixLocalpart(userId: string): string { + const trimmed = userId.trim(); + const noPrefix = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed; + const localpart = noPrefix.split(":")[0]?.trim() || ""; + if (!localpart) { + throw new Error(`Invalid Matrix userId for registration: ${userId}`); + } + return localpart; +} + +async function registerMatrixPasswordAccount(params: { + homeserver: string; + userId: string; + password: string; + deviceName?: string; +}): Promise<{ + access_token?: string; + user_id?: string; + device_id?: string; +}> { + const registerClient = new MatrixClient(params.homeserver, ""); + const payload = { + username: resolveMatrixLocalpart(params.userId), + password: params.password, + inhibit_login: false, + initial_device_display_name: params.deviceName ?? "OpenClaw Gateway", + }; + + let firstError: unknown = null; + try { + return (await registerClient.doRequest("POST", "/_matrix/client/v3/register", undefined, { + ...payload, + auth: { type: "m.login.dummy" }, + })) as { + access_token?: string; + user_id?: string; + device_id?: string; + }; + } catch (err) { + firstError = err; + } + + try { + return (await registerClient.doRequest( + "POST", + "/_matrix/client/v3/register", + undefined, + payload, + )) as { + access_token?: string; + user_id?: string; + device_id?: string; + }; + } catch (err) { + const firstMessage = firstError instanceof Error ? firstError.message : String(firstError); + const secondMessage = err instanceof Error ? err.message : String(err); + throw new Error( + `Matrix registration failed (dummy auth: ${firstMessage}; plain registration: ${secondMessage})`, + ); + } +} + export function resolveMatrixConfig( cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, env: NodeJS.ProcessEnv = process.env, @@ -17,6 +99,9 @@ export function resolveMatrixConfig( const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID); const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined; const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined; + const register = + parseOptionalBoolean(matrix.register) ?? parseOptionalBoolean(env.MATRIX_REGISTER) ?? false; + const deviceId = clean(matrix.deviceId) || clean(env.MATRIX_DEVICE_ID) || undefined; const deviceName = clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined; const initialSyncLimit = typeof matrix.initialSyncLimit === "number" @@ -28,6 +113,8 @@ export function resolveMatrixConfig( userId, accessToken, password, + register, + deviceId, deviceName, initialSyncLimit, encryption, @@ -65,24 +152,44 @@ export async function resolveMatrixAuth(params?: { // If we have an access token, we can fetch userId via whoami if not provided if (resolved.accessToken) { let userId = resolved.userId; - const knownDeviceId = - cachedCredentials && cachedCredentials.accessToken === resolved.accessToken - ? cachedCredentials.deviceId - : undefined; - if (!userId) { - // Fetch userId from access token via whoami + const hasMatchingCachedToken = cachedCredentials?.accessToken === resolved.accessToken; + let knownDeviceId = hasMatchingCachedToken + ? cachedCredentials?.deviceId || resolved.deviceId + : resolved.deviceId; + + if (!userId || !knownDeviceId) { + // Fetch whoami when we need to resolve userId and/or deviceId from token auth. ensureMatrixSdkLoggingConfigured(); const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken); - const whoami = await tempClient.getUserId(); - userId = whoami; - // Save the credentials with the fetched userId + const whoami = (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as { + user_id?: string; + device_id?: string; + }; + if (!userId) { + const fetchedUserId = whoami.user_id?.trim(); + if (!fetchedUserId) { + throw new Error("Matrix whoami did not return user_id"); + } + userId = fetchedUserId; + } + if (!knownDeviceId) { + knownDeviceId = whoami.device_id?.trim() || resolved.deviceId; + } + } + + const shouldRefreshCachedCredentials = + !cachedCredentials || + !hasMatchingCachedToken || + cachedCredentials.userId !== userId || + (cachedCredentials.deviceId || undefined) !== knownDeviceId; + if (shouldRefreshCachedCredentials) { saveMatrixCredentials({ homeserver: resolved.homeserver, userId, accessToken: resolved.accessToken, deviceId: knownDeviceId, }); - } else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) { + } else if (hasMatchingCachedToken) { touchMatrixCredentials(env); } return { @@ -102,7 +209,7 @@ export async function resolveMatrixAuth(params?: { homeserver: cachedCredentials.homeserver, userId: cachedCredentials.userId, accessToken: cachedCredentials.accessToken, - deviceId: cachedCredentials.deviceId, + deviceId: cachedCredentials.deviceId || resolved.deviceId, deviceName: resolved.deviceName, initialSyncLimit: resolved.initialSyncLimit, encryption: resolved.encryption, @@ -122,20 +229,46 @@ export async function resolveMatrixAuth(params?: { // Login with password using the same hardened request path as other Matrix HTTP calls. ensureMatrixSdkLoggingConfigured(); const loginClient = new MatrixClient(resolved.homeserver, ""); - const login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, { - type: "m.login.password", - identifier: { type: "m.id.user", user: resolved.userId }, - password: resolved.password, - initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway", - })) as { + let login: { access_token?: string; user_id?: string; device_id?: string; }; + try { + login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, { + type: "m.login.password", + identifier: { type: "m.id.user", user: resolved.userId }, + password: resolved.password, + initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway", + })) as { + access_token?: string; + user_id?: string; + device_id?: string; + }; + } catch (loginErr) { + if (!resolved.register) { + throw loginErr; + } + try { + login = await registerMatrixPasswordAccount({ + homeserver: resolved.homeserver, + userId: resolved.userId, + password: resolved.password, + deviceName: resolved.deviceName, + }); + } catch (registerErr) { + const loginMessage = loginErr instanceof Error ? loginErr.message : String(loginErr); + const registerMessage = + registerErr instanceof Error ? registerErr.message : String(registerErr); + throw new Error( + `Matrix login failed (${loginMessage}) and account registration failed (${registerMessage})`, + ); + } + } const accessToken = login.access_token?.trim(); if (!accessToken) { - throw new Error("Matrix login did not return an access token"); + throw new Error("Matrix login/registration did not return an access token"); } const auth: MatrixAuth = { diff --git a/extensions/matrix/src/matrix/client/create-client.ts b/extensions/matrix/src/matrix/client/create-client.ts index 8e71d3d5cd8..8c49616d5ea 100644 --- a/extensions/matrix/src/matrix/client/create-client.ts +++ b/extensions/matrix/src/matrix/client/create-client.ts @@ -39,11 +39,16 @@ export async function createMatrixClient(params: { accountId: params.accountId, }); + const cryptoDatabasePrefix = `openclaw-matrix-${storagePaths.accountKey}-${storagePaths.tokenHash}`; + return new MatrixClient(params.homeserver, params.accessToken, undefined, undefined, { userId: matrixClientUserId, deviceId: params.deviceId, encryption: params.encryption, localTimeoutMs: params.localTimeoutMs, initialSyncLimit: params.initialSyncLimit, + recoveryKeyPath: storagePaths.recoveryKeyPath, + idbSnapshotPath: storagePaths.idbSnapshotPath, + cryptoDatabasePrefix, }); } diff --git a/extensions/matrix/src/matrix/client/logging.ts b/extensions/matrix/src/matrix/client/logging.ts index f125a38ac2f..b4914678424 100644 --- a/extensions/matrix/src/matrix/client/logging.ts +++ b/extensions/matrix/src/matrix/client/logging.ts @@ -1,4 +1,4 @@ -import { ConsoleLogger, LogService } from "../sdk.js"; +import { ConsoleLogger, LogService } from "../sdk/logger.js"; let matrixSdkLoggingConfigured = false; const matrixSdkBaseLogger = new ConsoleLogger(); diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts index 2c745102ff0..3b43b49c7ba 100644 --- a/extensions/matrix/src/matrix/client/shared.ts +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -1,7 +1,7 @@ import type { MatrixClient } from "../sdk.js"; import type { CoreConfig } from "../types.js"; import type { MatrixAuth } from "./types.js"; -import { LogService } from "../sdk.js"; +import { LogService } from "../sdk/logger.js"; import { resolveMatrixAuth } from "./config.js"; import { createMatrixClient } from "./create-client.js"; import { DEFAULT_ACCOUNT_KEY } from "./storage.js"; diff --git a/extensions/matrix/src/matrix/client/storage.ts b/extensions/matrix/src/matrix/client/storage.ts index 1c9dfbf3371..f8f3d46edf4 100644 --- a/extensions/matrix/src/matrix/client/storage.ts +++ b/extensions/matrix/src/matrix/client/storage.ts @@ -39,8 +39,8 @@ function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): { } { const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); return { - storagePath: path.join(stateDir, "matrix", "bot-storage.json"), - cryptoPath: path.join(stateDir, "matrix", "crypto"), + storagePath: path.join(stateDir, "credentials", "matrix", "bot-storage.json"), + cryptoPath: path.join(stateDir, "credentials", "matrix", "crypto"), }; } @@ -59,6 +59,7 @@ export function resolveMatrixStoragePaths(params: { const tokenHash = hashAccessToken(params.accessToken); const rootDir = path.join( stateDir, + "credentials", "matrix", "accounts", accountKey, @@ -70,6 +71,8 @@ export function resolveMatrixStoragePaths(params: { storagePath: path.join(rootDir, "bot-storage.json"), cryptoPath: path.join(rootDir, "crypto"), metaPath: path.join(rootDir, STORAGE_META_FILENAME), + recoveryKeyPath: path.join(rootDir, "recovery-key.json"), + idbSnapshotPath: path.join(rootDir, "crypto-idb-snapshot.json"), accountKey, tokenHash, }; diff --git a/extensions/matrix/src/matrix/client/types.ts b/extensions/matrix/src/matrix/client/types.ts index c41c8e371fc..ac2839ea584 100644 --- a/extensions/matrix/src/matrix/client/types.ts +++ b/extensions/matrix/src/matrix/client/types.ts @@ -4,6 +4,7 @@ export type MatrixResolvedConfig = { accessToken?: string; deviceId?: string; password?: string; + register?: boolean; deviceName?: string; initialSyncLimit?: number; encryption?: boolean; @@ -31,6 +32,8 @@ export type MatrixStoragePaths = { storagePath: string; cryptoPath: string; metaPath: string; + recoveryKeyPath: string; + idbSnapshotPath: string; accountKey: string; tokenHash: string; }; diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 632488a90c9..4d920e3834d 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -1,4 +1,7 @@ import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; class FakeMatrixEvent extends EventEmitter { @@ -83,6 +86,7 @@ type MatrixJsClientStub = EventEmitter & { stopClient: ReturnType; initRustCrypto: ReturnType; getUserId: ReturnType; + getDeviceId: ReturnType; getJoinedRooms: ReturnType; getJoinedRoomMembers: ReturnType; getStateEvent: ReturnType; @@ -101,6 +105,7 @@ type MatrixJsClientStub = EventEmitter & { sendTyping: ReturnType; getRoom: ReturnType; getCrypto: ReturnType; + decryptEventIfNeeded: ReturnType; }; function createMatrixJsClientStub(): MatrixJsClientStub { @@ -109,6 +114,7 @@ function createMatrixJsClientStub(): MatrixJsClientStub { client.stopClient = vi.fn(); client.initRustCrypto = vi.fn(async () => {}); client.getUserId = vi.fn(() => "@bot:example.org"); + client.getDeviceId = vi.fn(() => "DEVICE123"); client.getJoinedRooms = vi.fn(async () => ({ joined_rooms: [] })); client.getJoinedRoomMembers = vi.fn(async () => ({ joined: {} })); client.getStateEvent = vi.fn(async () => ({})); @@ -127,15 +133,20 @@ function createMatrixJsClientStub(): MatrixJsClientStub { client.sendTyping = vi.fn(async () => {}); client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false })); client.getCrypto = vi.fn(() => undefined); + client.decryptEventIfNeeded = vi.fn(async () => {}); return client; } let matrixJsClient = createMatrixJsClientStub(); +let lastCreateClientOpts: Record | null = null; vi.mock("matrix-js-sdk", () => ({ ClientEvent: { Event: "event" }, MatrixEventEvent: { Decrypted: "decrypted" }, - createClient: vi.fn(() => matrixJsClient), + createClient: vi.fn((opts: Record) => { + lastCreateClientOpts = opts; + return matrixJsClient; + }), })); import { MatrixClient } from "./sdk.js"; @@ -143,6 +154,7 @@ import { MatrixClient } from "./sdk.js"; describe("MatrixClient request hardening", () => { beforeEach(() => { matrixJsClient = createMatrixJsClientStub(); + lastCreateClientOpts = null; vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -226,6 +238,12 @@ describe("MatrixClient request hardening", () => { describe("MatrixClient event bridge", () => { beforeEach(() => { matrixJsClient = createMatrixJsClientStub(); + lastCreateClientOpts = null; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); }); it("emits room.message only after encrypted events decrypt", async () => { @@ -313,6 +331,123 @@ describe("MatrixClient event bridge", () => { expect(delivered).toHaveLength(0); }); + it("retries failed decryption and emits room.message after late key availability", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token"); + const failed: string[] = []; + const delivered: string[] = []; + + client.on("room.failed_decryption", (_roomId, _event, error) => { + failed.push(error.message); + }); + client.on("room.message", (_roomId, event) => { + delivered.push(event.type); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + + matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { + encrypted.emit("decrypted", decrypted); + }); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + expect(failed).toEqual(["missing room key"]); + expect(delivered).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(1_600); + + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + expect(failed).toEqual(["missing room key"]); + expect(delivered).toEqual(["m.room.message"]); + }); + + it("retries failed decryptions immediately on crypto key update signals", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + const failed: string[] = []; + const delivered: string[] = []; + const cryptoListeners = new Map void>(); + + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + cryptoListeners.set(eventName, listener); + }), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + })); + + client.on("room.failed_decryption", (_roomId, _event, error) => { + failed.push(error.message); + }); + client.on("room.message", (_roomId, event) => { + delivered.push(event.type); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { + encrypted.emit("decrypted", decrypted); + }); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + expect(failed).toEqual(["missing room key"]); + expect(delivered).toHaveLength(0); + + const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached"); + expect(trigger).toBeTypeOf("function"); + trigger?.(); + await Promise.resolve(); + + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + expect(delivered).toEqual(["m.room.message"]); + }); + it("emits room.invite when a membership invite targets the current user", async () => { const client = new MatrixClient("https://matrix.example.org", "token"); const invites: string[] = []; @@ -340,3 +475,108 @@ describe("MatrixClient event bridge", () => { expect(invites).toEqual(["!room:example.org"]); }); }); + +describe("MatrixClient crypto bootstrapping", () => { + beforeEach(() => { + matrixJsClient = createMatrixJsClientStub(); + lastCreateClientOpts = null; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("passes cryptoDatabasePrefix into initRustCrypto", async () => { + matrixJsClient.getCrypto = vi.fn(() => undefined); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + cryptoDatabasePrefix: "openclaw-matrix-test", + }); + + await client.start(); + + expect(matrixJsClient.initRustCrypto).toHaveBeenCalledWith({ + cryptoDatabasePrefix: "openclaw-matrix-test", + }); + }); + + it("bootstraps cross-signing with setupNewCrossSigning enabled", async () => { + const bootstrapCrossSigning = vi.fn(async () => {}); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning, + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + + await client.start(); + + expect(bootstrapCrossSigning).toHaveBeenCalledWith({ + setupNewCrossSigning: true, + }); + }); + + 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"); + const privateKeyBase64 = Buffer.from([1, 2, 3, 4]).toString("base64"); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: new Date().toISOString(), + keyId: "SSSSKEY", + privateKeyBase64, + }), + "utf8", + ); + + new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + recoveryKeyPath, + }); + + const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as { + getSecretStorageKey?: ( + params: { keys: Record }, + name: string, + ) => Promise<[string, Uint8Array] | null>; + } | null; + expect(callbacks?.getSecretStorageKey).toBeTypeOf("function"); + + const resolved = await callbacks?.getSecretStorageKey?.( + { keys: { SSSSKEY: { algorithm: "m.secret_storage.v1.aes-hmac-sha2" } } }, + "m.cross_signing.master", + ); + expect(resolved?.[0]).toBe("SSSSKEY"); + expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]); + }); + + it("schedules periodic crypto snapshot persistence with fake timers", async () => { + vi.useFakeTimers(); + const databasesSpy = vi.spyOn(indexedDB, "databases").mockResolvedValue([]); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + idbSnapshotPath: path.join(os.tmpdir(), "matrix-idb-interval.json"), + cryptoDatabasePrefix: "openclaw-matrix-interval", + }); + + await client.start(); + const callsAfterStart = databasesSpy.mock.calls.length; + + await vi.advanceTimersByTimeAsync(60_000); + expect(databasesSpy.mock.calls.length).toBeGreaterThan(callsAfterStart); + + client.stop(); + const callsAfterStop = databasesSpy.mock.calls.length; + await vi.advanceTimersByTimeAsync(120_000); + expect(databasesSpy.mock.calls.length).toBe(callsAfterStop); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index e35a394d115..a2e636456ab 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -1,32 +1,30 @@ +// Polyfill IndexedDB for WASM crypto in Node.js +import "fake-indexeddb/auto"; import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { ClientEvent, - MatrixEventEvent, createClient as createMatrixJsClient, type MatrixClient as MatrixJsClient, type MatrixEvent, } from "matrix-js-sdk"; +import { CryptoEvent } from "matrix-js-sdk/src/crypto-api/CryptoEvent.ts"; +import { VerificationMethod } from "matrix-js-sdk/src/types.ts"; import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import path from "node:path"; +import { MatrixDecryptBridge } from "./sdk/decrypt-bridge.js"; +import { persistIdbToDisk, restoreIdbFromDisk } from "./sdk/idb-persistence.js"; +import { ConsoleLogger, LogService, noop } from "./sdk/logger.js"; +import { type HttpMethod, type QueryParams, performMatrixRequest } from "./sdk/transport.js"; +import { + type MatrixVerificationCryptoApi, + MatrixVerificationManager, + type MatrixVerificationMethod, + type MatrixVerificationRequestLike, + type MatrixVerificationSummary, +} from "./sdk/verification-manager.js"; -type Logger = { - trace: (module: string, ...messageOrObject: unknown[]) => void; - debug: (module: string, ...messageOrObject: unknown[]) => void; - info: (module: string, ...messageOrObject: unknown[]) => void; - warn: (module: string, ...messageOrObject: unknown[]) => void; - error: (module: string, ...messageOrObject: unknown[]) => void; -}; - -type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; - -type QueryValue = - | string - | number - | boolean - | null - | undefined - | Array; - -type QueryParams = Record | null | undefined; +export { ConsoleLogger, LogService }; type MatrixRawEvent = { event_id: string; @@ -51,56 +49,6 @@ type MatrixClientEventMap = { "room.join": [roomId: string, event: MatrixRawEvent]; }; -function noop(): void { - // no-op -} - -export class ConsoleLogger { - trace(module: string, ...messageOrObject: unknown[]): void { - console.debug(`[${module}]`, ...messageOrObject); - } - - debug(module: string, ...messageOrObject: unknown[]): void { - console.debug(`[${module}]`, ...messageOrObject); - } - - info(module: string, ...messageOrObject: unknown[]): void { - console.info(`[${module}]`, ...messageOrObject); - } - - warn(module: string, ...messageOrObject: unknown[]): void { - console.warn(`[${module}]`, ...messageOrObject); - } - - error(module: string, ...messageOrObject: unknown[]): void { - console.error(`[${module}]`, ...messageOrObject); - } -} - -const defaultLogger = new ConsoleLogger(); -let activeLogger: Logger = defaultLogger; - -export const LogService = { - setLogger(logger: Logger): void { - activeLogger = logger; - }, - trace(module: string, ...messageOrObject: unknown[]): void { - activeLogger.trace(module, ...messageOrObject); - }, - debug(module: string, ...messageOrObject: unknown[]): void { - activeLogger.debug(module, ...messageOrObject); - }, - info(module: string, ...messageOrObject: unknown[]): void { - activeLogger.info(module, ...messageOrObject); - }, - warn(module: string, ...messageOrObject: unknown[]): void { - activeLogger.warn(module, ...messageOrObject); - }, - error(module: string, ...messageOrObject: unknown[]): void { - activeLogger.error(module, ...messageOrObject); - }, -}; - export type EncryptedFile = { url: string; key: { @@ -182,6 +130,115 @@ type MatrixCryptoFacade = { 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]> }>; +}; + +type MatrixSecretStorageStatus = { + ready: boolean; + defaultKeyId: string | null; +}; + +type MatrixGeneratedSecretStorageKey = { + keyId?: string | null; + keyInfo?: { + passphrase?: unknown; + name?: string; + }; + privateKey: Uint8Array; + encodedPrivateKey?: string; +}; + +type MatrixCryptoBootstrapApi = { + on: (eventName: string, listener: (...args: unknown[]) => void) => void; + bootstrapCrossSigning: (opts: { setupNewCrossSigning?: boolean }) => Promise; + bootstrapSecretStorage: (opts?: { + createSecretStorageKey?: () => Promise; + setupNewSecretStorage?: boolean; + setupNewKeyBackup?: boolean; + }) => Promise; + createRecoveryKeyFromPassphrase?: (password?: string) => Promise; + getSecretStorageStatus?: () => Promise; + requestOwnUserVerification: () => Promise; + requestDeviceVerification?: ( + userId: string, + deviceId: string, + ) => Promise; + requestVerificationDM?: ( + userId: string, + roomId: string, + ) => Promise; + getDeviceVerificationStatus?: ( + userId: string, + deviceId: string, + ) => Promise; + setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise; + crossSignDevice?: (deviceId: string) => Promise; + isCrossSigningReady?: () => Promise; +}; + +type MatrixDeviceVerificationStatusLike = { + isVerified?: () => boolean; + localVerified?: boolean; + crossSigningVerified?: boolean; + signedByOwner?: boolean; +}; + +type MatrixSecretStorageKeyDescription = { + passphrase?: unknown; + name?: string; + [key: string]: unknown; +}; + +type MatrixCryptoCallbacks = { + getSecretStorageKey?: ( + params: { keys: Record }, + name: string, + ) => Promise<[string, Uint8Array] | null>; + cacheSecretStorageKey?: ( + keyId: string, + keyInfo: MatrixSecretStorageKeyDescription, + key: Uint8Array, + ) => void; +}; + +type MatrixStoredRecoveryKey = { + version: 1; + createdAt: string; + keyId?: string | null; + encodedPrivateKey?: string; + privateKeyBase64: string; + keyInfo?: { + passphrase?: unknown; + name?: string; + }; }; export class MatrixClient { @@ -192,12 +249,20 @@ export class MatrixClient { private readonly localTimeoutMs: number; private readonly initialSyncLimit?: number; private readonly encryptionEnabled: boolean; + private readonly recoveryKeyPath?: string; + private readonly idbSnapshotPath?: string; + private readonly cryptoDatabasePrefix?: string; private bridgeRegistered = false; private started = false; private selfUserId: string | null; private readonly dmRoomIds = new Set(); private cryptoInitialized = false; - private readonly decryptedMessageDedupe = new Map(); + private readonly decryptBridge: MatrixDecryptBridge; + private readonly verificationManager = new MatrixVerificationManager(); + private readonly secretStorageKeyCache = new Map< + string, + { key: Uint8Array; keyInfo?: MatrixStoredRecoveryKey["keyInfo"] } + >(); readonly dms = { update: async (): Promise => { @@ -219,6 +284,9 @@ export class MatrixClient { localTimeoutMs?: number; encryption?: boolean; initialSyncLimit?: number; + recoveryKeyPath?: string; + idbSnapshotPath?: string; + cryptoDatabasePrefix?: string; } = {}, ) { this.homeserver = homeserver; @@ -226,13 +294,37 @@ export class MatrixClient { this.localTimeoutMs = Math.max(1, opts.localTimeoutMs ?? 60_000); this.initialSyncLimit = opts.initialSyncLimit; this.encryptionEnabled = opts.encryption === true; + this.recoveryKeyPath = opts.recoveryKeyPath; + this.idbSnapshotPath = opts.idbSnapshotPath; + this.cryptoDatabasePrefix = opts.cryptoDatabasePrefix; this.selfUserId = opts.userId?.trim() || null; + const cryptoCallbacks = this.encryptionEnabled ? this.buildCryptoCallbacks() : undefined; this.client = createMatrixJsClient({ baseUrl: homeserver, accessToken, userId: opts.userId, deviceId: opts.deviceId, localTimeoutMs: this.localTimeoutMs, + cryptoCallbacks, + verificationMethods: [ + VerificationMethod.Sas, + VerificationMethod.ShowQrCode, + VerificationMethod.ScanQrCode, + VerificationMethod.Reciprocate, + ], + }); + this.decryptBridge = new MatrixDecryptBridge({ + client: this.client, + toRaw: (event) => matrixEventToRaw(event), + emitDecryptedEvent: (roomId, event) => { + this.emitter.emit("room.decrypted_event", roomId, event); + }, + emitMessage: (roomId, event) => { + this.emitter.emit("room.message", roomId, event); + }, + emitFailedDecryption: (roomId, event, error) => { + this.emitter.emit("room.failed_decryption", roomId, event, error); + }, }); if (this.encryptionEnabled) { @@ -260,6 +352,8 @@ export class MatrixClient { return this; } + private idbPersistTimer: ReturnType | null = null; + async start(): Promise { if (this.started) { return; @@ -268,9 +362,114 @@ export class MatrixClient { this.registerBridge(); if (this.encryptionEnabled && !this.cryptoInitialized) { + // Restore persisted IndexedDB crypto store before initializing WASM crypto + await restoreIdbFromDisk(this.idbSnapshotPath); + try { - await this.client.initRustCrypto(); + await this.client.initRustCrypto({ + cryptoDatabasePrefix: this.cryptoDatabasePrefix, + }); this.cryptoInitialized = true; + + // Bootstrap cross-signing and secret storage for automatic device verification + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (crypto) { + try { + // Bootstrap cross-signing (create master/user-signing/self-signing keys if needed) + await crypto.bootstrapCrossSigning({ setupNewCrossSigning: true }); + LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", err); + } + try { + // Bootstrap secret storage and ensure we have a recovery key. + await this.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); + } + + // Auto-accept incoming verification requests from other users/devices + // This allows Element to verify this device automatically without manual steps + 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; + + // Only auto-accept verifications from OTHER users (not self-verification) + // and only if we didn't initiate the request + 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}`, + ); + + // Accept the verification request (moves to Ready phase) + await verificationRequest.accept(); + + LogService.info( + "MatrixClientLite", + `Verification request from ${otherUserId} accepted, waiting for SAS...`, + ); + + // The SAS verification will complete automatically if the other side sends the accept + // We don't need to do anything else - the SDK handles the full flow + } catch (err) { + LogService.warn( + "MatrixClientLite", + `Failed to auto-accept verification from ${otherUserId}:`, + err, + ); + } + }); + + const triggerDecryptRetry = (reason: string): void => { + this.decryptBridge.retryPendingNow(reason); + }; + crypto.on(CryptoEvent.KeyBackupDecryptionKeyCached, () => { + triggerDecryptRetry("crypto.keyBackupDecryptionKeyCached"); + }); + crypto.on(CryptoEvent.RehydrationCompleted, () => { + triggerDecryptRetry("dehydration.RehydrationCompleted"); + }); + crypto.on(CryptoEvent.DevicesUpdated, () => { + triggerDecryptRetry("crypto.devicesUpdated"); + }); + crypto.on(CryptoEvent.KeysChanged, () => { + triggerDecryptRetry("crossSigning.keysChanged"); + }); + + LogService.info("MatrixClientLite", "Verification request handler registered"); + } + + // Persist the crypto store after successful init (captures fresh keys on first run) + await persistIdbToDisk({ + snapshotPath: this.idbSnapshotPath, + databasePrefix: this.cryptoDatabasePrefix, + }); + + // Periodically persist (every 60s) to capture new Olm sessions, room keys, etc. + this.idbPersistTimer = setInterval(() => { + persistIdbToDisk({ + snapshotPath: this.idbSnapshotPath, + databasePrefix: this.cryptoDatabasePrefix, + }).catch(noop); + }, 60_000); } catch (err) { LogService.warn("MatrixClientLite", "Failed to initialize rust crypto:", err); } @@ -284,10 +483,264 @@ export class MatrixClient { } stop(): void { + if (this.idbPersistTimer) { + clearInterval(this.idbPersistTimer); + this.idbPersistTimer = null; + } + this.decryptBridge.stop(); + // Final persist on shutdown + persistIdbToDisk({ + snapshotPath: this.idbSnapshotPath, + databasePrefix: this.cryptoDatabasePrefix, + }).catch(noop); this.client.stopClient(); this.started = false; } + private rememberSecretStorageKey( + keyId: string, + key: Uint8Array, + keyInfo?: MatrixStoredRecoveryKey["keyInfo"], + ): void { + if (!keyId.trim()) { + return; + } + this.secretStorageKeyCache.set(keyId, { + key: new Uint8Array(key), + keyInfo, + }); + } + + private buildCryptoCallbacks(): MatrixCryptoCallbacks { + return { + getSecretStorageKey: async ({ keys }) => { + const requestedKeyIds = Object.keys(keys ?? {}); + if (requestedKeyIds.length === 0) { + return null; + } + + for (const keyId of requestedKeyIds) { + const cached = this.secretStorageKeyCache.get(keyId); + if (cached) { + return [keyId, new Uint8Array(cached.key)]; + } + } + + const stored = this.loadStoredRecoveryKey(); + if (!stored || !stored.privateKeyBase64) { + return null; + } + const privateKey = new Uint8Array(Buffer.from(stored.privateKeyBase64, "base64")); + if (privateKey.length === 0) { + return null; + } + + if (stored.keyId && requestedKeyIds.includes(stored.keyId)) { + this.rememberSecretStorageKey(stored.keyId, privateKey, stored.keyInfo); + return [stored.keyId, privateKey]; + } + + // Fallback for older stored keys that predate keyId persistence. + const firstRequestedKeyId = requestedKeyIds[0]; + if (!firstRequestedKeyId) { + return null; + } + this.rememberSecretStorageKey(firstRequestedKeyId, privateKey, stored.keyInfo); + return [firstRequestedKeyId, privateKey]; + }, + cacheSecretStorageKey: (keyId, keyInfo, key) => { + const privateKey = new Uint8Array(key); + const normalizedKeyInfo: MatrixStoredRecoveryKey["keyInfo"] = { + passphrase: keyInfo?.passphrase, + name: typeof keyInfo?.name === "string" ? keyInfo.name : undefined, + }; + this.rememberSecretStorageKey(keyId, privateKey, normalizedKeyInfo); + + const stored = this.loadStoredRecoveryKey(); + this.saveRecoveryKeyToDisk({ + keyId, + keyInfo: normalizedKeyInfo, + privateKey, + encodedPrivateKey: stored?.encodedPrivateKey, + }); + }, + }; + } + + private loadStoredRecoveryKey(): MatrixStoredRecoveryKey | null { + if (!this.recoveryKeyPath) { + return null; + } + try { + if (!fs.existsSync(this.recoveryKeyPath)) { + return null; + } + const raw = fs.readFileSync(this.recoveryKeyPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.version !== 1 || + typeof parsed.createdAt !== "string" || + typeof parsed.privateKeyBase64 !== "string" || + !parsed.privateKeyBase64.trim() + ) { + return null; + } + return { + version: 1, + createdAt: parsed.createdAt, + keyId: typeof parsed.keyId === "string" ? parsed.keyId : null, + encodedPrivateKey: + typeof parsed.encodedPrivateKey === "string" ? parsed.encodedPrivateKey : undefined, + privateKeyBase64: parsed.privateKeyBase64, + keyInfo: + parsed.keyInfo && typeof parsed.keyInfo === "object" + ? { + passphrase: parsed.keyInfo.passphrase, + name: typeof parsed.keyInfo.name === "string" ? parsed.keyInfo.name : undefined, + } + : undefined, + }; + } catch { + return null; + } + } + + private saveRecoveryKeyToDisk(params: MatrixGeneratedSecretStorageKey): void { + if (!this.recoveryKeyPath) { + return; + } + try { + const payload: MatrixStoredRecoveryKey = { + version: 1, + createdAt: new Date().toISOString(), + keyId: typeof params.keyId === "string" ? params.keyId : null, + encodedPrivateKey: params.encodedPrivateKey, + privateKeyBase64: Buffer.from(params.privateKey).toString("base64"), + keyInfo: params.keyInfo + ? { + passphrase: params.keyInfo.passphrase, + name: params.keyInfo.name, + } + : undefined, + }; + fs.mkdirSync(path.dirname(this.recoveryKeyPath), { recursive: true }); + fs.writeFileSync(this.recoveryKeyPath, JSON.stringify(payload, null, 2), "utf8"); + fs.chmodSync(this.recoveryKeyPath, 0o600); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to persist recovery key:", err); + } + } + + private async bootstrapSecretStorageWithRecoveryKey( + crypto: MatrixCryptoBootstrapApi, + ): Promise { + let status: MatrixSecretStorageStatus | null = null; + if (typeof crypto.getSecretStorageStatus === "function") { + try { + status = await crypto.getSecretStorageStatus(); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to read secret storage status:", err); + } + } + + const hasDefaultSecretStorageKey = Boolean(status?.defaultKeyId); + let generatedRecoveryKey = false; + const storedRecovery = this.loadStoredRecoveryKey(); + let recoveryKey = storedRecovery + ? { + keyInfo: storedRecovery.keyInfo, + privateKey: new Uint8Array(Buffer.from(storedRecovery.privateKeyBase64, "base64")), + encodedPrivateKey: storedRecovery.encodedPrivateKey, + } + : null; + + if (recoveryKey && status?.defaultKeyId) { + const defaultKeyId = status.defaultKeyId; + this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo); + if (storedRecovery?.keyId !== defaultKeyId) { + this.saveRecoveryKeyToDisk({ + keyId: defaultKeyId, + keyInfo: recoveryKey.keyInfo, + privateKey: recoveryKey.privateKey, + encodedPrivateKey: recoveryKey.encodedPrivateKey, + }); + } + } + + const ensureRecoveryKey = async (): Promise => { + if (recoveryKey) { + return recoveryKey; + } + if (typeof crypto.createRecoveryKeyFromPassphrase !== "function") { + throw new Error( + "Matrix crypto backend does not support recovery key generation (createRecoveryKeyFromPassphrase missing)", + ); + } + recoveryKey = await crypto.createRecoveryKeyFromPassphrase(); + this.saveRecoveryKeyToDisk(recoveryKey); + generatedRecoveryKey = true; + return recoveryKey; + }; + + const secretStorageOptions: { + createSecretStorageKey?: () => Promise; + setupNewSecretStorage?: boolean; + setupNewKeyBackup?: boolean; + } = { + setupNewKeyBackup: false, + }; + + if (!hasDefaultSecretStorageKey) { + secretStorageOptions.setupNewSecretStorage = true; + secretStorageOptions.createSecretStorageKey = ensureRecoveryKey; + } + + await crypto.bootstrapSecretStorage(secretStorageOptions); + + if (generatedRecoveryKey && this.recoveryKeyPath) { + LogService.warn( + "MatrixClientLite", + `Generated Matrix recovery key and saved it to ${this.recoveryKeyPath}. Keep this file secure.`, + ); + } + } + + 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) { @@ -483,7 +936,7 @@ export class MatrixClient { if (isEncryptedEvent) { this.emitter.emit("room.encrypted_event", roomId, raw); } else { - if (!this.isDuplicateDecryptedMessage(roomId, raw.event_id)) { + if (this.decryptBridge.shouldEmitUnencryptedMessage(roomId, raw.event_id)) { this.emitter.emit("room.message", roomId, raw); } } @@ -503,73 +956,11 @@ export class MatrixClient { } if (isEncryptedEvent) { - event.on(MatrixEventEvent.Decrypted, (decryptedEvent: MatrixEvent, err?: Error) => { - const decryptedRoomId = decryptedEvent.getRoomId() || roomId; - const decryptedRaw = matrixEventToRaw(decryptedEvent); - if (err) { - this.emitter.emit("room.failed_decryption", decryptedRoomId, decryptedRaw, err); - return; - } - const failed = - typeof (decryptedEvent as { isDecryptionFailure?: () => boolean }) - .isDecryptionFailure === "function" && - (decryptedEvent as { isDecryptionFailure: () => boolean }).isDecryptionFailure(); - if (failed) { - this.emitter.emit( - "room.failed_decryption", - decryptedRoomId, - decryptedRaw, - new Error("Matrix event failed to decrypt"), - ); - return; - } - this.emitter.emit("room.decrypted_event", decryptedRoomId, decryptedRaw); - this.rememberDecryptedMessage(decryptedRoomId, decryptedRaw.event_id); - this.emitter.emit("room.message", decryptedRoomId, decryptedRaw); - }); + this.decryptBridge.attachEncryptedEvent(event, roomId); } }); } - private rememberDecryptedMessage(roomId: string, eventId: string): void { - if (!eventId) { - return; - } - const now = Date.now(); - this.pruneDecryptedMessageDedupe(now); - this.decryptedMessageDedupe.set(`${roomId}|${eventId}`, now); - } - - private isDuplicateDecryptedMessage(roomId: string, eventId: string): boolean { - if (!eventId) { - return false; - } - const key = `${roomId}|${eventId}`; - const createdAt = this.decryptedMessageDedupe.get(key); - if (createdAt === undefined) { - return false; - } - this.decryptedMessageDedupe.delete(key); - return true; - } - - private pruneDecryptedMessageDedupe(now: number): void { - const ttlMs = 30_000; - for (const [key, createdAt] of this.decryptedMessageDedupe) { - if (now - createdAt > ttlMs) { - this.decryptedMessageDedupe.delete(key); - } - } - const maxEntries = 2048; - while (this.decryptedMessageDedupe.size > maxEntries) { - const oldest = this.decryptedMessageDedupe.keys().next().value; - if (oldest === undefined) { - break; - } - this.decryptedMessageDedupe.delete(oldest); - } - } - private createCryptoFacade(): MatrixCryptoFacade { return { prepare: async (_joinedRooms: string[]) => { @@ -597,11 +988,8 @@ export class MatrixClient { } }, requestOwnUserVerification: async (): Promise => { - const crypto = this.client.getCrypto(); - if (!crypto) { - return null; - } - return await crypto.requestOwnUserVerification(); + const crypto = this.client.getCrypto() as MatrixVerificationCryptoApi | undefined; + return await this.verificationManager.requestOwnUserVerification(crypto); }, encryptMedia: async ( buffer: Buffer, @@ -638,6 +1026,51 @@ export class MatrixClient { const decrypted = Attachment.decrypt(attachment); return Buffer.from(decrypted); }, + getRecoveryKey: async () => { + const stored = this.loadStoredRecoveryKey(); + if (!stored) { + return null; + } + return { + encodedPrivateKey: stored.encodedPrivateKey, + keyId: stored.keyId, + createdAt: stored.createdAt, + }; + }, + 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); + }, }; } @@ -666,7 +1099,9 @@ export class MatrixClient { body?: unknown; timeoutMs: number; }): Promise { - const { response, text } = await this.performRequest({ + const { response, text } = await performMatrixRequest({ + homeserver: this.homeserver, + accessToken: this.accessToken, method: params.method, endpoint: params.endpoint, qs: params.qs, @@ -692,7 +1127,9 @@ export class MatrixClient { qs?: QueryParams; timeoutMs: number; }): Promise { - const { response, buffer } = await this.performRequest({ + const { response, buffer } = await performMatrixRequest({ + homeserver: this.homeserver, + accessToken: this.accessToken, method: params.method, endpoint: params.endpoint, qs: params.qs, @@ -704,68 +1141,6 @@ export class MatrixClient { } return buffer; } - - private async performRequest(params: { - method: HttpMethod; - endpoint: string; - qs?: QueryParams; - body?: unknown; - timeoutMs: number; - raw?: 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), this.homeserver); - applyQuery(baseUrl, params.qs); - - const headers = new Headers(); - headers.set("Accept", params.raw ? "*/*" : "application/json"); - if (this.accessToken) { - headers.set("Authorization", `Bearer ${this.accessToken}`); - } - - let body: BodyInit | undefined; - if (params.body !== undefined) { - if ( - params.body instanceof Uint8Array || - params.body instanceof ArrayBuffer || - typeof params.body === "string" - ) { - body = params.body as BodyInit; - } else { - headers.set("Content-Type", "application/json"); - body = JSON.stringify(params.body); - } - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs); - try { - const response = await fetchWithSafeRedirects(baseUrl, { - method: params.method, - headers, - body, - signal: controller.signal, - }); - if (params.raw) { - const bytes = Buffer.from(await response.arrayBuffer()); - return { - response, - text: bytes.toString("utf8"), - buffer: bytes, - }; - } - const text = await response.text(); - return { - response, - text, - buffer: Buffer.from(text, "utf8"), - }; - } finally { - clearTimeout(timeoutId); - } - } } function matrixEventToRaw(event: MatrixEvent): MatrixRawEvent { @@ -806,34 +1181,6 @@ function resolveMatrixStateKey(event: MatrixEvent): string | undefined { return undefined; } -function normalizeEndpoint(endpoint: string): string { - if (!endpoint) { - return "/"; - } - return endpoint.startsWith("/") ? endpoint : `/${endpoint}`; -} - -function applyQuery(url: URL, qs: QueryParams): void { - if (!qs) { - return; - } - for (const [key, rawValue] of Object.entries(qs)) { - if (rawValue === undefined || rawValue === null) { - continue; - } - if (Array.isArray(rawValue)) { - for (const item of rawValue) { - if (item === undefined || item === null) { - continue; - } - url.searchParams.append(key, String(item)); - } - continue; - } - url.searchParams.set(key, String(rawValue)); - } -} - function parseMxc(url: string): { server: string; mediaId: string } | null { const match = /^mxc:\/\/([^/]+)\/(.+)$/.exec(url.trim()); if (!match) { @@ -861,63 +1208,3 @@ function buildHttpError(statusCode: number, bodyText: string): Error & { statusC } return Object.assign(new Error(message), { statusCode }); } - -function isRedirectStatus(statusCode: number): boolean { - return statusCode >= 300 && statusCode < 400; -} - -async function fetchWithSafeRedirects(url: URL, init: RequestInit): Promise { - let currentUrl = new URL(url.toString()); - let method = (init.method ?? "GET").toUpperCase(); - let body = init.body; - let headers = new Headers(init.headers ?? {}); - const maxRedirects = 5; - - for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) { - const response = await fetch(currentUrl, { - ...init, - method, - body, - headers, - redirect: "manual", - }); - - if (!isRedirectStatus(response.status)) { - return response; - } - - const location = response.headers.get("location"); - if (!location) { - throw new Error(`Matrix redirect missing location header (${currentUrl.toString()})`); - } - - const nextUrl = new URL(location, currentUrl); - if (nextUrl.protocol !== currentUrl.protocol) { - throw new Error( - `Blocked cross-protocol redirect (${currentUrl.protocol} -> ${nextUrl.protocol})`, - ); - } - - if (nextUrl.origin !== currentUrl.origin) { - headers = new Headers(headers); - headers.delete("authorization"); - } - - if ( - response.status === 303 || - ((response.status === 301 || response.status === 302) && - method !== "GET" && - method !== "HEAD") - ) { - method = "GET"; - body = undefined; - headers = new Headers(headers); - headers.delete("content-type"); - headers.delete("content-length"); - } - - currentUrl = nextUrl; - } - - throw new Error(`Too many redirects while requesting ${url.toString()}`); -} diff --git a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts new file mode 100644 index 00000000000..14917df5817 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts @@ -0,0 +1,277 @@ +import { MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk"; +import { LogService, noop } from "./logger.js"; + +type MatrixDecryptIfNeededClient = { + decryptEventIfNeeded?: ( + event: MatrixEvent, + opts?: { + isRetry?: boolean; + }, + ) => Promise; +}; + +type MatrixDecryptRetryState = { + event: MatrixEvent; + roomId: string; + eventId: string; + attempts: number; + inFlight: boolean; + timer: ReturnType | null; +}; + +type DecryptBridgeRawEvent = { + event_id: string; +}; + +const MATRIX_DECRYPT_RETRY_BASE_DELAY_MS = 1_500; +const MATRIX_DECRYPT_RETRY_MAX_DELAY_MS = 30_000; +const MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS = 8; + +function resolveDecryptRetryKey(roomId: string, eventId: string): string | null { + if (!roomId || !eventId) { + return null; + } + return `${roomId}|${eventId}`; +} + +function isDecryptionFailure(event: MatrixEvent): boolean { + return ( + typeof (event as { isDecryptionFailure?: () => boolean }).isDecryptionFailure === "function" && + (event as { isDecryptionFailure: () => boolean }).isDecryptionFailure() + ); +} + +export class MatrixDecryptBridge { + private readonly trackedEncryptedEvents = new WeakSet(); + private readonly decryptedMessageDedupe = new Map(); + private readonly decryptRetries = new Map(); + private readonly failedDecryptionsNotified = new Set(); + + constructor( + private readonly deps: { + client: MatrixDecryptIfNeededClient; + toRaw: (event: MatrixEvent) => TRawEvent; + emitDecryptedEvent: (roomId: string, event: TRawEvent) => void; + emitMessage: (roomId: string, event: TRawEvent) => void; + emitFailedDecryption: (roomId: string, event: TRawEvent, error: Error) => void; + }, + ) {} + + shouldEmitUnencryptedMessage(roomId: string, eventId: string): boolean { + if (!eventId) { + return true; + } + const key = `${roomId}|${eventId}`; + const createdAt = this.decryptedMessageDedupe.get(key); + if (createdAt === undefined) { + return true; + } + this.decryptedMessageDedupe.delete(key); + return false; + } + + attachEncryptedEvent(event: MatrixEvent, roomId: string): void { + if (this.trackedEncryptedEvents.has(event)) { + return; + } + this.trackedEncryptedEvents.add(event); + event.on(MatrixEventEvent.Decrypted, (decryptedEvent: MatrixEvent, err?: Error) => { + this.handleEncryptedEventDecrypted({ + roomId, + encryptedEvent: event, + decryptedEvent, + err, + }); + }); + } + + retryPendingNow(reason: string): void { + const pending = Array.from(this.decryptRetries.entries()); + if (pending.length === 0) { + return; + } + LogService.debug("MatrixClientLite", `Retrying pending decryptions due to ${reason}`); + for (const [retryKey, state] of pending) { + if (state.timer) { + clearTimeout(state.timer); + state.timer = null; + } + if (state.inFlight) { + continue; + } + this.runDecryptRetry(retryKey).catch(noop); + } + } + + stop(): void { + for (const retryKey of this.decryptRetries.keys()) { + this.clearDecryptRetry(retryKey); + } + } + + private handleEncryptedEventDecrypted(params: { + roomId: string; + encryptedEvent: MatrixEvent; + decryptedEvent: MatrixEvent; + err?: Error; + }): void { + const decryptedRoomId = params.decryptedEvent.getRoomId() || params.roomId; + const decryptedRaw = this.deps.toRaw(params.decryptedEvent); + const retryEventId = decryptedRaw.event_id || params.encryptedEvent.getId() || ""; + const retryKey = resolveDecryptRetryKey(decryptedRoomId, retryEventId); + + if (params.err) { + this.emitFailedDecryptionOnce(retryKey, decryptedRoomId, decryptedRaw, params.err); + this.scheduleDecryptRetry({ + event: params.encryptedEvent, + roomId: decryptedRoomId, + eventId: retryEventId, + }); + return; + } + + if (isDecryptionFailure(params.decryptedEvent)) { + this.emitFailedDecryptionOnce( + retryKey, + decryptedRoomId, + decryptedRaw, + new Error("Matrix event failed to decrypt"), + ); + this.scheduleDecryptRetry({ + event: params.encryptedEvent, + roomId: decryptedRoomId, + eventId: retryEventId, + }); + return; + } + + if (retryKey) { + this.clearDecryptRetry(retryKey); + } + this.rememberDecryptedMessage(decryptedRoomId, decryptedRaw.event_id); + this.deps.emitDecryptedEvent(decryptedRoomId, decryptedRaw); + this.deps.emitMessage(decryptedRoomId, decryptedRaw); + } + + private emitFailedDecryptionOnce( + retryKey: string | null, + roomId: string, + event: TRawEvent, + error: Error, + ): void { + if (retryKey) { + if (this.failedDecryptionsNotified.has(retryKey)) { + return; + } + this.failedDecryptionsNotified.add(retryKey); + } + this.deps.emitFailedDecryption(roomId, event, error); + } + + private scheduleDecryptRetry(params: { + event: MatrixEvent; + roomId: string; + eventId: string; + }): void { + const retryKey = resolveDecryptRetryKey(params.roomId, params.eventId); + if (!retryKey) { + return; + } + const existing = this.decryptRetries.get(retryKey); + if (existing?.timer || existing?.inFlight) { + return; + } + const attempts = (existing?.attempts ?? 0) + 1; + if (attempts > MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS) { + this.clearDecryptRetry(retryKey); + LogService.debug( + "MatrixClientLite", + `Giving up decryption retry for ${params.eventId} in ${params.roomId} after ${attempts - 1} attempts`, + ); + return; + } + const delayMs = Math.min( + MATRIX_DECRYPT_RETRY_BASE_DELAY_MS * 2 ** (attempts - 1), + MATRIX_DECRYPT_RETRY_MAX_DELAY_MS, + ); + const next: MatrixDecryptRetryState = { + event: params.event, + roomId: params.roomId, + eventId: params.eventId, + attempts, + inFlight: false, + timer: null, + }; + next.timer = setTimeout(() => { + this.runDecryptRetry(retryKey).catch(noop); + }, delayMs); + this.decryptRetries.set(retryKey, next); + } + + private async runDecryptRetry(retryKey: string): Promise { + const state = this.decryptRetries.get(retryKey); + if (!state || state.inFlight) { + return; + } + + state.inFlight = true; + state.timer = null; + const canDecrypt = typeof this.deps.client.decryptEventIfNeeded === "function"; + if (!canDecrypt) { + this.clearDecryptRetry(retryKey); + return; + } + + try { + await this.deps.client.decryptEventIfNeeded?.(state.event, { + isRetry: true, + }); + } catch { + // Retry with backoff until we hit the configured retry cap. + } finally { + state.inFlight = false; + } + + if (isDecryptionFailure(state.event)) { + this.scheduleDecryptRetry(state); + return; + } + + this.clearDecryptRetry(retryKey); + } + + private clearDecryptRetry(retryKey: string): void { + const state = this.decryptRetries.get(retryKey); + if (state?.timer) { + clearTimeout(state.timer); + } + this.decryptRetries.delete(retryKey); + this.failedDecryptionsNotified.delete(retryKey); + } + + private rememberDecryptedMessage(roomId: string, eventId: string): void { + if (!eventId) { + return; + } + const now = Date.now(); + this.pruneDecryptedMessageDedupe(now); + this.decryptedMessageDedupe.set(`${roomId}|${eventId}`, now); + } + + private pruneDecryptedMessageDedupe(now: number): void { + const ttlMs = 30_000; + for (const [key, createdAt] of this.decryptedMessageDedupe) { + if (now - createdAt > ttlMs) { + this.decryptedMessageDedupe.delete(key); + } + } + const maxEntries = 2048; + while (this.decryptedMessageDedupe.size > maxEntries) { + const oldest = this.decryptedMessageDedupe.keys().next().value; + if (oldest === undefined) { + break; + } + this.decryptedMessageDedupe.delete(oldest); + } + } +} diff --git a/extensions/matrix/src/matrix/sdk/idb-persistence.ts b/extensions/matrix/src/matrix/sdk/idb-persistence.ts new file mode 100644 index 00000000000..c9a09b6030f --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/idb-persistence.ts @@ -0,0 +1,164 @@ +import { indexedDB as fakeIndexedDB } from "fake-indexeddb"; +import fs from "node:fs"; +import path from "node:path"; +import { LogService } from "./logger.js"; + +type IdbStoreSnapshot = { + name: string; + keyPath: IDBObjectStoreParameters["keyPath"]; + autoIncrement: boolean; + indexes: { name: string; keyPath: string | string[]; multiEntry: boolean; unique: boolean }[]; + records: { key: IDBValidKey; value: unknown }[]; +}; + +type IdbDatabaseSnapshot = { + name: string; + version: number; + stores: IdbStoreSnapshot[]; +}; + +function idbReq(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function dumpIndexedDatabases(databasePrefix?: string): Promise { + const idb = fakeIndexedDB; + const dbList = await idb.databases(); + const snapshot: IdbDatabaseSnapshot[] = []; + const expectedPrefix = databasePrefix ? `${databasePrefix}::` : null; + + for (const { name, version } of dbList) { + if (!name || !version) continue; + if (expectedPrefix && !name.startsWith(expectedPrefix)) continue; + const db: IDBDatabase = await new Promise((resolve, reject) => { + const r = idb.open(name, version); + r.onsuccess = () => resolve(r.result); + r.onerror = () => reject(r.error); + }); + + const stores: IdbStoreSnapshot[] = []; + for (const storeName of db.objectStoreNames) { + const tx = db.transaction(storeName, "readonly"); + const store = tx.objectStore(storeName); + const storeInfo: IdbStoreSnapshot = { + name: storeName, + keyPath: store.keyPath as IDBObjectStoreParameters["keyPath"], + autoIncrement: store.autoIncrement, + indexes: [], + records: [], + }; + for (const idxName of store.indexNames) { + const idx = store.index(idxName); + storeInfo.indexes.push({ + name: idxName, + keyPath: idx.keyPath as string | string[], + multiEntry: idx.multiEntry, + unique: idx.unique, + }); + } + const keys = await idbReq(store.getAllKeys()); + const values = await idbReq(store.getAll()); + storeInfo.records = keys.map((k, i) => ({ key: k, value: values[i] })); + stores.push(storeInfo); + } + snapshot.push({ name, version, stores }); + db.close(); + } + return snapshot; +} + +async function restoreIndexedDatabases(snapshot: IdbDatabaseSnapshot[]): Promise { + const idb = fakeIndexedDB; + for (const dbSnap of snapshot) { + await new Promise((resolve, reject) => { + const r = idb.open(dbSnap.name, dbSnap.version); + r.onupgradeneeded = () => { + const db = r.result; + for (const storeSnap of dbSnap.stores) { + const opts: IDBObjectStoreParameters = {}; + if (storeSnap.keyPath !== null) opts.keyPath = storeSnap.keyPath; + if (storeSnap.autoIncrement) opts.autoIncrement = true; + const store = db.createObjectStore(storeSnap.name, opts); + for (const idx of storeSnap.indexes) { + store.createIndex(idx.name, idx.keyPath, { + unique: idx.unique, + multiEntry: idx.multiEntry, + }); + } + } + }; + r.onsuccess = async () => { + try { + const db = r.result; + for (const storeSnap of dbSnap.stores) { + if (storeSnap.records.length === 0) continue; + const tx = db.transaction(storeSnap.name, "readwrite"); + const store = tx.objectStore(storeSnap.name); + for (const rec of storeSnap.records) { + if (storeSnap.keyPath !== null) { + store.put(rec.value); + } else { + store.put(rec.value, rec.key); + } + } + await new Promise((res) => { + tx.oncomplete = () => res(); + }); + } + db.close(); + resolve(); + } catch (err) { + reject(err); + } + }; + r.onerror = () => reject(r.error); + }); + } +} + +function resolveDefaultIdbSnapshotPath(): string { + const stateDir = + process.env.OPENCLAW_STATE_DIR || + process.env.MOLTBOT_STATE_DIR || + path.join(process.env.HOME || "/tmp", ".openclaw"); + return path.join(stateDir, "credentials", "matrix", "crypto-idb-snapshot.json"); +} + +export async function restoreIdbFromDisk(snapshotPath?: string): Promise { + const resolvedPath = snapshotPath ?? resolveDefaultIdbSnapshotPath(); + try { + const data = fs.readFileSync(resolvedPath, "utf8"); + const snapshot: IdbDatabaseSnapshot[] = JSON.parse(data); + if (!Array.isArray(snapshot) || snapshot.length === 0) return false; + await restoreIndexedDatabases(snapshot); + LogService.info( + "IdbPersistence", + `Restored ${snapshot.length} IndexedDB database(s) from ${resolvedPath}`, + ); + return true; + } catch { + return false; + } +} + +export async function persistIdbToDisk(params?: { + snapshotPath?: string; + databasePrefix?: string; +}): Promise { + const snapshotPath = params?.snapshotPath ?? resolveDefaultIdbSnapshotPath(); + try { + const snapshot = await dumpIndexedDatabases(params?.databasePrefix); + if (snapshot.length === 0) return; + fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); + fs.writeFileSync(snapshotPath, JSON.stringify(snapshot)); + LogService.debug( + "IdbPersistence", + `Persisted ${snapshot.length} IndexedDB database(s) to ${snapshotPath}`, + ); + } catch (err) { + LogService.warn("IdbPersistence", "Failed to persist IndexedDB snapshot:", err); + } +} diff --git a/extensions/matrix/src/matrix/sdk/logger.ts b/extensions/matrix/src/matrix/sdk/logger.ts new file mode 100644 index 00000000000..866f959e912 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/logger.ts @@ -0,0 +1,57 @@ +export type Logger = { + trace: (module: string, ...messageOrObject: unknown[]) => void; + debug: (module: string, ...messageOrObject: unknown[]) => void; + info: (module: string, ...messageOrObject: unknown[]) => void; + warn: (module: string, ...messageOrObject: unknown[]) => void; + error: (module: string, ...messageOrObject: unknown[]) => void; +}; + +export function noop(): void { + // no-op +} + +export class ConsoleLogger { + trace(module: string, ...messageOrObject: unknown[]): void { + console.debug(`[${module}]`, ...messageOrObject); + } + + debug(module: string, ...messageOrObject: unknown[]): void { + console.debug(`[${module}]`, ...messageOrObject); + } + + info(module: string, ...messageOrObject: unknown[]): void { + console.info(`[${module}]`, ...messageOrObject); + } + + warn(module: string, ...messageOrObject: unknown[]): void { + console.warn(`[${module}]`, ...messageOrObject); + } + + error(module: string, ...messageOrObject: unknown[]): void { + console.error(`[${module}]`, ...messageOrObject); + } +} + +const defaultLogger = new ConsoleLogger(); +let activeLogger: Logger = defaultLogger; + +export const LogService = { + setLogger(logger: Logger): void { + activeLogger = logger; + }, + trace(module: string, ...messageOrObject: unknown[]): void { + activeLogger.trace(module, ...messageOrObject); + }, + debug(module: string, ...messageOrObject: unknown[]): void { + activeLogger.debug(module, ...messageOrObject); + }, + info(module: string, ...messageOrObject: unknown[]): void { + activeLogger.info(module, ...messageOrObject); + }, + warn(module: string, ...messageOrObject: unknown[]): void { + activeLogger.warn(module, ...messageOrObject); + }, + error(module: string, ...messageOrObject: unknown[]): void { + activeLogger.error(module, ...messageOrObject); + }, +}; diff --git a/extensions/matrix/src/matrix/sdk/transport.ts b/extensions/matrix/src/matrix/sdk/transport.ts new file mode 100644 index 00000000000..5b793e085e2 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/transport.ts @@ -0,0 +1,163 @@ +export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; + +type QueryValue = + | string + | number + | boolean + | null + | undefined + | Array; + +export type QueryParams = Record | null | undefined; + +function normalizeEndpoint(endpoint: string): string { + if (!endpoint) { + return "/"; + } + return endpoint.startsWith("/") ? endpoint : `/${endpoint}`; +} + +function applyQuery(url: URL, qs: QueryParams): void { + if (!qs) { + return; + } + for (const [key, rawValue] of Object.entries(qs)) { + if (rawValue === undefined || rawValue === null) { + continue; + } + if (Array.isArray(rawValue)) { + for (const item of rawValue) { + if (item === undefined || item === null) { + continue; + } + url.searchParams.append(key, String(item)); + } + continue; + } + url.searchParams.set(key, String(rawValue)); + } +} + +function isRedirectStatus(statusCode: number): boolean { + return statusCode >= 300 && statusCode < 400; +} + +async function fetchWithSafeRedirects(url: URL, init: RequestInit): Promise { + let currentUrl = new URL(url.toString()); + let method = (init.method ?? "GET").toUpperCase(); + let body = init.body; + let headers = new Headers(init.headers ?? {}); + const maxRedirects = 5; + + for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) { + const response = await fetch(currentUrl, { + ...init, + method, + body, + headers, + redirect: "manual", + }); + + if (!isRedirectStatus(response.status)) { + return response; + } + + const location = response.headers.get("location"); + if (!location) { + throw new Error(`Matrix redirect missing location header (${currentUrl.toString()})`); + } + + const nextUrl = new URL(location, currentUrl); + if (nextUrl.protocol !== currentUrl.protocol) { + throw new Error( + `Blocked cross-protocol redirect (${currentUrl.protocol} -> ${nextUrl.protocol})`, + ); + } + + if (nextUrl.origin !== currentUrl.origin) { + headers = new Headers(headers); + headers.delete("authorization"); + } + + if ( + response.status === 303 || + ((response.status === 301 || response.status === 302) && + method !== "GET" && + method !== "HEAD") + ) { + method = "GET"; + body = undefined; + headers = new Headers(headers); + headers.delete("content-type"); + headers.delete("content-length"); + } + + currentUrl = nextUrl; + } + + throw new Error(`Too many redirects while requesting ${url.toString()}`); +} + +export async function performMatrixRequest(params: { + homeserver: string; + accessToken: string; + method: HttpMethod; + endpoint: string; + qs?: QueryParams; + body?: unknown; + timeoutMs: number; + raw?: 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); + applyQuery(baseUrl, params.qs); + + const headers = new Headers(); + headers.set("Accept", params.raw ? "*/*" : "application/json"); + if (params.accessToken) { + headers.set("Authorization", `Bearer ${params.accessToken}`); + } + + let body: BodyInit | undefined; + if (params.body !== undefined) { + if ( + params.body instanceof Uint8Array || + params.body instanceof ArrayBuffer || + typeof params.body === "string" + ) { + body = params.body as BodyInit; + } else { + headers.set("Content-Type", "application/json"); + body = JSON.stringify(params.body); + } + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs); + try { + const response = await fetchWithSafeRedirects(baseUrl, { + method: params.method, + headers, + body, + signal: controller.signal, + }); + if (params.raw) { + const bytes = Buffer.from(await response.arrayBuffer()); + return { + response, + text: bytes.toString("utf8"), + buffer: bytes, + }; + } + const text = await response.text(); + return { + response, + text, + buffer: Buffer.from(text, "utf8"), + }; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.ts b/extensions/matrix/src/matrix/sdk/verification-manager.ts new file mode 100644 index 00000000000..2527a31182b --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/verification-manager.ts @@ -0,0 +1,434 @@ +import { + VerificationPhase, + VerificationRequestEvent, + VerifierEvent, +} from "matrix-js-sdk/src/crypto-api/verification.ts"; +import { VerificationMethod } from "matrix-js-sdk/src/types.ts"; + +export type MatrixVerificationMethod = "sas" | "show-qr" | "scan-qr"; + +export type MatrixVerificationSummary = { + id: string; + transactionId?: string; + roomId?: string; + otherUserId: string; + otherDeviceId?: string; + isSelfVerification: boolean; + initiatedByMe: boolean; + phase: number; + phaseName: string; + pending: boolean; + methods: string[]; + chosenMethod?: string | null; + canAccept: boolean; + hasSas: boolean; + hasReciprocateQr: boolean; + completed: boolean; + error?: string; + createdAt: string; + updatedAt: string; +}; + +export type MatrixShowSasCallbacks = { + sas: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + }; + confirm: () => Promise; + mismatch: () => void; + cancel: () => void; +}; + +export type MatrixShowQrCodeCallbacks = { + confirm: () => void; + cancel: () => void; +}; + +export type MatrixVerifierLike = { + verify: () => Promise; + cancel: (e: Error) => void; + getShowSasCallbacks: () => MatrixShowSasCallbacks | null; + getReciprocateQrCodeCallbacks: () => MatrixShowQrCodeCallbacks | null; + on: (eventName: string, listener: (...args: unknown[]) => void) => void; +}; + +export type MatrixVerificationRequestLike = { + transactionId?: string; + roomId?: string; + initiatedByMe: boolean; + otherUserId: string; + otherDeviceId?: string; + isSelfVerification: boolean; + phase: number; + pending: boolean; + accepting: boolean; + declining: boolean; + methods: string[]; + chosenMethod?: string | null; + cancellationCode?: string | null; + accept: () => Promise; + cancel: (params?: { reason?: string; code?: string }) => Promise; + startVerification: (method: string) => Promise; + scanQRCode: (qrCodeData: Uint8ClampedArray) => Promise; + generateQRCode: () => Promise; + verifier?: MatrixVerifierLike; + on: (eventName: string, listener: (...args: unknown[]) => void) => void; +}; + +export type MatrixVerificationCryptoApi = { + requestOwnUserVerification: () => Promise; + requestDeviceVerification?: ( + userId: string, + deviceId: string, + ) => Promise; + requestVerificationDM?: ( + userId: string, + roomId: string, + ) => Promise; +}; + +type MatrixVerificationSession = { + id: string; + request: MatrixVerificationRequestLike; + createdAtMs: number; + updatedAtMs: number; + error?: string; + activeVerifier?: MatrixVerifierLike; + verifyPromise?: Promise; + verifyStarted: boolean; + sasCallbacks?: MatrixShowSasCallbacks; + reciprocateQrCallbacks?: MatrixShowQrCodeCallbacks; +}; + +export class MatrixVerificationManager { + private readonly verificationSessions = new Map(); + private verificationSessionCounter = 0; + private readonly trackedVerificationRequests = new WeakSet(); + private readonly trackedVerificationVerifiers = new WeakSet(); + + private getVerificationPhaseName(phase: number): string { + switch (phase) { + case VerificationPhase.Unsent: + return "unsent"; + case VerificationPhase.Requested: + return "requested"; + case VerificationPhase.Ready: + return "ready"; + case VerificationPhase.Started: + return "started"; + case VerificationPhase.Cancelled: + return "cancelled"; + case VerificationPhase.Done: + return "done"; + default: + return `unknown(${phase})`; + } + } + + private touchVerificationSession(session: MatrixVerificationSession): void { + session.updatedAtMs = Date.now(); + } + + private buildVerificationSummary(session: MatrixVerificationSession): MatrixVerificationSummary { + const request = session.request; + const phase = request.phase; + const canAccept = phase < VerificationPhase.Ready && !request.accepting && !request.declining; + return { + id: session.id, + transactionId: request.transactionId, + roomId: request.roomId, + otherUserId: request.otherUserId, + otherDeviceId: request.otherDeviceId, + isSelfVerification: request.isSelfVerification, + initiatedByMe: request.initiatedByMe, + phase, + phaseName: this.getVerificationPhaseName(phase), + pending: request.pending, + methods: Array.isArray(request.methods) ? request.methods : [], + chosenMethod: request.chosenMethod ?? null, + canAccept, + hasSas: Boolean(session.sasCallbacks), + hasReciprocateQr: Boolean(session.reciprocateQrCallbacks), + completed: phase === VerificationPhase.Done, + error: session.error, + createdAt: new Date(session.createdAtMs).toISOString(), + updatedAt: new Date(session.updatedAtMs).toISOString(), + }; + } + + private findVerificationSession(id: string): MatrixVerificationSession { + const direct = this.verificationSessions.get(id); + if (direct) { + return direct; + } + for (const session of this.verificationSessions.values()) { + if (session.request.transactionId === id) { + return session; + } + } + throw new Error(`Matrix verification request not found: ${id}`); + } + + private ensureVerificationRequestTracked(session: MatrixVerificationSession): void { + const requestObj = session.request as unknown as object; + if (this.trackedVerificationRequests.has(requestObj)) { + return; + } + this.trackedVerificationRequests.add(requestObj); + session.request.on(VerificationRequestEvent.Change, () => { + this.touchVerificationSession(session); + if (session.request.verifier) { + this.attachVerifierToVerificationSession(session, session.request.verifier); + } + }); + } + + private attachVerifierToVerificationSession( + session: MatrixVerificationSession, + verifier: MatrixVerifierLike, + ): void { + session.activeVerifier = verifier; + this.touchVerificationSession(session); + + const maybeSas = verifier.getShowSasCallbacks(); + if (maybeSas) { + session.sasCallbacks = maybeSas; + } + const maybeReciprocateQr = verifier.getReciprocateQrCodeCallbacks(); + if (maybeReciprocateQr) { + session.reciprocateQrCallbacks = maybeReciprocateQr; + } + + const verifierObj = verifier as unknown as object; + if (this.trackedVerificationVerifiers.has(verifierObj)) { + return; + } + this.trackedVerificationVerifiers.add(verifierObj); + + verifier.on(VerifierEvent.ShowSas, (sas) => { + session.sasCallbacks = sas as MatrixShowSasCallbacks; + this.touchVerificationSession(session); + }); + verifier.on(VerifierEvent.ShowReciprocateQr, (qr) => { + session.reciprocateQrCallbacks = qr as MatrixShowQrCodeCallbacks; + this.touchVerificationSession(session); + }); + verifier.on(VerifierEvent.Cancel, (err) => { + session.error = err instanceof Error ? err.message : String(err); + this.touchVerificationSession(session); + }); + } + + private ensureVerificationStarted(session: MatrixVerificationSession): void { + if (!session.activeVerifier || session.verifyStarted) { + return; + } + session.verifyStarted = true; + const verifier = session.activeVerifier; + session.verifyPromise = verifier + .verify() + .then(() => { + this.touchVerificationSession(session); + }) + .catch((err) => { + session.error = err instanceof Error ? err.message : String(err); + this.touchVerificationSession(session); + }); + } + + trackVerificationRequest(request: MatrixVerificationRequestLike): MatrixVerificationSummary { + const txId = request.transactionId?.trim(); + if (txId) { + for (const existing of this.verificationSessions.values()) { + if (existing.request.transactionId === txId) { + existing.request = request; + this.ensureVerificationRequestTracked(existing); + if (request.verifier) { + this.attachVerifierToVerificationSession(existing, request.verifier); + } + this.touchVerificationSession(existing); + return this.buildVerificationSummary(existing); + } + } + } + + const now = Date.now(); + const id = `verification-${++this.verificationSessionCounter}`; + const session: MatrixVerificationSession = { + id, + request, + createdAtMs: now, + updatedAtMs: now, + verifyStarted: false, + }; + this.verificationSessions.set(session.id, session); + this.ensureVerificationRequestTracked(session); + if (request.verifier) { + this.attachVerifierToVerificationSession(session, request.verifier); + } + return this.buildVerificationSummary(session); + } + + async requestOwnUserVerification( + crypto: MatrixVerificationCryptoApi | undefined, + ): Promise { + if (!crypto) { + return null; + } + const request = + (await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null; + if (!request) { + return null; + } + return this.trackVerificationRequest(request); + } + + listVerifications(): MatrixVerificationSummary[] { + const summaries = Array.from(this.verificationSessions.values()).map((session) => + this.buildVerificationSummary(session), + ); + return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } + + async requestVerification( + crypto: MatrixVerificationCryptoApi | undefined, + params: { + ownUser?: boolean; + userId?: string; + deviceId?: string; + roomId?: string; + }, + ): Promise { + if (!crypto) { + throw new Error("Matrix crypto is not available"); + } + let request: MatrixVerificationRequestLike | null = null; + if (params.ownUser) { + request = (await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null; + } else if (params.userId && params.deviceId && crypto.requestDeviceVerification) { + request = await crypto.requestDeviceVerification(params.userId, params.deviceId); + } else if (params.userId && params.roomId && crypto.requestVerificationDM) { + request = await crypto.requestVerificationDM(params.userId, params.roomId); + } else { + throw new Error( + "Matrix verification request requires one of: ownUser, userId+deviceId, or userId+roomId", + ); + } + + if (!request) { + throw new Error("Matrix verification request could not be created"); + } + return this.trackVerificationRequest(request); + } + + async acceptVerification(id: string): Promise { + const session = this.findVerificationSession(id); + await session.request.accept(); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + async cancelVerification( + id: string, + params?: { reason?: string; code?: string }, + ): Promise { + const session = this.findVerificationSession(id); + await session.request.cancel(params); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + async startVerification( + id: string, + method: MatrixVerificationMethod = "sas", + ): Promise { + const session = this.findVerificationSession(id); + if (method !== "sas") { + throw new Error("Matrix startVerification currently supports only SAS directly"); + } + const verifier = await session.request.startVerification(VerificationMethod.Sas); + this.attachVerifierToVerificationSession(session, verifier); + this.ensureVerificationStarted(session); + return this.buildVerificationSummary(session); + } + + async generateVerificationQr(id: string): Promise<{ qrDataBase64: string }> { + const session = this.findVerificationSession(id); + const qr = await session.request.generateQRCode(); + if (!qr) { + throw new Error("Matrix verification QR data is not available yet"); + } + return { qrDataBase64: Buffer.from(qr).toString("base64") }; + } + + async scanVerificationQr(id: string, qrDataBase64: string): Promise { + const session = this.findVerificationSession(id); + const trimmed = qrDataBase64.trim(); + if (!trimmed) { + throw new Error("Matrix verification QR payload is required"); + } + const qrBytes = Buffer.from(trimmed, "base64"); + if (qrBytes.length === 0) { + throw new Error("Matrix verification QR payload is invalid base64"); + } + const verifier = await session.request.scanQRCode(new Uint8ClampedArray(qrBytes)); + this.attachVerifierToVerificationSession(session, verifier); + this.ensureVerificationStarted(session); + return this.buildVerificationSummary(session); + } + + async confirmVerificationSas(id: string): Promise { + const session = this.findVerificationSession(id); + const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (!callbacks) { + throw new Error("Matrix SAS confirmation is not available for this verification request"); + } + session.sasCallbacks = callbacks; + await callbacks.confirm(); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + mismatchVerificationSas(id: string): MatrixVerificationSummary { + const session = this.findVerificationSession(id); + const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (!callbacks) { + throw new Error("Matrix SAS mismatch is not available for this verification request"); + } + session.sasCallbacks = callbacks; + callbacks.mismatch(); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + confirmVerificationReciprocateQr(id: string): MatrixVerificationSummary { + const session = this.findVerificationSession(id); + const callbacks = + session.reciprocateQrCallbacks ?? session.activeVerifier?.getReciprocateQrCodeCallbacks(); + if (!callbacks) { + throw new Error( + "Matrix reciprocate-QR confirmation is not available for this verification request", + ); + } + session.reciprocateQrCallbacks = callbacks; + callbacks.confirm(); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + getVerificationSas(id: string): { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + } { + const session = this.findVerificationSession(id); + const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (!callbacks) { + throw new Error("Matrix SAS data is not available for this verification request"); + } + session.sasCallbacks = callbacks; + return { + decimal: callbacks.sas.decimal, + emoji: callbacks.sas.emoji, + }; + } +} diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts index f23276abda4..1b09bfdd779 100644 --- a/extensions/matrix/src/matrix/send/client.ts +++ b/extensions/matrix/src/matrix/send/client.ts @@ -49,6 +49,7 @@ export async function resolveMatrixClient(opts: { homeserver: auth.homeserver, userId: auth.userId, accessToken: auth.accessToken, + deviceId: auth.deviceId, encryption: auth.encryption, localTimeoutMs: opts.timeoutMs, }); diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index d66ebcd6be0..ffb2ff6bf89 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -37,8 +37,9 @@ async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { await prompter.note( [ "Matrix requires a homeserver URL.", - "Use an access token (recommended) or a password (logs in and stores a token).", + "Use an access token (recommended), password login, or account registration.", "With access token: user ID is fetched automatically.", + "Password + register mode can create an account on homeservers with open registration.", "Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.", `Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`, ].join("\n"), @@ -266,6 +267,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { let accessToken = existing.accessToken ?? ""; let password = existing.password ?? ""; let userId = existing.userId ?? ""; + let register = existing.register === true; if (accessToken || password) { const keep = await prompter.confirm({ @@ -276,6 +278,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { accessToken = ""; password = ""; userId = ""; + register = false; } } @@ -286,6 +289,10 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { options: [ { value: "token", label: "Access token (user ID fetched automatically)" }, { value: "password", label: "Password (requires user ID)" }, + { + value: "register", + label: "Register account (open homeserver registration required)", + }, ], }); @@ -299,8 +306,9 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { // With access token, we can fetch the userId automatically - don't prompt for it // The client.ts will use whoami() to get it userId = ""; + register = false; } else { - // Password auth requires user ID upfront + // Password auth and registration mode require user ID upfront userId = String( await prompter.text({ message: "Matrix user ID", @@ -326,6 +334,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); + register = authMode === "register"; } } @@ -353,6 +362,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { userId: userId || undefined, accessToken: accessToken || undefined, password: password || undefined, + register, deviceName: deviceName || undefined, encryption: enableEncryption || undefined, }, diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts index 83ccecd7a81..029e4f46f60 100644 --- a/extensions/matrix/src/tool-actions.ts +++ b/extensions/matrix/src/tool-actions.ts @@ -8,16 +8,28 @@ import { } from "openclaw/plugin-sdk"; import type { CoreConfig } from "./types.js"; import { + acceptMatrixVerification, + cancelMatrixVerification, + confirmMatrixVerificationReciprocateQr, + confirmMatrixVerificationSas, deleteMatrixMessage, editMatrixMessage, + generateMatrixVerificationQr, + getMatrixEncryptionStatus, getMatrixMemberInfo, getMatrixRoomInfo, + getMatrixVerificationSas, listMatrixPins, listMatrixReactions, + listMatrixVerifications, + mismatchMatrixVerificationSas, pinMatrixMessage, readMatrixMessages, + requestMatrixVerification, removeMatrixReactions, + scanMatrixVerificationQr, sendMatrixMessage, + startMatrixVerification, unpinMatrixMessage, } from "./matrix/actions.js"; import { reactMatrixMessage } from "./matrix/send.js"; @@ -25,6 +37,20 @@ import { reactMatrixMessage } from "./matrix/send.js"; const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]); const reactionActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); +const verificationActions = new Set([ + "encryptionStatus", + "verificationList", + "verificationRequest", + "verificationAccept", + "verificationCancel", + "verificationStart", + "verificationGenerateQr", + "verificationScanQr", + "verificationSas", + "verificationConfirm", + "verificationMismatch", + "verificationConfirmQr", +]); function readRoomId(params: Record, required = true): string { const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); @@ -160,5 +186,109 @@ export async function handleMatrixAction( return jsonResult({ ok: true, room: result }); } + if (verificationActions.has(action)) { + if (!isActionEnabled("verification")) { + throw new Error("Matrix verification actions are disabled."); + } + + const requestId = + readStringParam(params, "requestId") ?? + readStringParam(params, "verificationId") ?? + readStringParam(params, "id"); + + if (action === "encryptionStatus") { + const includeRecoveryKey = params.includeRecoveryKey === true; + const status = await getMatrixEncryptionStatus({ includeRecoveryKey }); + return jsonResult({ ok: true, status }); + } + if (action === "verificationList") { + const verifications = await listMatrixVerifications(); + return jsonResult({ ok: true, verifications }); + } + if (action === "verificationRequest") { + const userId = readStringParam(params, "userId"); + const deviceId = readStringParam(params, "deviceId"); + const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); + const ownUser = typeof params.ownUser === "boolean" ? params.ownUser : undefined; + const verification = await requestMatrixVerification({ + ownUser, + userId: userId ?? undefined, + deviceId: deviceId ?? undefined, + roomId: roomId ?? undefined, + }); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationAccept") { + const verification = await acceptMatrixVerification( + readStringParam({ requestId }, "requestId", { required: true }), + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationCancel") { + const reason = readStringParam(params, "reason"); + const code = readStringParam(params, "code"); + const verification = await cancelMatrixVerification( + readStringParam({ requestId }, "requestId", { required: true }), + { reason: reason ?? undefined, code: code ?? undefined }, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationStart") { + const methodRaw = readStringParam(params, "method"); + const method = methodRaw?.trim().toLowerCase(); + if (method && method !== "sas") { + throw new Error( + "Matrix verificationStart only supports method=sas; use verificationGenerateQr/verificationScanQr for QR flows.", + ); + } + const verification = await startMatrixVerification( + readStringParam({ requestId }, "requestId", { required: true }), + { method: "sas" }, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationGenerateQr") { + const qr = await generateMatrixVerificationQr( + readStringParam({ requestId }, "requestId", { required: true }), + ); + return jsonResult({ ok: true, ...qr }); + } + if (action === "verificationScanQr") { + const qrDataBase64 = + readStringParam(params, "qrDataBase64") ?? + readStringParam(params, "qrData") ?? + readStringParam(params, "qr"); + const verification = await scanMatrixVerificationQr( + readStringParam({ requestId }, "requestId", { required: true }), + readStringParam({ qrDataBase64 }, "qrDataBase64", { required: true }), + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationSas") { + const sas = await getMatrixVerificationSas( + readStringParam({ requestId }, "requestId", { required: true }), + ); + return jsonResult({ ok: true, sas }); + } + if (action === "verificationConfirm") { + const verification = await confirmMatrixVerificationSas( + readStringParam({ requestId }, "requestId", { required: true }), + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationMismatch") { + const verification = await mismatchMatrixVerificationSas( + readStringParam({ requestId }, "requestId", { required: true }), + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationConfirmQr") { + const verification = await confirmMatrixVerificationReciprocateQr( + readStringParam({ requestId }, "requestId", { required: true }), + ); + return jsonResult({ ok: true, verification }); + } + } + throw new Error(`Unsupported Matrix action: ${action}`); } diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index 9d598575382..696a2b1910f 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -36,6 +36,7 @@ export type MatrixActionConfig = { pins?: boolean; memberInfo?: boolean; channelInfo?: boolean; + verification?: boolean; }; export type MatrixConfig = { @@ -51,6 +52,10 @@ export type MatrixConfig = { accessToken?: string; /** Matrix password (used only to fetch access token). */ password?: string; + /** Auto-register account when password login fails (open registration homeservers). */ + register?: boolean; + /** Optional Matrix device id (recommended when using access tokens + E2EE). */ + deviceId?: string; /** Optional device name when logging in via password. */ deviceName?: string; /** Initial sync limit for startup (defaults to matrix-js-sdk behavior). */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6ad6d162e5..e10366a1b0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -370,6 +370,9 @@ importers: '@matrix-org/matrix-sdk-crypto-nodejs': specifier: ^0.4.0 version: 0.4.0 + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 markdown-it: specifier: 14.1.0 version: 14.1.0 @@ -3487,6 +3490,10 @@ packages: fast-content-type-parse@3.0.0: resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -8601,6 +8608,8 @@ snapshots: fast-content-type-parse@3.0.0: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-uri@3.1.0: {}