Compare commits

...

8 Commits

Author SHA1 Message Date
gustavo
668c8f76f3 extending SDK; adding automatic bot acc creation 2026-02-08 19:04:00 -05:00
gustavo
cc47efd430 Matrix: harden E2EE flows and split SDK modules 2026-02-08 16:09:07 -05:00
gustavo
bba2de35dc Matrix: split sdk into focused crypto/http/type modules 2026-02-08 15:54:38 -05:00
gustavo
a7fb08e6bd Matrix: harden verification session lifecycle and retry coverage 2026-02-08 15:43:55 -05:00
gustavo
ba8e08186d Matrix: consolidate decrypt retry wiring and harden sdk imports 2026-02-08 15:39:53 -05:00
gustavo
afd46ce9b8 Matrix: stabilize E2EE verification and modularize SDK 2026-02-08 15:20:29 -05:00
Gustavo Madeira Santana
66c0f4bcc7 Matrix: fix event bridge dedupe and invite detection 2026-02-08 01:28:08 -05:00
Gustavo Madeira Santana
35f60d65d5 Matrix: migrate to matrix-js-sdk and harden client transport 2026-02-08 01:09:39 -05:00
70 changed files with 5982 additions and 1135 deletions

View File

@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras.
- Matrix plugin: harden E2EE bootstrap and verification flows (cross-signing + secret storage + own-device trust), add bounded decrypt retry with crypto-key signals, enforce safe absolute-endpoint request handling, and split SDK internals into focused modules with coverage. (#11705) Thanks @gumadeiras.
## 2026.2.6

View File

@@ -12,7 +12,7 @@ on any homeserver, so you need a Matrix account for the bot. Once it is logged i
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
but it requires E2EE to be enabled.
Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
Status: supported via plugin (`matrix-js-sdk`). Direct messages, rooms, threads, media, reactions,
polls (send + poll-start as text), location, and E2EE (with crypto support).
## Plugin required
@@ -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,15 @@ 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/<account>/<homeserver>__<user>/<token-hash>/recovery-key.json`
- On startup, OpenClaw requests self-verification and can accept incoming verification requests.
- OpenClaw also marks and cross-signs its own device when crypto APIs are available, which improves
trust establishment on fresh sessions.
- Failed decryptions are retried with bounded backoff and retried immediately again when new room keys
arrive, so new key-sharing events recover without waiting for the next retry window.
- Verify in another Matrix client (Element, etc.) to establish trust and improve key sharing.
- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt;
OpenClaw logs a warning.
- If you see missing crypto module errors (for example, `@matrix-org/matrix-sdk-crypto-nodejs-*`),
@@ -126,8 +136,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/<account>/<homeserver>__<user>/<token-hash>/crypto/`
(SQLite database). Sync state lives alongside it in `bot-storage.json`.
`~/.openclaw/credentials/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/`.
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 +147,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.
@@ -225,6 +255,11 @@ Common failures:
- Logged in but room messages ignored: room blocked by `groupPolicy` or room allowlist.
- DMs ignored: sender pending approval when `channels.matrix.dm.policy="pairing"`.
- Encrypted rooms fail: crypto support or encryption settings mismatch.
- "User verification unavailable" in Element for the bot profile:
- Ensure `channels.matrix.encryption: true` is set and restart.
- Ensure the bot logs in with a stable `channels.matrix.deviceId`.
- Send at least one new encrypted message after verification. Older messages from before
the current bot device login may remain undecryptable.
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
@@ -239,6 +274,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 +294,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.

View File

@@ -5,8 +5,9 @@
"type": "module",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"@vector-im/matrix-bot-sdk": "0.8.0-element.3",
"fake-indexeddb": "^6.2.5",
"markdown-it": "14.1.0",
"matrix-js-sdk": "^40.1.0",
"music-metadata": "^11.11.2",
"zod": "^4.3.6"
},

View File

@@ -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<string, string> = {
"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.`);
},
};

View File

@@ -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<ResolvedMatrixAccount> = {
"userId",
"accessToken",
"password",
"register",
"deviceName",
"initialSyncLimit",
],

View File

@@ -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(),

View File

@@ -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";

View File

@@ -41,6 +41,8 @@ export async function resolveActionClient(
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
password: auth.password,
deviceId: auth.deviceId,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
});

View File

@@ -101,7 +101,7 @@ export async function readMatrixMessages(
: 20;
const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b";
// @vector-im/matrix-bot-sdk uses doRequest for room messages
// Room history is queried via the low-level endpoint for compatibility.
const res = (await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,

View File

@@ -21,7 +21,7 @@ export async function listMatrixReactions(
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 100;
// @vector-im/matrix-bot-sdk uses doRequest for relations
// Relations are queried via the low-level endpoint for compatibility.
const res = (await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,

View File

@@ -9,10 +9,8 @@ export async function getMatrixMemberInfo(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
// @vector-im/matrix-bot-sdk uses getUserProfile
const profile = await client.getUserProfile(userId);
// Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
// We'd need to fetch room state separately if needed
// Membership and power levels are not included in profile calls; fetch state separately if needed.
return {
userId,
profile: {
@@ -35,7 +33,6 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
// @vector-im/matrix-bot-sdk uses getRoomState for state events
let name: string | null = null;
let topic: string | null = null;
let canonicalAlias: string | null = null;
@@ -43,21 +40,21 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
try {
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
name = nameState?.name ?? null;
name = typeof nameState?.name === "string" ? nameState.name : null;
} catch {
// ignore
}
try {
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
topic = topicState?.topic ?? null;
topic = typeof topicState?.topic === "string" ? topicState.topic : null;
} catch {
// ignore
}
try {
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
canonicalAlias = aliasState?.alias ?? null;
canonicalAlias = typeof aliasState?.alias === "string" ? aliasState.alias : null;
} catch {
// ignore
}

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
import {
EventType,
type MatrixMessageSummary,

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient, MatrixRawEvent, MessageEventContent } from "../sdk.js";
export const MsgType = {
Text: "m.text",
@@ -16,7 +16,7 @@ export const EventType = {
Reaction: "m.reaction",
} as const;
export type RoomMessageEventContent = {
export type RoomMessageEventContent = MessageEventContent & {
msgtype: string;
body: string;
"m.new_content"?: RoomMessageEventContent;
@@ -43,17 +43,6 @@ export type RoomTopicEventContent = {
topic?: string;
};
export type MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
unsigned?: {
redacted_because?: unknown;
};
};
export type MatrixActionClientOpts = {
client?: MatrixClient;
timeoutMs?: number;

View File

@@ -0,0 +1,220 @@
import type { MatrixActionClientOpts } from "./types.js";
import { resolveActionClient } from "./client.js";
function requireCrypto(
client: import("../sdk.js").MatrixClient,
): NonNullable<import("../sdk.js").MatrixClient["crypto"]> {
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();
}
}
}

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "./sdk.js";
let activeClient: MatrixClient | null = null;

View File

@@ -1,6 +1,26 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixConfig } from "./client.js";
import { resolveMatrixAuth, resolveMatrixConfig } from "./client.js";
import * as credentialsModule from "./credentials.js";
import * as sdkModule from "./sdk.js";
const saveMatrixCredentialsMock = vi.fn();
const prepareMatrixRegisterModeMock = vi.fn(async () => null);
const finalizeMatrixRegisterConfigAfterSuccessMock = vi.fn(async () => false);
vi.mock("./credentials.js", () => ({
loadMatrixCredentials: vi.fn(() => null),
saveMatrixCredentials: (...args: unknown[]) => saveMatrixCredentialsMock(...args),
credentialsMatchConfig: vi.fn(() => false),
touchMatrixCredentials: vi.fn(),
}));
vi.mock("./client/register-mode.js", () => ({
prepareMatrixRegisterMode: (...args: unknown[]) => prepareMatrixRegisterModeMock(...args),
finalizeMatrixRegisterConfigAfterSuccess: (...args: unknown[]) =>
finalizeMatrixRegisterConfigAfterSuccessMock(...args),
resetPreparedMatrixRegisterModesForTests: vi.fn(),
}));
describe("resolveMatrixConfig", () => {
it("prefers config over env", () => {
@@ -29,6 +49,8 @@ describe("resolveMatrixConfig", () => {
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
register: false,
deviceId: undefined,
deviceName: "CfgDevice",
initialSyncLimit: 5,
encryption: false,
@@ -42,6 +64,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);
@@ -49,8 +72,328 @@ 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", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
saveMatrixCredentialsMock.mockReset();
prepareMatrixRegisterModeMock.mockReset();
finalizeMatrixRegisterConfigAfterSuccessMock.mockReset();
});
it("uses the hardened client request path for password login and persists deviceId", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
access_token: "tok-123",
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
"/_matrix/client/v3/login",
undefined,
expect.objectContaining({
type: "m.login.password",
}),
);
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
expect.objectContaining({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
}),
);
});
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",
device_id: undefined,
}),
);
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,
});
expect(prepareMatrixRegisterModeMock).toHaveBeenCalledWith({
cfg,
homeserver: "https://matrix.example.org",
userId: "@newbot:example.org",
env: {} as NodeJS.ProcessEnv,
});
expect(finalizeMatrixRegisterConfigAfterSuccessMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
userId: "@newbot:example.org",
deviceId: "REGDEVICE123",
});
});
it("ignores cached credentials when matrix.register=true", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
access_token: "tok-123",
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
register: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
"/_matrix/client/v3/login",
undefined,
expect.objectContaining({
type: "m.login.password",
}),
);
expect(auth.accessToken).toBe("tok-123");
expect(prepareMatrixRegisterModeMock).toHaveBeenCalledTimes(1);
});
it("requires matrix.password when matrix.register=true", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
register: true,
},
},
} as CoreConfig;
await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
"Matrix password is required when matrix.register=true",
);
expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled();
expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled();
});
it("requires matrix.userId when matrix.register=true", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
password: "secret",
register: true,
},
},
} as CoreConfig;
await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
"Matrix userId is required when matrix.register=true",
);
expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled();
expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled();
});
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,
});
});
});

View File

@@ -1,13 +1,101 @@
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { MatrixClient } from "../sdk.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
finalizeMatrixRegisterConfigAfterSuccess,
prepareMatrixRegisterMode,
} from "./register-mode.js";
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;
deviceId?: 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,
device_id: params.deviceId,
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 +105,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 +119,8 @@ export function resolveMatrixConfig(
userId,
accessToken,
password,
register,
deviceId,
deviceName,
initialSyncLimit,
encryption,
@@ -41,6 +134,7 @@ export async function resolveMatrixAuth(params?: {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const resolved = resolveMatrixConfig(cfg, env);
const registerFromConfig = cfg.channels?.matrix?.register === true;
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix.homeserver)");
}
@@ -62,40 +156,84 @@ export async function resolveMatrixAuth(params?: {
? cached
: null;
if (registerFromConfig) {
if (!resolved.userId) {
throw new Error("Matrix userId is required when matrix.register=true");
}
if (!resolved.password) {
throw new Error("Matrix password is required when matrix.register=true");
}
await prepareMatrixRegisterMode({
cfg,
homeserver: resolved.homeserver,
userId: resolved.userId,
env,
});
}
// If we have an access token, we can fetch userId via whoami if not provided
if (resolved.accessToken) {
if (resolved.accessToken && !registerFromConfig) {
let userId = resolved.userId;
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 {
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
password: resolved.password,
deviceId: knownDeviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (cachedCredentials) {
if (cachedCredentials && !registerFromConfig) {
touchMatrixCredentials(env);
return {
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
password: resolved.password,
deviceId: cachedCredentials.deviceId || resolved.deviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
@@ -112,38 +250,59 @@ export async function resolveMatrixAuth(params?: {
);
}
// Login with password using HTTP API
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
}),
});
if (!loginResponse.ok) {
const errorText = await loginResponse.text();
throw new Error(`Matrix login failed: ${errorText}`);
}
const login = (await loginResponse.json()) as {
// Login with password using the same hardened request path as other Matrix HTTP calls.
ensureMatrixSdkLoggingConfigured();
const loginClient = new MatrixClient(resolved.homeserver, "");
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,
device_id: resolved.deviceId,
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,
deviceId: resolved.deviceId,
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 = {
homeserver: resolved.homeserver,
userId: login.user_id ?? resolved.userId,
accessToken,
password: resolved.password,
deviceId: login.device_id ?? resolved.deviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
@@ -153,8 +312,16 @@ export async function resolveMatrixAuth(params?: {
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: login.device_id,
deviceId: auth.deviceId,
});
if (registerFromConfig) {
await finalizeMatrixRegisterConfigAfterSuccess({
homeserver: auth.homeserver,
userId: auth.userId,
deviceId: auth.deviceId,
});
}
return auth;
}

View File

@@ -1,11 +1,5 @@
import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
import {
LogService,
MatrixClient,
SimpleFsStorageProvider,
RustSdkCryptoStorageProvider,
} from "@vector-im/matrix-bot-sdk";
import fs from "node:fs";
import { MatrixClient } from "../sdk.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
maybeMigrateLegacyStorage,
@@ -13,111 +7,50 @@ import {
writeStorageMeta,
} from "./storage.js";
function sanitizeUserIdList(input: unknown, label: string): string[] {
if (input == null) {
return [];
}
if (!Array.isArray(input)) {
LogService.warn(
"MatrixClientLite",
`Expected ${label} list to be an array, got ${typeof input}`,
);
return [];
}
const filtered = input.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
);
if (filtered.length !== input.length) {
LogService.warn(
"MatrixClientLite",
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
);
}
return filtered;
}
export async function createMatrixClient(params: {
homeserver: string;
userId: string;
userId?: string;
accessToken: string;
password?: string;
deviceId?: string;
encryption?: boolean;
localTimeoutMs?: number;
initialSyncLimit?: number;
accountId?: string | null;
}): Promise<MatrixClient> {
ensureMatrixSdkLoggingConfigured();
const env = process.env;
const userId = params.userId?.trim() || "unknown";
const matrixClientUserId = params.userId?.trim() || undefined;
// Create storage provider
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.homeserver,
userId: params.userId,
userId,
accessToken: params.accessToken,
accountId: params.accountId,
env,
});
maybeMigrateLegacyStorage({ storagePaths, env });
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath);
// Create crypto storage if encryption is enabled
let cryptoStorage: ICryptoStorageProvider | undefined;
if (params.encryption) {
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
try {
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
cryptoStorage = new RustSdkCryptoStorageProvider(storagePaths.cryptoPath, StoreType.Sqlite);
} catch (err) {
LogService.warn(
"MatrixClientLite",
"Failed to initialize crypto storage, E2EE disabled:",
err,
);
}
}
writeStorageMeta({
storagePaths,
homeserver: params.homeserver,
userId: params.userId,
userId,
accountId: params.accountId,
});
const client = new MatrixClient(params.homeserver, params.accessToken, storage, cryptoStorage);
const cryptoDatabasePrefix = `openclaw-matrix-${storagePaths.accountKey}-${storagePaths.tokenHash}`;
if (client.crypto) {
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
client.crypto.updateSyncData = async (
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
changedDeviceLists,
leftDeviceLists,
) => {
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
try {
return await originalUpdateSyncData(
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
safeChanged,
safeLeft,
);
} catch (err) {
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
if (message.includes("Expect value to be String")) {
LogService.warn(
"MatrixClientLite",
"Ignoring malformed device list entries during crypto sync",
message,
);
return;
}
throw err;
}
};
}
return client;
return new MatrixClient(params.homeserver, params.accessToken, undefined, undefined, {
userId: matrixClientUserId,
password: params.password,
deviceId: params.deviceId,
encryption: params.encryption,
localTimeoutMs: params.localTimeoutMs,
initialSyncLimit: params.initialSyncLimit,
recoveryKeyPath: storagePaths.recoveryKeyPath,
idbSnapshotPath: storagePaths.idbSnapshotPath,
cryptoDatabasePrefix,
});
}

View File

@@ -1,4 +1,4 @@
import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk";
import { ConsoleLogger, LogService } from "../sdk/logger.js";
let matrixSdkLoggingConfigured = false;
const matrixSdkBaseLogger = new ConsoleLogger();

View File

@@ -0,0 +1,97 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../../types.js";
import * as runtimeModule from "../../runtime.js";
import {
finalizeMatrixRegisterConfigAfterSuccess,
prepareMatrixRegisterMode,
resetPreparedMatrixRegisterModesForTests,
} from "./register-mode.js";
describe("matrix register mode helpers", () => {
const tempDirs: string[] = [];
afterEach(() => {
resetPreparedMatrixRegisterModesForTests();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
vi.restoreAllMocks();
});
it("moves existing matrix state into a .bak snapshot before fresh registration", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-register-mode-"));
tempDirs.push(stateDir);
const credentialsDir = path.join(stateDir, "credentials", "matrix");
const accountsDir = path.join(credentialsDir, "accounts");
fs.mkdirSync(accountsDir, { recursive: true });
fs.writeFileSync(path.join(credentialsDir, "credentials.json"), '{"accessToken":"old"}\n');
fs.writeFileSync(path.join(accountsDir, "dummy.txt"), "old-state\n");
const cfg = {
channels: {
matrix: {
userId: "@pinguini:matrix.gumadeiras.com",
register: true,
encryption: true,
},
},
} as CoreConfig;
const backupDir = await prepareMatrixRegisterMode({
cfg,
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
});
expect(backupDir).toBeTruthy();
expect(fs.existsSync(path.join(credentialsDir, "credentials.json"))).toBe(false);
expect(fs.existsSync(path.join(credentialsDir, "accounts"))).toBe(false);
expect(fs.existsSync(path.join(backupDir as string, "credentials.json"))).toBe(true);
expect(fs.existsSync(path.join(backupDir as string, "accounts", "dummy.txt"))).toBe(true);
expect(fs.existsSync(path.join(backupDir as string, "matrix-config.json"))).toBe(true);
});
it("updates matrix config after successful register mode auth", async () => {
const writeConfigFile = vi.fn(async () => {});
vi.spyOn(runtimeModule, "getMatrixRuntime").mockReturnValue({
config: {
loadConfig: () =>
({
channels: {
matrix: {
register: true,
accessToken: "stale-token",
userId: "@pinguini:matrix.gumadeiras.com",
},
},
}) as CoreConfig,
writeConfigFile,
},
} as never);
const updated = await finalizeMatrixRegisterConfigAfterSuccess({
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
deviceId: "DEVICE123",
});
expect(updated).toBe(true);
expect(writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
channels: expect.objectContaining({
matrix: expect.objectContaining({
register: false,
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
deviceId: "DEVICE123",
}),
}),
}),
);
const written = writeConfigFile.mock.calls[0]?.[0] as CoreConfig;
expect(written.channels?.matrix?.accessToken).toBeUndefined();
});
});

View File

@@ -0,0 +1,125 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { CoreConfig } from "../../types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { resolveMatrixCredentialsDir } from "../credentials.js";
const preparedRegisterKeys = new Set<string>();
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv): string {
try {
return getMatrixRuntime().state.resolveStateDir(env, os.homedir);
} catch {
// fall through to deterministic fallback for tests/early init
}
const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (override) {
if (override.startsWith("~")) {
const expanded = override.replace(/^~(?=$|[\\/])/, os.homedir());
return path.resolve(expanded);
}
return path.resolve(override);
}
return path.join(os.homedir(), ".openclaw");
}
function buildRegisterKey(params: { homeserver: string; userId: string }): string {
return `${params.homeserver.trim().toLowerCase()}|${params.userId.trim().toLowerCase()}`;
}
function buildBackupDirName(now = new Date()): string {
const ts = now.toISOString().replace(/[:.]/g, "-");
const suffix = Math.random().toString(16).slice(2, 8);
return `${ts}-${suffix}`;
}
export async function prepareMatrixRegisterMode(params: {
cfg: CoreConfig;
homeserver: string;
userId: string;
env?: NodeJS.ProcessEnv;
}): Promise<string | null> {
const env = params.env ?? process.env;
const registerKey = buildRegisterKey({
homeserver: params.homeserver,
userId: params.userId,
});
if (preparedRegisterKeys.has(registerKey)) {
return null;
}
const stateDir = resolveStateDirFromEnv(env);
const credentialsDir = resolveMatrixCredentialsDir(env, stateDir);
if (!fs.existsSync(credentialsDir)) {
return null;
}
const entries = fs.readdirSync(credentialsDir).filter((name) => name !== ".bak");
if (entries.length === 0) {
return null;
}
const backupRoot = path.join(credentialsDir, ".bak");
fs.mkdirSync(backupRoot, { recursive: true });
const backupDir = path.join(backupRoot, buildBackupDirName());
fs.mkdirSync(backupDir, { recursive: true });
const matrixConfig = params.cfg.channels?.matrix ?? {};
fs.writeFileSync(
path.join(backupDir, "matrix-config.json"),
JSON.stringify(matrixConfig, null, 2).trimEnd().concat("\n"),
"utf-8",
);
for (const entry of entries) {
fs.renameSync(path.join(credentialsDir, entry), path.join(backupDir, entry));
}
preparedRegisterKeys.add(registerKey);
return backupDir;
}
export async function finalizeMatrixRegisterConfigAfterSuccess(params: {
homeserver: string;
userId: string;
deviceId?: string;
}): Promise<boolean> {
let runtime: ReturnType<typeof getMatrixRuntime> | null = null;
try {
runtime = getMatrixRuntime();
} catch {
return false;
}
const cfg = runtime.config.loadConfig() as CoreConfig;
if (cfg.channels?.matrix?.register !== true) {
return false;
}
const matrixCfg = cfg.channels?.matrix ?? {};
const nextMatrix: Record<string, unknown> = {
...matrixCfg,
register: false,
homeserver: params.homeserver,
userId: params.userId,
...(params.deviceId?.trim() ? { deviceId: params.deviceId.trim() } : {}),
};
// Registration mode should continue relying on password + cached credentials, not stale inline token.
delete nextMatrix.accessToken;
const next: CoreConfig = {
...cfg,
channels: {
...(cfg.channels ?? {}),
matrix: nextMatrix as CoreConfig["channels"]["matrix"],
},
};
await runtime.config.writeConfigFile(next as never);
return true;
}
export function resetPreparedMatrixRegisterModesForTests(): void {
preparedRegisterKeys.clear();
}

View File

@@ -1,7 +1,7 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { LogService } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
import type { CoreConfig } from "../types.js";
import type { MatrixAuth } from "./types.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";
@@ -36,8 +36,11 @@ async function createSharedMatrixClient(params: {
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
password: params.auth.password,
deviceId: params.auth.deviceId,
encryption: params.auth.encryption,
localTimeoutMs: params.timeoutMs,
initialSyncLimit: params.auth.initialSyncLimit,
accountId: params.accountId,
});
return {
@@ -158,7 +161,7 @@ export async function waitForMatrixSync(_params: {
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
// @vector-im/matrix-bot-sdk handles sync internally in start()
// matrix-js-sdk handles sync lifecycle in start() for this integration.
// This is kept for API compatibility but is essentially a no-op now
}

View File

@@ -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,
};

View File

@@ -2,7 +2,9 @@ export type MatrixResolvedConfig = {
homeserver: string;
userId: string;
accessToken?: string;
deviceId?: string;
password?: string;
register?: boolean;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
@@ -19,6 +21,8 @@ export type MatrixAuth = {
homeserver: string;
userId: string;
accessToken: string;
password?: string;
deviceId?: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
@@ -29,6 +33,8 @@ export type MatrixStoragePaths = {
storagePath: string;
cryptoPath: string;
metaPath: string;
recoveryKeyPath: string;
idbSnapshotPath: string;
accountKey: string;
tokenHash: string;
};

View File

@@ -5,18 +5,28 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { getMatrixRuntime } from "../runtime.js";
const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
const REQUIRED_MATRIX_PACKAGES = ["matrix-js-sdk", "@matrix-org/matrix-sdk-crypto-nodejs"];
export function isMatrixSdkAvailable(): boolean {
function resolveMissingMatrixPackages(): string[] {
try {
const req = createRequire(import.meta.url);
req.resolve(MATRIX_SDK_PACKAGE);
return true;
return REQUIRED_MATRIX_PACKAGES.filter((pkg) => {
try {
req.resolve(pkg);
return false;
} catch {
return true;
}
});
} catch {
return false;
return [...REQUIRED_MATRIX_PACKAGES];
}
}
export function isMatrixSdkAvailable(): boolean {
return resolveMissingMatrixPackages().length === 0;
}
function resolvePluginRoot(): string {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(currentDir, "..", "..");
@@ -31,9 +41,13 @@ export async function ensureMatrixSdkInstalled(params: {
}
const confirm = params.confirm;
if (confirm) {
const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");
const ok = await confirm(
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs. Install now?",
);
if (!ok) {
throw new Error("Matrix requires @vector-im/matrix-bot-sdk (install dependencies first).");
throw new Error(
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs (install dependencies first).",
);
}
}
@@ -53,8 +67,11 @@ export async function ensureMatrixSdkInstalled(params: {
);
}
if (!isMatrixSdkAvailable()) {
const missing = resolveMissingMatrixPackages();
throw new Error(
"Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.",
missing.length > 0
? `Matrix dependency install completed but required packages are still missing: ${missing.join(", ")}`
: "Matrix dependency install completed but Matrix dependencies are still missing.",
);
}
}

View File

@@ -0,0 +1,127 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../../types.js";
import { setMatrixRuntime } from "../../runtime.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
type InviteHandler = (roomId: string, inviteEvent: unknown) => Promise<void>;
function createClientStub() {
let inviteHandler: InviteHandler | null = null;
const client = {
on: vi.fn((eventName: string, listener: unknown) => {
if (eventName === "room.invite") {
inviteHandler = listener as InviteHandler;
}
return client;
}),
joinRoom: vi.fn(async () => {}),
getRoomStateEvent: vi.fn(async () => ({})),
} as unknown as import("../sdk.js").MatrixClient;
return {
client,
getInviteHandler: () => inviteHandler,
joinRoom: (client as unknown as { joinRoom: ReturnType<typeof vi.fn> }).joinRoom,
getRoomStateEvent: (client as unknown as { getRoomStateEvent: ReturnType<typeof vi.fn> })
.getRoomStateEvent,
};
}
describe("registerMatrixAutoJoin", () => {
beforeEach(() => {
setMatrixRuntime({
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime);
});
it("joins all invites when autoJoin=always", async () => {
const { client, getInviteHandler, joinRoom } = createClientStub();
const cfg: CoreConfig = {
channels: {
matrix: {
autoJoin: "always",
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
expect(inviteHandler).toBeTruthy();
await inviteHandler!("!room:example.org", {});
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
});
it("ignores invites outside allowlist when autoJoin=allowlist", async () => {
const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub();
getRoomStateEvent.mockResolvedValue({
alias: "#other:example.org",
alt_aliases: ["#else:example.org"],
});
const cfg: CoreConfig = {
channels: {
matrix: {
autoJoin: "allowlist",
autoJoinAllowlist: ["#allowed:example.org"],
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
expect(inviteHandler).toBeTruthy();
await inviteHandler!("!room:example.org", {});
expect(joinRoom).not.toHaveBeenCalled();
});
it("joins invite when alias matches allowlist", async () => {
const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub();
getRoomStateEvent.mockResolvedValue({
alias: "#allowed:example.org",
alt_aliases: ["#backup:example.org"],
});
const cfg: CoreConfig = {
channels: {
matrix: {
autoJoin: "allowlist",
autoJoinAllowlist: [" #allowed:example.org "],
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
expect(inviteHandler).toBeTruthy();
await inviteHandler!("!room:example.org", {});
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
});
});

View File

@@ -1,7 +1,6 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { RuntimeEnv } from "openclaw/plugin-sdk";
import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../../types.js";
import type { MatrixClient } from "../sdk.js";
import { getMatrixRuntime } from "../../runtime.js";
export function registerMatrixAutoJoin(params: {
@@ -18,47 +17,52 @@ export function registerMatrixAutoJoin(params: {
runtime.log?.(message);
};
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
const autoJoinAllowlist = new Set(
(cfg.channels?.matrix?.autoJoinAllowlist ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean),
);
if (autoJoin === "off") {
return;
}
if (autoJoin === "always") {
// Use the built-in autojoin mixin for "always" mode
AutojoinRoomsMixin.setupOnClient(client);
logVerbose("matrix: auto-join enabled for all invites");
return;
} else {
logVerbose("matrix: auto-join enabled for allowlist invites");
}
// For "allowlist" mode, handle invites manually
// Handle invites directly so both "always" and "allowlist" modes share the same path.
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin !== "allowlist") {
return;
}
if (autoJoin === "allowlist") {
let alias: string | undefined;
let altAliases: string[] = [];
try {
const aliasState = await client
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
.catch(() => null);
alias = aliasState && typeof aliasState.alias === "string" ? aliasState.alias : undefined;
altAliases =
aliasState && Array.isArray(aliasState.alt_aliases)
? aliasState.alt_aliases
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean)
: [];
} catch {
// Ignore errors
}
// Get room alias if available
let alias: string | undefined;
let altAliases: string[] = [];
try {
const aliasState = await client
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
.catch(() => null);
alias = aliasState?.alias;
altAliases = Array.isArray(aliasState?.alt_aliases) ? aliasState.alt_aliases : [];
} catch {
// Ignore errors
}
const allowed =
autoJoinAllowlist.has("*") ||
autoJoinAllowlist.has(roomId) ||
(alias ? autoJoinAllowlist.has(alias) : false) ||
altAliases.some((value) => autoJoinAllowlist.has(value));
const allowed =
autoJoinAllowlist.includes("*") ||
autoJoinAllowlist.includes(roomId) ||
(alias ? autoJoinAllowlist.includes(alias) : false) ||
altAliases.some((value) => autoJoinAllowlist.includes(value));
if (!allowed) {
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
return;
if (!allowed) {
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
return;
}
}
try {

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
type DirectMessageCheck = {
roomId: string;

View File

@@ -1,6 +1,6 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk";
import type { MatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js";
import type { MatrixRawEvent } from "./types.js";
import { EventType } from "./types.js";

View File

@@ -1,4 +1,3 @@
import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
import {
createReplyPrefixOptions,
createTypingCallbacks,
@@ -9,6 +8,7 @@ import {
type RuntimeEnv,
} from "openclaw/plugin-sdk";
import type { CoreConfig, ReplyToMode } from "../../types.js";
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
import {
formatPollAsText,
@@ -116,7 +116,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
try {
const eventType = event.type;
if (eventType === EventType.RoomMessageEncrypted) {
// Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled
// Encrypted payloads are emitted separately after decryption.
return;
}
@@ -446,7 +446,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
threadReplies,
messageId,
threadRootId,
isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available
isThreadRoot: false, // Raw event payload does not carry explicit thread-root metadata.
});
const route = core.channel.routing.resolveAgentRoute({

View File

@@ -300,7 +300,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
logVerboseMessage("matrix: client started");
// @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient
// Shared client is already started via resolveSharedMatrixClient.
logger.info(`matrix: logged in as ${auth.userId}`);
// If E2EE is enabled, trigger device verification

View File

@@ -1,9 +1,9 @@
import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk";
import {
formatLocationText,
toLocationContext,
type NormalizedLocation,
} from "openclaw/plugin-sdk";
import type { LocationMessageEventContent } from "../sdk.js";
import { EventType } from "./types.js";
export type MatrixLocationPayload = {

View File

@@ -28,7 +28,7 @@ describe("downloadMatrixMedia", () => {
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
} as unknown as import("../sdk.js").MatrixClient;
const file = {
url: "mxc://example/file",
@@ -69,7 +69,7 @@ describe("downloadMatrixMedia", () => {
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
} as unknown as import("../sdk.js").MatrixClient;
const file = {
url: "mxc://example/file",

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
import { getMatrixRuntime } from "../../runtime.js";
// Type for encrypted file info
@@ -21,7 +21,7 @@ async function fetchMatrixMediaBuffer(params: {
mxcUrl: string;
maxBytes: number;
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
// @vector-im/matrix-bot-sdk provides mxcToHttp helper
// The client wrapper exposes mxcToHttp for Matrix media URIs.
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) {
return null;
@@ -41,7 +41,7 @@ async function fetchMatrixMediaBuffer(params: {
/**
* Download and decrypt encrypted media from a Matrix room.
* Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption.
* Uses the Matrix crypto adapter's decryptMedia helper.
*/
async function fetchEncryptedMediaBuffer(params: {
client: MatrixClient;

View File

@@ -1,5 +1,5 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
import type { MatrixClient } from "../sdk.js";
import { getMatrixRuntime } from "../../runtime.js";
import { sendMessageMatrix } from "../send.js";

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
export type MatrixRoomInfo = {
name?: string;

View File

@@ -1,4 +1,4 @@
// Type for raw Matrix event from @vector-im/matrix-bot-sdk
// Type for raw Matrix event payload consumed by thread helpers.
type MatrixRawEvent = {
event_id: string;
sender: string;

View File

@@ -1,4 +1,4 @@
import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk";
import type { EncryptedFile, MatrixRawEvent, MessageEventContent } from "../sdk.js";
export const EventType = {
RoomMessage: "m.room.message",
@@ -12,18 +12,6 @@ export const RelationType = {
Thread: "m.thread",
} as const;
export type MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
unsigned?: {
age?: number;
redacted_because?: unknown;
};
};
export type RoomMessageEventContent = MessageEventContent & {
url?: string;
file?: EncryptedFile;

View File

@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const createMatrixClientMock = vi.fn();
const isBunRuntimeMock = vi.fn(() => false);
vi.mock("./client.js", () => ({
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
isBunRuntime: () => isBunRuntimeMock(),
}));
import { probeMatrix } from "./probe.js";
describe("probeMatrix", () => {
beforeEach(() => {
vi.clearAllMocks();
isBunRuntimeMock.mockReturnValue(false);
createMatrixClientMock.mockResolvedValue({
getUserId: vi.fn(async () => "@bot:example.org"),
});
});
it("passes undefined userId when not provided", async () => {
const result = await probeMatrix({
homeserver: "https://matrix.example.org",
accessToken: "tok",
timeoutMs: 1234,
});
expect(result.ok).toBe(true);
expect(createMatrixClientMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
userId: undefined,
accessToken: "tok",
localTimeoutMs: 1234,
});
});
it("trims provided userId before client creation", async () => {
await probeMatrix({
homeserver: "https://matrix.example.org",
accessToken: "tok",
userId: " @bot:example.org ",
timeoutMs: 500,
});
expect(createMatrixClientMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
localTimeoutMs: 500,
});
});
});

View File

@@ -43,13 +43,14 @@ export async function probeMatrix(params: {
};
}
try {
const inputUserId = params.userId?.trim() || undefined;
const client = await createMatrixClient({
homeserver: params.homeserver,
userId: params.userId ?? "",
userId: inputUserId,
accessToken: params.accessToken,
localTimeoutMs: params.timeoutMs,
});
// @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally
// The client wrapper resolves user ID via whoami when needed.
const userId = await client.getUserId();
result.ok = true;
result.userId = userId ?? null;

View File

@@ -0,0 +1,751 @@
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 {
private readonly roomId: string;
private readonly eventId: string;
private readonly sender: string;
private readonly type: string;
private readonly ts: number;
private readonly content: Record<string, unknown>;
private readonly stateKey?: string;
private readonly unsigned?: {
age?: number;
redacted_because?: unknown;
};
private readonly decryptionFailure: boolean;
constructor(params: {
roomId: string;
eventId: string;
sender: string;
type: string;
ts: number;
content: Record<string, unknown>;
stateKey?: string;
unsigned?: {
age?: number;
redacted_because?: unknown;
};
decryptionFailure?: boolean;
}) {
super();
this.roomId = params.roomId;
this.eventId = params.eventId;
this.sender = params.sender;
this.type = params.type;
this.ts = params.ts;
this.content = params.content;
this.stateKey = params.stateKey;
this.unsigned = params.unsigned;
this.decryptionFailure = params.decryptionFailure === true;
}
getRoomId(): string {
return this.roomId;
}
getId(): string {
return this.eventId;
}
getSender(): string {
return this.sender;
}
getType(): string {
return this.type;
}
getTs(): number {
return this.ts;
}
getContent(): Record<string, unknown> {
return this.content;
}
getUnsigned(): { age?: number; redacted_because?: unknown } {
return this.unsigned ?? {};
}
getStateKey(): string | undefined {
return this.stateKey;
}
isDecryptionFailure(): boolean {
return this.decryptionFailure;
}
}
type MatrixJsClientStub = EventEmitter & {
startClient: ReturnType<typeof vi.fn>;
stopClient: ReturnType<typeof vi.fn>;
initRustCrypto: ReturnType<typeof vi.fn>;
getUserId: ReturnType<typeof vi.fn>;
getDeviceId: ReturnType<typeof vi.fn>;
getJoinedRooms: ReturnType<typeof vi.fn>;
getJoinedRoomMembers: ReturnType<typeof vi.fn>;
getStateEvent: ReturnType<typeof vi.fn>;
getAccountData: ReturnType<typeof vi.fn>;
setAccountData: ReturnType<typeof vi.fn>;
getRoomIdForAlias: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>;
sendEvent: ReturnType<typeof vi.fn>;
sendStateEvent: ReturnType<typeof vi.fn>;
redactEvent: ReturnType<typeof vi.fn>;
getProfileInfo: ReturnType<typeof vi.fn>;
joinRoom: ReturnType<typeof vi.fn>;
mxcUrlToHttp: ReturnType<typeof vi.fn>;
uploadContent: ReturnType<typeof vi.fn>;
fetchRoomEvent: ReturnType<typeof vi.fn>;
sendTyping: ReturnType<typeof vi.fn>;
getRoom: ReturnType<typeof vi.fn>;
getRooms: ReturnType<typeof vi.fn>;
getCrypto: ReturnType<typeof vi.fn>;
decryptEventIfNeeded: ReturnType<typeof vi.fn>;
};
function createMatrixJsClientStub(): MatrixJsClientStub {
const client = new EventEmitter() as MatrixJsClientStub;
client.startClient = vi.fn(async () => {});
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 () => ({}));
client.getAccountData = vi.fn(() => undefined);
client.setAccountData = vi.fn(async () => {});
client.getRoomIdForAlias = vi.fn(async () => ({ room_id: "!resolved:example.org" }));
client.sendMessage = vi.fn(async () => ({ event_id: "$sent" }));
client.sendEvent = vi.fn(async () => ({ event_id: "$sent-event" }));
client.sendStateEvent = vi.fn(async () => ({ event_id: "$state" }));
client.redactEvent = vi.fn(async () => ({ event_id: "$redact" }));
client.getProfileInfo = vi.fn(async () => ({}));
client.joinRoom = vi.fn(async () => ({}));
client.mxcUrlToHttp = vi.fn(() => null);
client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" }));
client.fetchRoomEvent = vi.fn(async () => ({}));
client.sendTyping = vi.fn(async () => {});
client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false }));
client.getRooms = vi.fn(() => []);
client.getCrypto = vi.fn(() => undefined);
client.decryptEventIfNeeded = vi.fn(async () => {});
return client;
}
let matrixJsClient = createMatrixJsClientStub();
let lastCreateClientOpts: Record<string, unknown> | null = null;
vi.mock("matrix-js-sdk", () => ({
ClientEvent: { Event: "event", Room: "Room" },
MatrixEventEvent: { Decrypted: "decrypted" },
createClient: vi.fn((opts: Record<string, unknown>) => {
lastCreateClientOpts = opts;
return matrixJsClient;
}),
}));
import { MatrixClient } from "./sdk.js";
describe("MatrixClient request hardening", () => {
beforeEach(() => {
matrixJsClient = createMatrixJsClientStub();
lastCreateClientOpts = null;
vi.useRealTimers();
vi.unstubAllGlobals();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("blocks absolute endpoints unless explicitly allowed", async () => {
const fetchMock = vi.fn(async () => {
return new Response("{}", {
status: 200,
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow(
"Absolute Matrix endpoint is blocked by default",
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks cross-protocol redirects when absolute endpoints are allowed", async () => {
const fetchMock = vi.fn(async () => {
return new Response("", {
status: 302,
headers: {
location: "http://evil.example.org/next",
},
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(
client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, {
allowAbsoluteEndpoint: true,
}),
).rejects.toThrow("Blocked cross-protocol redirect");
});
it("strips authorization when redirect crosses origin", async () => {
const calls: Array<{ url: string; headers: Headers }> = [];
const fetchMock = vi.fn(async (url: URL | string, init?: RequestInit) => {
calls.push({
url: String(url),
headers: new Headers(init?.headers),
});
if (calls.length === 1) {
return new Response("", {
status: 302,
headers: { location: "https://cdn.example.org/next" },
});
}
return new Response("{}", {
status: 200,
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token");
await client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, {
allowAbsoluteEndpoint: true,
});
expect(calls).toHaveLength(2);
expect(calls[0]?.url).toBe("https://matrix.example.org/start");
expect(calls[0]?.headers.get("authorization")).toBe("Bearer token");
expect(calls[1]?.url).toBe("https://cdn.example.org/next");
expect(calls[1]?.headers.get("authorization")).toBeNull();
});
it("aborts requests after timeout", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn((_: URL | string, init?: RequestInit) => {
return new Promise<Response>((_, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new Error("aborted"));
});
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
localTimeoutMs: 25,
});
const pending = client.doRequest("GET", "/_matrix/client/v3/account/whoami");
const assertion = expect(pending).rejects.toThrow("aborted");
await vi.advanceTimersByTimeAsync(30);
await assertion;
});
});
describe("MatrixClient event bridge", () => {
beforeEach(() => {
matrixJsClient = createMatrixJsClientStub();
lastCreateClientOpts = null;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("emits room.message only after encrypted events decrypt", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
const messageEvents: Array<{ roomId: string; type: string }> = [];
client.on("room.message", (roomId, event) => {
messageEvents.push({ roomId, type: event.type });
});
await client.start();
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
});
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.emit("event", encrypted);
expect(messageEvents).toHaveLength(0);
encrypted.emit("decrypted", decrypted);
// Simulate a second normal event emission from the SDK after decryption.
matrixJsClient.emit("event", decrypted);
expect(messageEvents).toEqual([
{
roomId: "!room:example.org",
type: "m.room.message",
},
]);
});
it("emits room.failed_decryption when decrypting fails", async () => {
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);
});
await client.start();
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
});
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.emit("event", encrypted);
encrypted.emit("decrypted", decrypted, new Error("decrypt failed"));
expect(failed).toEqual(["decrypt failed"]);
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<string, (...args: unknown[]) => 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("stops decryption retries after hitting retry cap", async () => {
vi.useFakeTimers();
const client = new MatrixClient("https://matrix.example.org", "token");
const failed: string[] = [];
client.on("room.failed_decryption", (_roomId, _event, error) => {
failed.push(error.message);
});
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
decryptionFailure: true,
});
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
throw new Error("still missing key");
});
await client.start();
matrixJsClient.emit("event", encrypted);
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
expect(failed).toEqual(["missing room key"]);
await vi.advanceTimersByTimeAsync(200_000);
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8);
await vi.advanceTimersByTimeAsync(200_000);
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8);
});
it("does not start duplicate retries when crypto signals fire while retry is in-flight", async () => {
vi.useFakeTimers();
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
const delivered: string[] = [];
const cryptoListeners = new Map<string, (...args: unknown[]) => 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.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",
},
});
let releaseRetry: (() => void) | null = null;
matrixJsClient.decryptEventIfNeeded = vi.fn(
async () =>
await new Promise<void>((resolve) => {
releaseRetry = () => {
encrypted.emit("decrypted", decrypted);
resolve();
};
}),
);
await client.start();
matrixJsClient.emit("event", encrypted);
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached");
expect(trigger).toBeTypeOf("function");
trigger?.();
trigger?.();
await Promise.resolve();
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
releaseRetry?.();
await Promise.resolve();
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[] = [];
client.on("room.invite", (roomId) => {
invites.push(roomId);
});
await client.start();
const inviteMembership = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$invite",
sender: "@alice:example.org",
type: "m.room.member",
ts: Date.now(),
stateKey: "@bot:example.org",
content: {
membership: "invite",
},
});
matrixJsClient.emit("event", inviteMembership);
expect(invites).toEqual(["!room:example.org"]);
});
it("emits room.invite when SDK emits Room event with invite membership", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
const invites: string[] = [];
client.on("room.invite", (roomId) => {
invites.push(roomId);
});
await client.start();
matrixJsClient.emit("Room", {
roomId: "!invite:example.org",
getMyMembership: () => "invite",
});
expect(invites).toEqual(["!invite:example.org"]);
});
it("replays outstanding invite rooms at startup", async () => {
matrixJsClient.getRooms = vi.fn(() => [
{
roomId: "!pending:example.org",
getMyMembership: () => "invite",
},
{
roomId: "!joined:example.org",
getMyMembership: () => "join",
},
]);
const client = new MatrixClient("https://matrix.example.org", "token");
const invites: string[] = [];
client.on("room.invite", (roomId) => {
invites.push(roomId);
});
await client.start();
expect(invites).toEqual(["!pending: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(
expect.objectContaining({
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
});
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<string, unknown> },
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);
});
});

View File

@@ -0,0 +1,527 @@
// Polyfill IndexedDB for WASM crypto in Node.js
import "fake-indexeddb/auto";
import {
ClientEvent,
createClient as createMatrixJsClient,
type MatrixClient as MatrixJsClient,
type MatrixEvent,
} from "matrix-js-sdk";
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
import { EventEmitter } from "node:events";
import type {
MatrixClientEventMap,
MatrixCryptoBootstrapApi,
MatrixRawEvent,
MessageEventContent,
} from "./sdk/types.js";
import { MatrixCryptoBootstrapper } from "./sdk/crypto-bootstrap.js";
import { createMatrixCryptoFacade, type MatrixCryptoFacade } from "./sdk/crypto-facade.js";
import { MatrixDecryptBridge } from "./sdk/decrypt-bridge.js";
import { matrixEventToRaw, parseMxc } from "./sdk/event-helpers.js";
import { MatrixAuthedHttpClient } from "./sdk/http-client.js";
import { persistIdbToDisk, restoreIdbFromDisk } from "./sdk/idb-persistence.js";
import { ConsoleLogger, LogService, noop } from "./sdk/logger.js";
import { MatrixRecoveryKeyStore } from "./sdk/recovery-key-store.js";
import { type HttpMethod, type QueryParams } from "./sdk/transport.js";
import { MatrixVerificationManager } from "./sdk/verification-manager.js";
export { ConsoleLogger, LogService };
export type {
DimensionalFileInfo,
FileWithThumbnailInfo,
TimedFileInfo,
VideoFileInfo,
} from "./sdk/types.js";
export type {
EncryptedFile,
LocationMessageEventContent,
MessageEventContent,
TextualMessageEventContent,
} from "./sdk/types.js";
export class MatrixClient {
private readonly client: MatrixJsClient;
private readonly emitter = new EventEmitter();
private readonly httpClient: MatrixAuthedHttpClient;
private readonly localTimeoutMs: number;
private readonly initialSyncLimit?: number;
private readonly encryptionEnabled: boolean;
private readonly idbSnapshotPath?: string;
private readonly cryptoDatabasePrefix?: string;
private bridgeRegistered = false;
private started = false;
private selfUserId: string | null;
private readonly dmRoomIds = new Set<string>();
private cryptoInitialized = false;
private readonly decryptBridge: MatrixDecryptBridge<MatrixRawEvent>;
private readonly verificationManager = new MatrixVerificationManager();
private readonly recoveryKeyStore: MatrixRecoveryKeyStore;
private readonly cryptoBootstrapper: MatrixCryptoBootstrapper<MatrixRawEvent>;
readonly dms = {
update: async (): Promise<void> => {
await this.refreshDmCache();
},
isDm: (roomId: string): boolean => this.dmRoomIds.has(roomId),
};
crypto?: MatrixCryptoFacade;
constructor(
homeserver: string,
accessToken: string,
_storage?: unknown,
_cryptoStorage?: unknown,
opts: {
userId?: string;
password?: string;
deviceId?: string;
localTimeoutMs?: number;
encryption?: boolean;
initialSyncLimit?: number;
recoveryKeyPath?: string;
idbSnapshotPath?: string;
cryptoDatabasePrefix?: string;
} = {},
) {
this.httpClient = new MatrixAuthedHttpClient(homeserver, accessToken);
this.localTimeoutMs = Math.max(1, opts.localTimeoutMs ?? 60_000);
this.initialSyncLimit = opts.initialSyncLimit;
this.encryptionEnabled = opts.encryption === true;
this.idbSnapshotPath = opts.idbSnapshotPath;
this.cryptoDatabasePrefix = opts.cryptoDatabasePrefix;
this.selfUserId = opts.userId?.trim() || null;
this.recoveryKeyStore = new MatrixRecoveryKeyStore(opts.recoveryKeyPath);
const cryptoCallbacks = this.encryptionEnabled
? this.recoveryKeyStore.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<MatrixRawEvent>({
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);
},
});
this.cryptoBootstrapper = new MatrixCryptoBootstrapper<MatrixRawEvent>({
getUserId: () => this.getUserId(),
getPassword: () => opts.password,
getDeviceId: () => this.client.getDeviceId(),
verificationManager: this.verificationManager,
recoveryKeyStore: this.recoveryKeyStore,
decryptBridge: this.decryptBridge,
});
if (this.encryptionEnabled) {
this.crypto = createMatrixCryptoFacade({
client: this.client,
verificationManager: this.verificationManager,
recoveryKeyStore: this.recoveryKeyStore,
getRoomStateEvent: (roomId, eventType, stateKey = "") =>
this.getRoomStateEvent(roomId, eventType, stateKey),
downloadContent: (mxcUrl) => this.downloadContent(mxcUrl),
});
}
}
on<TEvent extends keyof MatrixClientEventMap>(
eventName: TEvent,
listener: (...args: MatrixClientEventMap[TEvent]) => void,
): this;
on(eventName: string, listener: (...args: unknown[]) => void): this;
on(eventName: string, listener: (...args: unknown[]) => void): this {
this.emitter.on(eventName, listener as (...args: unknown[]) => void);
return this;
}
off<TEvent extends keyof MatrixClientEventMap>(
eventName: TEvent,
listener: (...args: MatrixClientEventMap[TEvent]) => void,
): this;
off(eventName: string, listener: (...args: unknown[]) => void): this;
off(eventName: string, listener: (...args: unknown[]) => void): this {
this.emitter.off(eventName, listener as (...args: unknown[]) => void);
return this;
}
private idbPersistTimer: ReturnType<typeof setInterval> | null = null;
async start(): Promise<void> {
if (this.started) {
return;
}
this.registerBridge();
await this.initializeCryptoIfNeeded();
await this.client.startClient({
initialSyncLimit: this.initialSyncLimit,
});
this.started = true;
this.emitOutstandingInviteEvents();
await this.refreshDmCache().catch(noop);
}
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 async initializeCryptoIfNeeded(): Promise<void> {
if (!this.encryptionEnabled || this.cryptoInitialized) {
return;
}
// Restore persisted IndexedDB crypto store before initializing WASM crypto.
await restoreIdbFromDisk(this.idbSnapshotPath);
try {
await this.client.initRustCrypto({
cryptoDatabasePrefix: this.cryptoDatabasePrefix,
});
this.cryptoInitialized = true;
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (crypto) {
await this.cryptoBootstrapper.bootstrap(crypto);
}
// Persist the crypto store after successful init (captures fresh keys on first run).
await persistIdbToDisk({
snapshotPath: this.idbSnapshotPath,
databasePrefix: this.cryptoDatabasePrefix,
});
// Periodically persist to capture new Olm sessions and room keys.
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);
}
}
async getUserId(): Promise<string> {
const fromClient = this.client.getUserId();
if (fromClient) {
this.selfUserId = fromClient;
return fromClient;
}
if (this.selfUserId) {
return this.selfUserId;
}
const whoami = (await this.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
user_id?: string;
};
const resolved = whoami.user_id?.trim();
if (!resolved) {
throw new Error("Matrix whoami did not return user_id");
}
this.selfUserId = resolved;
return resolved;
}
async getJoinedRooms(): Promise<string[]> {
const joined = await this.client.getJoinedRooms();
return Array.isArray(joined.joined_rooms) ? joined.joined_rooms : [];
}
async getJoinedRoomMembers(roomId: string): Promise<string[]> {
const members = await this.client.getJoinedRoomMembers(roomId);
const joined = members?.joined;
if (!joined || typeof joined !== "object") {
return [];
}
return Object.keys(joined);
}
async getRoomStateEvent(
roomId: string,
eventType: string,
stateKey = "",
): Promise<Record<string, unknown>> {
const state = await this.client.getStateEvent(roomId, eventType, stateKey);
return (state ?? {}) as Record<string, unknown>;
}
async getAccountData(eventType: string): Promise<Record<string, unknown> | undefined> {
const event = this.client.getAccountData(eventType);
return (event?.getContent() as Record<string, unknown> | undefined) ?? undefined;
}
async setAccountData(eventType: string, content: Record<string, unknown>): Promise<void> {
await this.client.setAccountData(eventType as never, content as never);
await this.refreshDmCache().catch(noop);
}
async resolveRoom(aliasOrRoomId: string): Promise<string | null> {
if (aliasOrRoomId.startsWith("!")) {
return aliasOrRoomId;
}
if (!aliasOrRoomId.startsWith("#")) {
return aliasOrRoomId;
}
try {
const resolved = await this.client.getRoomIdForAlias(aliasOrRoomId);
return resolved.room_id ?? null;
} catch {
return null;
}
}
async sendMessage(roomId: string, content: MessageEventContent): Promise<string> {
const sent = await this.client.sendMessage(roomId, content as never);
return sent.event_id;
}
async sendEvent(
roomId: string,
eventType: string,
content: Record<string, unknown>,
): Promise<string> {
const sent = await this.client.sendEvent(roomId, eventType as never, content as never);
return sent.event_id;
}
async sendStateEvent(
roomId: string,
eventType: string,
stateKey: string,
content: Record<string, unknown>,
): Promise<string> {
const sent = await this.client.sendStateEvent(
roomId,
eventType as never,
content as never,
stateKey,
);
return sent.event_id;
}
async redactEvent(roomId: string, eventId: string, reason?: string): Promise<string> {
const sent = await this.client.redactEvent(
roomId,
eventId,
undefined,
reason?.trim() ? { reason } : undefined,
);
return sent.event_id;
}
async doRequest(
method: HttpMethod,
endpoint: string,
qs?: QueryParams,
body?: unknown,
opts?: { allowAbsoluteEndpoint?: boolean },
): Promise<unknown> {
return await this.httpClient.requestJson({
method,
endpoint,
qs,
body,
timeoutMs: this.localTimeoutMs,
allowAbsoluteEndpoint: opts?.allowAbsoluteEndpoint,
});
}
async getUserProfile(userId: string): Promise<{ displayname?: string; avatar_url?: string }> {
return await this.client.getProfileInfo(userId);
}
async joinRoom(roomId: string): Promise<void> {
await this.client.joinRoom(roomId);
}
mxcToHttp(mxcUrl: string): string | null {
return this.client.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, true, false, true);
}
async downloadContent(mxcUrl: string, allowRemote = true): Promise<Buffer> {
const parsed = parseMxc(mxcUrl);
if (!parsed) {
throw new Error(`Invalid Matrix content URI: ${mxcUrl}`);
}
const endpoint = `/_matrix/media/v3/download/${encodeURIComponent(parsed.server)}/${encodeURIComponent(parsed.mediaId)}`;
const response = await this.httpClient.requestRaw({
method: "GET",
endpoint,
qs: { allow_remote: allowRemote },
timeoutMs: this.localTimeoutMs,
});
return response;
}
async uploadContent(file: Buffer, contentType?: string, filename?: string): Promise<string> {
const uploaded = await this.client.uploadContent(file, {
type: contentType || "application/octet-stream",
name: filename,
includeFilename: Boolean(filename),
});
return uploaded.content_uri;
}
async getEvent(roomId: string, eventId: string): Promise<Record<string, unknown>> {
return (await this.client.fetchRoomEvent(roomId, eventId)) as Record<string, unknown>;
}
async setTyping(roomId: string, typing: boolean, timeoutMs: number): Promise<void> {
await this.client.sendTyping(roomId, typing, timeoutMs);
}
async sendReadReceipt(roomId: string, eventId: string): Promise<void> {
await this.httpClient.requestJson({
method: "POST",
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/receipt/m.read/${encodeURIComponent(
eventId,
)}`,
body: {},
timeoutMs: this.localTimeoutMs,
});
}
private registerBridge(): void {
if (this.bridgeRegistered) {
return;
}
this.bridgeRegistered = true;
this.client.on(ClientEvent.Event, (event: MatrixEvent) => {
const roomId = event.getRoomId();
if (!roomId) {
return;
}
const raw = matrixEventToRaw(event);
const isEncryptedEvent = raw.type === "m.room.encrypted";
this.emitter.emit("room.event", roomId, raw);
if (isEncryptedEvent) {
this.emitter.emit("room.encrypted_event", roomId, raw);
} else {
if (this.decryptBridge.shouldEmitUnencryptedMessage(roomId, raw.event_id)) {
this.emitter.emit("room.message", roomId, raw);
}
}
const stateKey = raw.state_key ?? "";
const selfUserId = this.client.getUserId() ?? this.selfUserId ?? "";
const membership =
raw.type === "m.room.member"
? (raw.content as { membership?: string }).membership
: undefined;
if (stateKey && selfUserId && stateKey === selfUserId) {
if (membership === "invite") {
this.emitter.emit("room.invite", roomId, raw);
} else if (membership === "join") {
this.emitter.emit("room.join", roomId, raw);
}
}
if (isEncryptedEvent) {
this.decryptBridge.attachEncryptedEvent(event, roomId);
}
});
// Some SDK invite transitions are surfaced as room lifecycle events instead of raw timeline events.
this.client.on(ClientEvent.Room, (room) => {
this.emitMembershipForRoom(room);
});
}
private emitMembershipForRoom(room: unknown): void {
const roomObj = room as {
roomId?: string;
getMyMembership?: () => string | null | undefined;
selfMembership?: string | null | undefined;
};
const roomId = roomObj.roomId?.trim();
if (!roomId) {
return;
}
const membership = roomObj.getMyMembership?.() ?? roomObj.selfMembership ?? undefined;
const selfUserId = this.client.getUserId() ?? this.selfUserId ?? "";
if (!selfUserId) {
return;
}
const raw: MatrixRawEvent = {
type: "m.room.member",
room_id: roomId,
sender: selfUserId,
state_key: selfUserId,
content: { membership },
origin_server_ts: Date.now(),
unsigned: { age: 0 },
};
if (membership === "invite") {
this.emitter.emit("room.invite", roomId, raw);
return;
}
if (membership === "join") {
this.emitter.emit("room.join", roomId, raw);
}
}
private emitOutstandingInviteEvents(): void {
const listRooms = (this.client as { getRooms?: () => unknown[] }).getRooms;
if (typeof listRooms !== "function") {
return;
}
const rooms = listRooms.call(this.client);
if (!Array.isArray(rooms)) {
return;
}
for (const room of rooms) {
this.emitMembershipForRoom(room);
}
}
private async refreshDmCache(): Promise<void> {
const direct = await this.getAccountData("m.direct");
this.dmRoomIds.clear();
if (!direct || typeof direct !== "object") {
return;
}
for (const value of Object.values(direct)) {
if (!Array.isArray(value)) {
continue;
}
for (const roomId of value) {
if (typeof roomId === "string" && roomId.trim()) {
this.dmRoomIds.add(roomId);
}
}
}
}
}

View File

@@ -0,0 +1,241 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js";
import { MatrixCryptoBootstrapper, type MatrixCryptoBootstrapperDeps } from "./crypto-bootstrap.js";
function createBootstrapperDeps() {
return {
getUserId: vi.fn(async () => "@bot:example.org"),
getPassword: vi.fn(() => "super-secret-password"),
getDeviceId: vi.fn(() => "DEVICE123"),
verificationManager: {
trackVerificationRequest: vi.fn(),
},
recoveryKeyStore: {
bootstrapSecretStorageWithRecoveryKey: vi.fn(async () => {}),
},
decryptBridge: {
bindCryptoRetrySignals: vi.fn(),
},
};
}
function createCryptoApi(overrides?: Partial<MatrixCryptoBootstrapApi>): MatrixCryptoBootstrapApi {
return {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
...overrides,
};
}
describe("MatrixCryptoBootstrapper", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("bootstraps cross-signing/secret-storage and binds decrypt retry signals", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
expect(crypto.bootstrapCrossSigning).toHaveBeenCalledWith(
expect.objectContaining({
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
crypto,
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledTimes(2);
expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledWith(crypto);
});
it("forces new cross-signing keys only when readiness check still fails", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi.fn(async () => {});
const crypto = createCryptoApi({
bootstrapCrossSigning,
isCrossSigningReady: vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true),
userHasCrossSigningKeys: vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
});
it("uses password UIA fallback when null and dummy auth fail", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi.fn(async () => {});
const crypto = createCryptoApi({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
const firstCall = bootstrapCrossSigning.mock.calls[0]?.[0] as {
authUploadDeviceSigningKeys?: <T>(
makeRequest: (authData: Record<string, unknown> | null) => Promise<T>,
) => Promise<T>;
};
expect(firstCall.authUploadDeviceSigningKeys).toBeTypeOf("function");
const seenAuthStages: Array<Record<string, unknown> | null> = [];
const result = await firstCall.authUploadDeviceSigningKeys?.(async (authData) => {
seenAuthStages.push(authData);
if (authData === null) {
throw new Error("need auth");
}
if (authData.type === "m.login.dummy") {
throw new Error("dummy rejected");
}
if (authData.type === "m.login.password") {
return "ok";
}
throw new Error("unexpected auth stage");
});
expect(result).toBe("ok");
expect(seenAuthStages).toEqual([
null,
{ type: "m.login.dummy" },
{
type: "m.login.password",
identifier: { type: "m.id.user", user: "@bot:example.org" },
password: "super-secret-password",
},
]);
});
it("resets cross-signing when first bootstrap attempt throws", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("first attempt failed"))
.mockResolvedValueOnce(undefined);
const crypto = createCryptoApi({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
});
it("marks own device verified and cross-signs it when needed", async () => {
const deps = createBootstrapperDeps();
const setDeviceVerified = vi.fn(async () => {});
const crossSignDevice = vi.fn(async () => {});
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: false,
})),
setDeviceVerified,
crossSignDevice,
isCrossSigningReady: vi.fn(async () => true),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true);
expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123");
});
it("auto-accepts incoming verification requests from other users", async () => {
const deps = createBootstrapperDeps();
const listeners = new Map<string, (...args: unknown[]) => void>();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
}),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
const verificationRequest = {
otherUserId: "@alice:example.org",
isSelfVerification: false,
initiatedByMe: false,
accept: vi.fn(async () => {}),
};
const listener = Array.from(listeners.entries()).find(([eventName]) =>
eventName.toLowerCase().includes("verificationrequest"),
)?.[1];
expect(listener).toBeTypeOf("function");
await listener?.(verificationRequest);
expect(deps.verificationManager.trackVerificationRequest).toHaveBeenCalledWith(
verificationRequest,
);
expect(verificationRequest.accept).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,226 @@
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
import type { MatrixDecryptBridge } from "./decrypt-bridge.js";
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type {
MatrixAuthDict,
MatrixCryptoBootstrapApi,
MatrixRawEvent,
MatrixUiAuthCallback,
} from "./types.js";
import type {
MatrixVerificationManager,
MatrixVerificationRequestLike,
} from "./verification-manager.js";
import { LogService } from "./logger.js";
export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
getUserId: () => Promise<string>;
getPassword?: () => string | undefined;
getDeviceId: () => string | null | undefined;
verificationManager: MatrixVerificationManager;
recoveryKeyStore: MatrixRecoveryKeyStore;
decryptBridge: Pick<MatrixDecryptBridge<TRawEvent>, "bindCryptoRetrySignals">;
};
export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
constructor(private readonly deps: MatrixCryptoBootstrapperDeps<TRawEvent>) {}
async bootstrap(crypto: MatrixCryptoBootstrapApi): Promise<void> {
await this.bootstrapSecretStorage(crypto);
await this.bootstrapCrossSigning(crypto);
await this.bootstrapSecretStorage(crypto);
await this.ensureOwnDeviceTrust(crypto);
this.registerVerificationRequestHandler(crypto);
}
private createSigningKeysUiAuthCallback(params: {
userId: string;
password?: string;
}): MatrixUiAuthCallback {
return async <T>(makeRequest: (authData: MatrixAuthDict | null) => Promise<T>): Promise<T> => {
try {
return await makeRequest(null);
} catch {
// Some homeservers require an explicit dummy UIA stage even when no user interaction is needed.
try {
return await makeRequest({ type: "m.login.dummy" });
} catch {
if (!params.password?.trim()) {
throw new Error(
"Matrix cross-signing key upload requires UIA; provide matrix.password for m.login.password fallback",
);
}
return await makeRequest({
type: "m.login.password",
identifier: { type: "m.id.user", user: params.userId },
password: params.password,
});
}
}
};
}
private async bootstrapCrossSigning(crypto: MatrixCryptoBootstrapApi): Promise<void> {
const userId = await this.deps.getUserId();
const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({
userId,
password: this.deps.getPassword?.(),
});
const hasPublishedCrossSigningKeys = async (): Promise<boolean> => {
if (typeof crypto.userHasCrossSigningKeys !== "function") {
return true;
}
try {
return await crypto.userHasCrossSigningKeys(userId, true);
} catch {
return false;
}
};
const isCrossSigningReady = async (): Promise<boolean> => {
if (typeof crypto.isCrossSigningReady !== "function") {
return true;
}
try {
return await crypto.isCrossSigningReady();
} catch {
return false;
}
};
// First pass: preserve existing cross-signing identity and ensure public keys are uploaded.
try {
await crypto.bootstrapCrossSigning({
authUploadDeviceSigningKeys,
});
} catch (err) {
LogService.warn(
"MatrixClientLite",
"Initial cross-signing bootstrap failed, trying reset:",
err,
);
try {
await crypto.bootstrapCrossSigning({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys,
});
} catch (resetErr) {
LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", resetErr);
return;
}
}
const firstPassReady = await isCrossSigningReady();
const firstPassPublished = await hasPublishedCrossSigningKeys();
if (firstPassReady && firstPassPublished) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return;
}
// Fallback: recover from broken local/server state by creating a fresh identity.
try {
await crypto.bootstrapCrossSigning({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys,
});
} catch (err) {
LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err);
return;
}
const finalReady = await isCrossSigningReady();
const finalPublished = await hasPublishedCrossSigningKeys();
if (finalReady && finalPublished) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return;
}
LogService.warn(
"MatrixClientLite",
"Cross-signing bootstrap finished but server keys are still not published",
);
}
private async bootstrapSecretStorage(crypto: MatrixCryptoBootstrapApi): Promise<void> {
try {
await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto);
LogService.info("MatrixClientLite", "Secret storage bootstrap complete");
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err);
}
}
private registerVerificationRequestHandler(crypto: MatrixCryptoBootstrapApi): void {
// Auto-accept incoming verification requests from other users/devices.
crypto.on(CryptoEvent.VerificationRequestReceived, async (request) => {
const verificationRequest = request as MatrixVerificationRequestLike;
this.deps.verificationManager.trackVerificationRequest(verificationRequest);
const otherUserId = verificationRequest.otherUserId;
const isSelfVerification = verificationRequest.isSelfVerification;
const initiatedByMe = verificationRequest.initiatedByMe;
if (isSelfVerification || initiatedByMe) {
LogService.debug(
"MatrixClientLite",
`Ignoring ${isSelfVerification ? "self" : "initiated"} verification request from ${otherUserId}`,
);
return;
}
try {
LogService.info(
"MatrixClientLite",
`Auto-accepting verification request from ${otherUserId}`,
);
await verificationRequest.accept();
LogService.info(
"MatrixClientLite",
`Verification request from ${otherUserId} accepted, waiting for SAS...`,
);
} catch (err) {
LogService.warn(
"MatrixClientLite",
`Failed to auto-accept verification from ${otherUserId}:`,
err,
);
}
});
this.deps.decryptBridge.bindCryptoRetrySignals(crypto);
LogService.info("MatrixClientLite", "Verification request handler registered");
}
private async ensureOwnDeviceTrust(crypto: MatrixCryptoBootstrapApi): Promise<void> {
const deviceId = this.deps.getDeviceId()?.trim();
if (!deviceId) {
return;
}
const userId = await this.deps.getUserId();
const deviceStatus =
typeof crypto.getDeviceVerificationStatus === "function"
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
: null;
const alreadyVerified =
deviceStatus?.isVerified?.() === true ||
deviceStatus?.localVerified === true ||
deviceStatus?.crossSigningVerified === true ||
deviceStatus?.signedByOwner === true;
if (alreadyVerified) {
return;
}
if (typeof crypto.setDeviceVerified === "function") {
await crypto.setDeviceVerified(userId, deviceId, true);
}
if (typeof crypto.crossSignDevice === "function") {
const crossSigningReady =
typeof crypto.isCrossSigningReady === "function"
? await crypto.isCrossSigningReady()
: true;
if (crossSigningReady) {
await crypto.crossSignDevice(deviceId);
}
}
}
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from "vitest";
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type { MatrixVerificationManager } from "./verification-manager.js";
import { createMatrixCryptoFacade } from "./crypto-facade.js";
describe("createMatrixCryptoFacade", () => {
it("detects encrypted rooms from cached room state", async () => {
const facade = createMatrixCryptoFacade({
client: {
getRoom: () => ({
hasEncryptionStateEvent: () => true,
}),
getCrypto: () => undefined,
},
verificationManager: {
requestOwnUserVerification: vi.fn(),
listVerifications: vi.fn(async () => []),
requestVerification: vi.fn(),
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
} as unknown as MatrixVerificationManager,
recoveryKeyStore: {
getRecoveryKeySummary: vi.fn(() => null),
} as unknown as MatrixRecoveryKeyStore,
getRoomStateEvent: vi.fn(async () => ({ algorithm: "m.megolm.v1.aes-sha2" })),
downloadContent: vi.fn(async () => Buffer.alloc(0)),
});
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
});
it("falls back to server room state when room cache has no encryption event", async () => {
const getRoomStateEvent = vi.fn(async () => ({
algorithm: "m.megolm.v1.aes-sha2",
}));
const facade = createMatrixCryptoFacade({
client: {
getRoom: () => ({
hasEncryptionStateEvent: () => false,
}),
getCrypto: () => undefined,
},
verificationManager: {
requestOwnUserVerification: vi.fn(),
listVerifications: vi.fn(async () => []),
requestVerification: vi.fn(),
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
} as unknown as MatrixVerificationManager,
recoveryKeyStore: {
getRecoveryKeySummary: vi.fn(() => null),
} as unknown as MatrixRecoveryKeyStore,
getRoomStateEvent,
downloadContent: vi.fn(async () => Buffer.alloc(0)),
});
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
expect(getRoomStateEvent).toHaveBeenCalledWith("!room:example.org", "m.room.encryption", "");
});
it("forwards verification requests and uses client crypto API", async () => {
const crypto = { requestOwnUserVerification: vi.fn(async () => null) };
const requestVerification = vi.fn(async () => ({
id: "verification-1",
otherUserId: "@alice:example.org",
isSelfVerification: false,
initiatedByMe: true,
phase: 2,
phaseName: "ready",
pending: true,
methods: ["m.sas.v1"],
canAccept: false,
hasSas: false,
hasReciprocateQr: false,
completed: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
const facade = createMatrixCryptoFacade({
client: {
getRoom: () => null,
getCrypto: () => crypto,
},
verificationManager: {
requestOwnUserVerification: vi.fn(async () => null),
listVerifications: vi.fn(async () => []),
requestVerification,
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
} as unknown as MatrixVerificationManager,
recoveryKeyStore: {
getRecoveryKeySummary: vi.fn(() => ({ keyId: "KEY" })),
} as unknown as MatrixRecoveryKeyStore,
getRoomStateEvent: vi.fn(async () => ({})),
downloadContent: vi.fn(async () => Buffer.alloc(0)),
});
const result = await facade.requestVerification({
userId: "@alice:example.org",
deviceId: "DEVICE",
});
expect(requestVerification).toHaveBeenCalledWith(crypto, {
userId: "@alice:example.org",
deviceId: "DEVICE",
});
expect(result.id).toBe("verification-1");
await expect(facade.getRecoveryKey()).resolves.toMatchObject({ keyId: "KEY" });
});
});

View File

@@ -0,0 +1,173 @@
import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs";
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type { EncryptedFile } from "./types.js";
import type {
MatrixVerificationCryptoApi,
MatrixVerificationManager,
MatrixVerificationMethod,
MatrixVerificationSummary,
} from "./verification-manager.js";
type MatrixCryptoFacadeClient = {
getRoom: (roomId: string) => { hasEncryptionStateEvent: () => boolean } | null;
getCrypto: () => unknown;
};
export type MatrixCryptoFacade = {
prepare: (joinedRooms: string[]) => Promise<void>;
updateSyncData: (
toDeviceMessages: unknown,
otkCounts: unknown,
unusedFallbackKeyAlgs: unknown,
changedDeviceLists: unknown,
leftDeviceLists: unknown,
) => Promise<void>;
isRoomEncrypted: (roomId: string) => Promise<boolean>;
requestOwnUserVerification: () => Promise<unknown | null>;
encryptMedia: (buffer: Buffer) => Promise<{ buffer: Buffer; file: Omit<EncryptedFile, "url"> }>;
decryptMedia: (file: EncryptedFile) => Promise<Buffer>;
getRecoveryKey: () => Promise<{
encodedPrivateKey?: string;
keyId?: string | null;
createdAt?: string;
} | null>;
listVerifications: () => Promise<MatrixVerificationSummary[]>;
requestVerification: (params: {
ownUser?: boolean;
userId?: string;
deviceId?: string;
roomId?: string;
}) => Promise<MatrixVerificationSummary>;
acceptVerification: (id: string) => Promise<MatrixVerificationSummary>;
cancelVerification: (
id: string,
params?: { reason?: string; code?: string },
) => Promise<MatrixVerificationSummary>;
startVerification: (
id: string,
method?: MatrixVerificationMethod,
) => Promise<MatrixVerificationSummary>;
generateVerificationQr: (id: string) => Promise<{ qrDataBase64: string }>;
scanVerificationQr: (id: string, qrDataBase64: string) => Promise<MatrixVerificationSummary>;
confirmVerificationSas: (id: string) => Promise<MatrixVerificationSummary>;
mismatchVerificationSas: (id: string) => Promise<MatrixVerificationSummary>;
confirmVerificationReciprocateQr: (id: string) => Promise<MatrixVerificationSummary>;
getVerificationSas: (
id: string,
) => Promise<{ decimal?: [number, number, number]; emoji?: Array<[string, string]> }>;
};
export function createMatrixCryptoFacade(deps: {
client: MatrixCryptoFacadeClient;
verificationManager: MatrixVerificationManager;
recoveryKeyStore: MatrixRecoveryKeyStore;
getRoomStateEvent: (
roomId: string,
eventType: string,
stateKey?: string,
) => Promise<Record<string, unknown>>;
downloadContent: (mxcUrl: string) => Promise<Buffer>;
}): MatrixCryptoFacade {
return {
prepare: async (_joinedRooms: string[]) => {
// matrix-js-sdk performs crypto prep during startup; no extra work required here.
},
updateSyncData: async (
_toDeviceMessages: unknown,
_otkCounts: unknown,
_unusedFallbackKeyAlgs: unknown,
_changedDeviceLists: unknown,
_leftDeviceLists: unknown,
) => {
// compatibility no-op
},
isRoomEncrypted: async (roomId: string): Promise<boolean> => {
const room = deps.client.getRoom(roomId);
if (room?.hasEncryptionStateEvent()) {
return true;
}
try {
const event = await deps.getRoomStateEvent(roomId, "m.room.encryption", "");
return typeof event.algorithm === "string" && event.algorithm.length > 0;
} catch {
return false;
}
},
requestOwnUserVerification: async (): Promise<unknown | null> => {
const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined;
return await deps.verificationManager.requestOwnUserVerification(crypto);
},
encryptMedia: async (
buffer: Buffer,
): Promise<{ buffer: Buffer; file: Omit<EncryptedFile, "url"> }> => {
const encrypted = Attachment.encrypt(new Uint8Array(buffer));
const mediaInfoJson = encrypted.mediaEncryptionInfo;
if (!mediaInfoJson) {
throw new Error("Matrix media encryption failed: missing media encryption info");
}
const parsed = JSON.parse(mediaInfoJson) as EncryptedFile;
return {
buffer: Buffer.from(encrypted.encryptedData),
file: {
key: parsed.key,
iv: parsed.iv,
hashes: parsed.hashes,
v: parsed.v,
},
};
},
decryptMedia: async (file: EncryptedFile): Promise<Buffer> => {
const encrypted = await deps.downloadContent(file.url);
const metadata: EncryptedFile = {
url: file.url,
key: file.key,
iv: file.iv,
hashes: file.hashes,
v: file.v,
};
const attachment = new EncryptedAttachment(
new Uint8Array(encrypted),
JSON.stringify(metadata),
);
const decrypted = Attachment.decrypt(attachment);
return Buffer.from(decrypted);
},
getRecoveryKey: async () => {
return deps.recoveryKeyStore.getRecoveryKeySummary();
},
listVerifications: async () => {
return deps.verificationManager.listVerifications();
},
requestVerification: async (params) => {
const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined;
return await deps.verificationManager.requestVerification(crypto, params);
},
acceptVerification: async (id) => {
return await deps.verificationManager.acceptVerification(id);
},
cancelVerification: async (id, params) => {
return await deps.verificationManager.cancelVerification(id, params);
},
startVerification: async (id, method = "sas") => {
return await deps.verificationManager.startVerification(id, method);
},
generateVerificationQr: async (id) => {
return await deps.verificationManager.generateVerificationQr(id);
},
scanVerificationQr: async (id, qrDataBase64) => {
return await deps.verificationManager.scanVerificationQr(id, qrDataBase64);
},
confirmVerificationSas: async (id) => {
return await deps.verificationManager.confirmVerificationSas(id);
},
mismatchVerificationSas: async (id) => {
return deps.verificationManager.mismatchVerificationSas(id);
},
confirmVerificationReciprocateQr: async (id) => {
return deps.verificationManager.confirmVerificationReciprocateQr(id);
},
getVerificationSas: async (id) => {
return deps.verificationManager.getVerificationSas(id);
},
};
}

View File

@@ -0,0 +1,307 @@
import { MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk";
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
import { LogService, noop } from "./logger.js";
type MatrixDecryptIfNeededClient = {
decryptEventIfNeeded?: (
event: MatrixEvent,
opts?: {
isRetry?: boolean;
},
) => Promise<void>;
};
type MatrixDecryptRetryState = {
event: MatrixEvent;
roomId: string;
eventId: string;
attempts: number;
inFlight: boolean;
timer: ReturnType<typeof setTimeout> | null;
};
type DecryptBridgeRawEvent = {
event_id: string;
};
type MatrixCryptoRetrySignalSource = {
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
};
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<TRawEvent extends DecryptBridgeRawEvent> {
private readonly trackedEncryptedEvents = new WeakSet<object>();
private readonly decryptedMessageDedupe = new Map<string, number>();
private readonly decryptRetries = new Map<string, MatrixDecryptRetryState>();
private readonly failedDecryptionsNotified = new Set<string>();
private cryptoRetrySignalsBound = false;
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);
}
}
bindCryptoRetrySignals(crypto: MatrixCryptoRetrySignalSource | undefined): void {
if (!crypto || this.cryptoRetrySignalsBound) {
return;
}
this.cryptoRetrySignalsBound = true;
const trigger = (reason: string): void => {
this.retryPendingNow(reason);
};
crypto.on(CryptoEvent.KeyBackupDecryptionKeyCached, () => {
trigger("crypto.keyBackupDecryptionKeyCached");
});
crypto.on(CryptoEvent.RehydrationCompleted, () => {
trigger("dehydration.RehydrationCompleted");
});
crypto.on(CryptoEvent.DevicesUpdated, () => {
trigger("crypto.devicesUpdated");
});
crypto.on(CryptoEvent.KeysChanged, () => {
trigger("crossSigning.keysChanged");
});
}
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<void> {
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);
}
}
}

View File

@@ -0,0 +1,60 @@
import type { MatrixEvent } from "matrix-js-sdk";
import { describe, expect, it } from "vitest";
import { buildHttpError, matrixEventToRaw, parseMxc } from "./event-helpers.js";
describe("event-helpers", () => {
it("parses mxc URIs", () => {
expect(parseMxc("mxc://server.example/media-id")).toEqual({
server: "server.example",
mediaId: "media-id",
});
expect(parseMxc("not-mxc")).toBeNull();
});
it("builds HTTP errors from JSON and plain text payloads", () => {
const fromJson = buildHttpError(403, JSON.stringify({ error: "forbidden" }));
expect(fromJson.message).toBe("forbidden");
expect(fromJson.statusCode).toBe(403);
const fromText = buildHttpError(500, "internal failure");
expect(fromText.message).toBe("internal failure");
expect(fromText.statusCode).toBe(500);
});
it("serializes Matrix events and resolves state key from available sources", () => {
const viaGetter = {
getId: () => "$1",
getSender: () => "@alice:example.org",
getType: () => "m.room.member",
getTs: () => 1000,
getContent: () => ({ membership: "join" }),
getUnsigned: () => ({ age: 1 }),
getStateKey: () => "@alice:example.org",
} as unknown as MatrixEvent;
expect(matrixEventToRaw(viaGetter).state_key).toBe("@alice:example.org");
const viaWire = {
getId: () => "$2",
getSender: () => "@bob:example.org",
getType: () => "m.room.member",
getTs: () => 2000,
getContent: () => ({ membership: "join" }),
getUnsigned: () => ({}),
getStateKey: () => undefined,
getWireContent: () => ({ state_key: "@bob:example.org" }),
} as unknown as MatrixEvent;
expect(matrixEventToRaw(viaWire).state_key).toBe("@bob:example.org");
const viaRaw = {
getId: () => "$3",
getSender: () => "@carol:example.org",
getType: () => "m.room.member",
getTs: () => 3000,
getContent: () => ({ membership: "join" }),
getUnsigned: () => ({}),
getStateKey: () => undefined,
event: { state_key: "@carol:example.org" },
} as unknown as MatrixEvent;
expect(matrixEventToRaw(viaRaw).state_key).toBe("@carol:example.org");
});
});

View File

@@ -0,0 +1,71 @@
import type { MatrixEvent } from "matrix-js-sdk";
import type { MatrixRawEvent } from "./types.js";
export function matrixEventToRaw(event: MatrixEvent): MatrixRawEvent {
const unsigned = (event.getUnsigned?.() ?? {}) as {
age?: number;
redacted_because?: unknown;
};
const raw: MatrixRawEvent = {
event_id: event.getId() ?? "",
sender: event.getSender() ?? "",
type: event.getType() ?? "",
origin_server_ts: event.getTs() ?? 0,
content: ((event.getContent?.() ?? {}) as Record<string, unknown>) || {},
unsigned,
};
const stateKey = resolveMatrixStateKey(event);
if (typeof stateKey === "string") {
raw.state_key = stateKey;
}
return raw;
}
export function parseMxc(url: string): { server: string; mediaId: string } | null {
const match = /^mxc:\/\/([^/]+)\/(.+)$/.exec(url.trim());
if (!match) {
return null;
}
return {
server: match[1],
mediaId: match[2],
};
}
export function buildHttpError(
statusCode: number,
bodyText: string,
): Error & { statusCode: number } {
let message = `Matrix HTTP ${statusCode}`;
if (bodyText.trim()) {
try {
const parsed = JSON.parse(bodyText) as { error?: string };
if (typeof parsed.error === "string" && parsed.error.trim()) {
message = parsed.error.trim();
} else {
message = bodyText.slice(0, 500);
}
} catch {
message = bodyText.slice(0, 500);
}
}
return Object.assign(new Error(message), { statusCode });
}
function resolveMatrixStateKey(event: MatrixEvent): string | undefined {
const direct = event.getStateKey?.();
if (typeof direct === "string") {
return direct;
}
const wireContent = (
event as { getWireContent?: () => { state_key?: unknown } }
).getWireContent?.();
if (wireContent && typeof wireContent.state_key === "string") {
return wireContent.state_key;
}
const rawEvent = (event as { event?: { state_key?: unknown } }).event;
if (rawEvent && typeof rawEvent.state_key === "string") {
return rawEvent.state_key;
}
return undefined;
}

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { performMatrixRequestMock } = vi.hoisted(() => ({
performMatrixRequestMock: vi.fn(),
}));
vi.mock("./transport.js", () => ({
performMatrixRequest: performMatrixRequestMock,
}));
import { MatrixAuthedHttpClient } from "./http-client.js";
describe("MatrixAuthedHttpClient", () => {
beforeEach(() => {
performMatrixRequestMock.mockReset();
});
it("parses JSON responses and forwards absolute-endpoint opt-in", async () => {
performMatrixRequestMock.mockResolvedValue({
response: new Response('{"ok":true}', {
status: 200,
headers: { "content-type": "application/json" },
}),
text: '{"ok":true}',
buffer: Buffer.from('{"ok":true}', "utf8"),
});
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
const result = await client.requestJson({
method: "GET",
endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami",
timeoutMs: 5000,
allowAbsoluteEndpoint: true,
});
expect(result).toEqual({ ok: true });
expect(performMatrixRequestMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami",
allowAbsoluteEndpoint: true,
}),
);
});
it("returns plain text when response is not JSON", async () => {
performMatrixRequestMock.mockResolvedValue({
response: new Response("pong", {
status: 200,
headers: { "content-type": "text/plain" },
}),
text: "pong",
buffer: Buffer.from("pong", "utf8"),
});
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
const result = await client.requestJson({
method: "GET",
endpoint: "/_matrix/client/v3/ping",
timeoutMs: 5000,
});
expect(result).toBe("pong");
});
it("returns raw buffers for media requests", async () => {
const payload = Buffer.from([1, 2, 3, 4]);
performMatrixRequestMock.mockResolvedValue({
response: new Response(payload, { status: 200 }),
text: payload.toString("utf8"),
buffer: payload,
});
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
const result = await client.requestRaw({
method: "GET",
endpoint: "/_matrix/media/v3/download/example/id",
timeoutMs: 5000,
});
expect(result).toEqual(payload);
});
it("raises HTTP errors with status code metadata", async () => {
performMatrixRequestMock.mockResolvedValue({
response: new Response(JSON.stringify({ error: "forbidden" }), {
status: 403,
headers: { "content-type": "application/json" },
}),
text: JSON.stringify({ error: "forbidden" }),
buffer: Buffer.from(JSON.stringify({ error: "forbidden" }), "utf8"),
});
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
await expect(
client.requestJson({
method: "GET",
endpoint: "/_matrix/client/v3/rooms",
timeoutMs: 5000,
}),
).rejects.toMatchObject({
message: "forbidden",
statusCode: 403,
});
});
});

View File

@@ -0,0 +1,63 @@
import { buildHttpError } from "./event-helpers.js";
import { type HttpMethod, type QueryParams, performMatrixRequest } from "./transport.js";
export class MatrixAuthedHttpClient {
constructor(
private readonly homeserver: string,
private readonly accessToken: string,
) {}
async requestJson(params: {
method: HttpMethod;
endpoint: string;
qs?: QueryParams;
body?: unknown;
timeoutMs: number;
allowAbsoluteEndpoint?: boolean;
}): Promise<unknown> {
const { response, text } = await performMatrixRequest({
homeserver: this.homeserver,
accessToken: this.accessToken,
method: params.method,
endpoint: params.endpoint,
qs: params.qs,
body: params.body,
timeoutMs: params.timeoutMs,
allowAbsoluteEndpoint: params.allowAbsoluteEndpoint,
});
if (!response.ok) {
throw buildHttpError(response.status, text);
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
if (!text.trim()) {
return {};
}
return JSON.parse(text);
}
return text;
}
async requestRaw(params: {
method: HttpMethod;
endpoint: string;
qs?: QueryParams;
timeoutMs: number;
allowAbsoluteEndpoint?: boolean;
}): Promise<Buffer> {
const { response, buffer } = await performMatrixRequest({
homeserver: this.homeserver,
accessToken: this.accessToken,
method: params.method,
endpoint: params.endpoint,
qs: params.qs,
timeoutMs: params.timeoutMs,
raw: true,
allowAbsoluteEndpoint: params.allowAbsoluteEndpoint,
});
if (!response.ok) {
throw buildHttpError(response.status, buffer.toString("utf8"));
}
return buffer;
}
}

View File

@@ -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<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function dumpIndexedDatabases(databasePrefix?: string): Promise<IdbDatabaseSnapshot[]> {
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<void> {
const idb = fakeIndexedDB;
for (const dbSnap of snapshot) {
await new Promise<void>((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<void>((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<boolean> {
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<void> {
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);
}
}

View File

@@ -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);
},
};

View File

@@ -0,0 +1,176 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixCryptoBootstrapApi } from "./types.js";
import { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
function createTempRecoveryKeyPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-recovery-key-store-"));
return path.join(dir, "recovery-key.json");
}
describe("MatrixRecoveryKeyStore", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("loads a stored recovery key for requested secret-storage keys", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "SSSS",
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
}),
"utf8",
);
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const callbacks = store.buildCryptoCallbacks();
const resolved = await callbacks.getSecretStorageKey?.(
{ keys: { SSSS: { name: "test" } } },
"m.cross_signing.master",
);
expect(resolved?.[0]).toBe("SSSS");
expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]);
});
it("persists cached secret-storage keys with secure file permissions", () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const callbacks = store.buildCryptoCallbacks();
callbacks.cacheSecretStorageKey?.(
"KEY123",
{
name: "openclaw",
},
new Uint8Array([9, 8, 7]),
);
const saved = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
keyId?: string;
privateKeyBase64?: string;
};
expect(saved.keyId).toBe("KEY123");
expect(saved.privateKeyBase64).toBe(Buffer.from([9, 8, 7]).toString("base64"));
const mode = fs.statSync(recoveryKeyPath).mode & 0o777;
expect(mode).toBe(0o600);
});
it("creates and persists a recovery key when secret storage is missing", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const generated = {
keyId: "GENERATED",
keyInfo: { name: "generated" },
privateKey: new Uint8Array([5, 6, 7, 8]),
encodedPrivateKey: "encoded-generated-key",
};
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
const bootstrapSecretStorage = vi.fn(
async (opts?: { createSecretStorageKey?: () => Promise<unknown> }) => {
await opts?.createSecretStorageKey?.();
},
);
const crypto = {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage,
createRecoveryKeyFromPassphrase,
getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: null })),
requestOwnUserVerification: vi.fn(async () => null),
} as unknown as MatrixCryptoBootstrapApi;
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
expect.objectContaining({
setupNewSecretStorage: true,
}),
);
expect(store.getRecoveryKeySummary()).toMatchObject({
keyId: "GENERATED",
encodedPrivateKey: "encoded-generated-key",
});
});
it("rebinds stored recovery key to server default key id when it changes", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "OLD",
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
}),
"utf8",
);
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const bootstrapSecretStorage = vi.fn(async () => {});
const createRecoveryKeyFromPassphrase = vi.fn(async () => {
throw new Error("should not be called");
});
const crypto = {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage,
createRecoveryKeyFromPassphrase,
getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "NEW" })),
requestOwnUserVerification: vi.fn(async () => null),
} as unknown as MatrixCryptoBootstrapApi;
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
expect(createRecoveryKeyFromPassphrase).not.toHaveBeenCalled();
expect(store.getRecoveryKeySummary()).toMatchObject({
keyId: "NEW",
});
});
it("recreates secret storage when default key exists but is not usable locally", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const generated = {
keyId: "RECOVERED",
keyInfo: { name: "recovered" },
privateKey: new Uint8Array([1, 1, 2, 3]),
encodedPrivateKey: "encoded-recovered-key",
};
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
const bootstrapSecretStorage = vi.fn(
async (opts?: { createSecretStorageKey?: () => Promise<unknown> }) => {
await opts?.createSecretStorageKey?.();
},
);
const crypto = {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage,
createRecoveryKeyFromPassphrase,
getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: "LEGACY" })),
requestOwnUserVerification: vi.fn(async () => null),
} as unknown as MatrixCryptoBootstrapApi;
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
expect.objectContaining({
setupNewSecretStorage: true,
}),
);
expect(store.getRecoveryKeySummary()).toMatchObject({
keyId: "RECOVERED",
encodedPrivateKey: "encoded-recovered-key",
});
});
});

View File

@@ -0,0 +1,253 @@
import fs from "node:fs";
import path from "node:path";
import type {
MatrixCryptoBootstrapApi,
MatrixCryptoCallbacks,
MatrixGeneratedSecretStorageKey,
MatrixSecretStorageStatus,
MatrixStoredRecoveryKey,
} from "./types.js";
import { LogService } from "./logger.js";
export class MatrixRecoveryKeyStore {
private readonly secretStorageKeyCache = new Map<
string,
{ key: Uint8Array; keyInfo?: MatrixStoredRecoveryKey["keyInfo"] }
>();
constructor(private readonly recoveryKeyPath?: string) {}
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];
}
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,
});
},
};
}
getRecoveryKeySummary(): {
encodedPrivateKey?: string;
keyId?: string | null;
createdAt?: string;
} | null {
const stored = this.loadStoredRecoveryKey();
if (!stored) {
return null;
}
return {
encodedPrivateKey: stored.encodedPrivateKey,
keyId: stored.keyId,
createdAt: stored.createdAt,
};
}
async bootstrapSecretStorageWithRecoveryKey(crypto: MatrixCryptoBootstrapApi): Promise<void> {
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);
const hasKnownInvalidSecrets = Object.values(status?.secretStorageKeyValidityMap ?? {}).some(
(valid) => valid === false,
);
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<MatrixGeneratedSecretStorageKey> => {
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 shouldRecreateSecretStorage =
!hasDefaultSecretStorageKey ||
(!recoveryKey && status?.ready === false) ||
hasKnownInvalidSecrets;
if (hasKnownInvalidSecrets) {
// Existing secret storage keys can't decrypt required secrets. Generate a fresh recovery key.
recoveryKey = null;
}
const secretStorageOptions: {
createSecretStorageKey?: () => Promise<MatrixGeneratedSecretStorageKey>;
setupNewSecretStorage?: boolean;
setupNewKeyBackup?: boolean;
} = {
setupNewKeyBackup: false,
};
if (shouldRecreateSecretStorage) {
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 rememberSecretStorageKey(
keyId: string,
key: Uint8Array,
keyInfo?: MatrixStoredRecoveryKey["keyInfo"],
): void {
if (!keyId.trim()) {
return;
}
this.secretStorageKeyCache.set(keyId, {
key: new Uint8Array(key),
keyInfo,
});
}
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<MatrixStoredRecoveryKey>;
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);
}
}
}

View File

@@ -0,0 +1,171 @@
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type QueryValue =
| string
| number
| boolean
| null
| undefined
| Array<string | number | boolean | null | undefined>;
export type QueryParams = Record<string, QueryValue> | 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<Response> {
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;
allowAbsoluteEndpoint?: boolean;
}): Promise<{ response: Response; text: string; buffer: Buffer }> {
const isAbsoluteEndpoint =
params.endpoint.startsWith("http://") || params.endpoint.startsWith("https://");
if (isAbsoluteEndpoint && params.allowAbsoluteEndpoint !== true) {
throw new Error(
`Absolute Matrix endpoint is blocked by default: ${params.endpoint}. Set allowAbsoluteEndpoint=true to opt in.`,
);
}
const baseUrl = isAbsoluteEndpoint
? new URL(params.endpoint)
: new URL(normalizeEndpoint(params.endpoint), params.homeserver);
applyQuery(baseUrl, params.qs);
const headers = new Headers();
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);
}
}

View File

@@ -0,0 +1,183 @@
import type { MatrixVerificationRequestLike } from "./verification-manager.js";
export type MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
unsigned?: {
age?: number;
redacted_because?: unknown;
};
state_key?: string;
};
export type MatrixClientEventMap = {
"room.event": [roomId: string, event: MatrixRawEvent];
"room.message": [roomId: string, event: MatrixRawEvent];
"room.encrypted_event": [roomId: string, event: MatrixRawEvent];
"room.decrypted_event": [roomId: string, event: MatrixRawEvent];
"room.failed_decryption": [roomId: string, event: MatrixRawEvent, error: Error];
"room.invite": [roomId: string, event: MatrixRawEvent];
"room.join": [roomId: string, event: MatrixRawEvent];
};
export type EncryptedFile = {
url: string;
key: {
kty: string;
key_ops: string[];
alg: string;
k: string;
ext: boolean;
};
iv: string;
hashes: Record<string, string>;
v: string;
};
export type FileWithThumbnailInfo = {
size?: number;
mimetype?: string;
thumbnail_url?: string;
thumbnail_info?: {
w?: number;
h?: number;
mimetype?: string;
size?: number;
};
};
export type DimensionalFileInfo = FileWithThumbnailInfo & {
w?: number;
h?: number;
};
export type TimedFileInfo = FileWithThumbnailInfo & {
duration?: number;
};
export type VideoFileInfo = DimensionalFileInfo &
TimedFileInfo & {
duration?: number;
};
export type MessageEventContent = {
msgtype?: string;
body?: string;
format?: string;
formatted_body?: string;
filename?: string;
url?: string;
file?: EncryptedFile;
info?: Record<string, unknown>;
"m.relates_to"?: Record<string, unknown>;
"m.new_content"?: unknown;
"m.mentions"?: {
user_ids?: string[];
room?: boolean;
};
[key: string]: unknown;
};
export type TextualMessageEventContent = MessageEventContent & {
msgtype: string;
body: string;
};
export type LocationMessageEventContent = MessageEventContent & {
msgtype?: string;
geo_uri?: string;
};
export type MatrixSecretStorageStatus = {
ready: boolean;
defaultKeyId: string | null;
secretStorageKeyValidityMap?: Record<string, boolean>;
};
export type MatrixGeneratedSecretStorageKey = {
keyId?: string | null;
keyInfo?: {
passphrase?: unknown;
name?: string;
};
privateKey: Uint8Array;
encodedPrivateKey?: string;
};
export type MatrixDeviceVerificationStatusLike = {
isVerified?: () => boolean;
localVerified?: boolean;
crossSigningVerified?: boolean;
signedByOwner?: boolean;
};
export type MatrixSecretStorageKeyDescription = {
passphrase?: unknown;
name?: string;
[key: string]: unknown;
};
export type MatrixCryptoCallbacks = {
getSecretStorageKey?: (
params: { keys: Record<string, MatrixSecretStorageKeyDescription> },
name: string,
) => Promise<[string, Uint8Array] | null>;
cacheSecretStorageKey?: (
keyId: string,
keyInfo: MatrixSecretStorageKeyDescription,
key: Uint8Array,
) => void;
};
export type MatrixStoredRecoveryKey = {
version: 1;
createdAt: string;
keyId?: string | null;
encodedPrivateKey?: string;
privateKeyBase64: string;
keyInfo?: {
passphrase?: unknown;
name?: string;
};
};
export type MatrixAuthDict = Record<string, unknown>;
export type MatrixUiAuthCallback = <T>(
makeRequest: (authData: MatrixAuthDict | null) => Promise<T>,
) => Promise<T>;
export type MatrixCryptoBootstrapApi = {
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
bootstrapCrossSigning: (opts: {
setupNewCrossSigning?: boolean;
authUploadDeviceSigningKeys?: MatrixUiAuthCallback;
}) => Promise<void>;
bootstrapSecretStorage: (opts?: {
createSecretStorageKey?: () => Promise<MatrixGeneratedSecretStorageKey>;
setupNewSecretStorage?: boolean;
setupNewKeyBackup?: boolean;
}) => Promise<void>;
createRecoveryKeyFromPassphrase?: (password?: string) => Promise<MatrixGeneratedSecretStorageKey>;
getSecretStorageStatus?: () => Promise<MatrixSecretStorageStatus>;
requestOwnUserVerification: () => Promise<unknown | null>;
requestDeviceVerification?: (
userId: string,
deviceId: string,
) => Promise<MatrixVerificationRequestLike>;
requestVerificationDM?: (
userId: string,
roomId: string,
) => Promise<MatrixVerificationRequestLike>;
getDeviceVerificationStatus?: (
userId: string,
deviceId: string,
) => Promise<MatrixDeviceVerificationStatusLike | null>;
setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise<void>;
crossSignDevice?: (deviceId: string) => Promise<void>;
isCrossSigningReady?: () => Promise<boolean>;
userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise<boolean>;
};

View File

@@ -0,0 +1,170 @@
import { VerificationPhase } from "matrix-js-sdk/lib/crypto-api/verification.js";
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import {
MatrixVerificationManager,
type MatrixShowQrCodeCallbacks,
type MatrixShowSasCallbacks,
type MatrixVerificationRequestLike,
type MatrixVerifierLike,
} from "./verification-manager.js";
class MockVerifier extends EventEmitter implements MatrixVerifierLike {
constructor(
private readonly sasCallbacks: MatrixShowSasCallbacks | null,
private readonly qrCallbacks: MatrixShowQrCodeCallbacks | null,
private readonly verifyImpl: () => Promise<void> = async () => {},
) {
super();
}
verify(): Promise<void> {
return this.verifyImpl();
}
cancel(_e: Error): void {
void _e;
}
getShowSasCallbacks(): MatrixShowSasCallbacks | null {
return this.sasCallbacks;
}
getReciprocateQrCodeCallbacks(): MatrixShowQrCodeCallbacks | null {
return this.qrCallbacks;
}
}
class MockVerificationRequest extends EventEmitter implements MatrixVerificationRequestLike {
transactionId?: string;
roomId?: string;
initiatedByMe = false;
otherUserId = "@alice:example.org";
otherDeviceId?: string;
isSelfVerification = false;
phase = VerificationPhase.Requested;
pending = true;
accepting = false;
declining = false;
methods: string[] = ["m.sas.v1"];
chosenMethod?: string | null;
cancellationCode?: string | null;
verifier?: MatrixVerifierLike;
constructor(init?: Partial<MockVerificationRequest>) {
super();
Object.assign(this, init);
}
accept = vi.fn(async () => {
this.phase = VerificationPhase.Ready;
});
cancel = vi.fn(async () => {
this.phase = VerificationPhase.Cancelled;
});
startVerification = vi.fn(async (_method: string) => {
if (!this.verifier) {
throw new Error("verifier not configured");
}
this.phase = VerificationPhase.Started;
return this.verifier;
});
scanQRCode = vi.fn(async (_qrCodeData: Uint8ClampedArray) => {
if (!this.verifier) {
throw new Error("verifier not configured");
}
this.phase = VerificationPhase.Started;
return this.verifier;
});
generateQRCode = vi.fn(async () => new Uint8ClampedArray([1, 2, 3]));
}
describe("MatrixVerificationManager", () => {
it("reuses the same tracked id for repeated transaction IDs", () => {
const manager = new MatrixVerificationManager();
const first = new MockVerificationRequest({
transactionId: "txn-1",
phase: VerificationPhase.Requested,
});
const second = new MockVerificationRequest({
transactionId: "txn-1",
phase: VerificationPhase.Ready,
pending: false,
chosenMethod: "m.sas.v1",
});
const firstSummary = manager.trackVerificationRequest(first);
const secondSummary = manager.trackVerificationRequest(second);
expect(secondSummary.id).toBe(firstSummary.id);
expect(secondSummary.phase).toBe(VerificationPhase.Ready);
expect(secondSummary.pending).toBe(false);
expect(secondSummary.chosenMethod).toBe("m.sas.v1");
});
it("starts SAS verification and exposes SAS payload/callback flow", async () => {
const confirm = vi.fn(async () => {});
const mismatch = vi.fn();
const verifier = new MockVerifier(
{
sas: {
decimal: [111, 222, 333],
emoji: [
["cat", "cat"],
["dog", "dog"],
["fox", "fox"],
],
},
confirm,
mismatch,
cancel: vi.fn(),
},
null,
async () => {},
);
const request = new MockVerificationRequest({
transactionId: "txn-2",
verifier,
});
const manager = new MatrixVerificationManager();
const tracked = manager.trackVerificationRequest(request);
const started = await manager.startVerification(tracked.id, "sas");
expect(started.hasSas).toBe(true);
const sas = manager.getVerificationSas(tracked.id);
expect(sas.decimal).toEqual([111, 222, 333]);
expect(sas.emoji?.length).toBe(3);
await manager.confirmVerificationSas(tracked.id);
expect(confirm).toHaveBeenCalledTimes(1);
manager.mismatchVerificationSas(tracked.id);
expect(mismatch).toHaveBeenCalledTimes(1);
});
it("prunes stale terminal sessions during list operations", () => {
const now = new Date("2026-02-08T15:00:00.000Z").getTime();
const nowSpy = vi.spyOn(Date, "now");
nowSpy.mockReturnValue(now);
const manager = new MatrixVerificationManager();
manager.trackVerificationRequest(
new MockVerificationRequest({
transactionId: "txn-old-done",
phase: VerificationPhase.Done,
pending: false,
}),
);
nowSpy.mockReturnValue(now + 24 * 60 * 60 * 1000 + 1);
const summaries = manager.listVerifications();
expect(summaries).toHaveLength(0);
nowSpy.mockRestore();
});
});

View File

@@ -0,0 +1,464 @@
import {
VerificationPhase,
VerificationRequestEvent,
VerifierEvent,
} from "matrix-js-sdk/lib/crypto-api/verification.js";
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
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<void>;
mismatch: () => void;
cancel: () => void;
};
export type MatrixShowQrCodeCallbacks = {
confirm: () => void;
cancel: () => void;
};
export type MatrixVerifierLike = {
verify: () => Promise<void>;
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<void>;
cancel: (params?: { reason?: string; code?: string }) => Promise<void>;
startVerification: (method: string) => Promise<MatrixVerifierLike>;
scanQRCode: (qrCodeData: Uint8ClampedArray) => Promise<MatrixVerifierLike>;
generateQRCode: () => Promise<Uint8ClampedArray | undefined>;
verifier?: MatrixVerifierLike;
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
};
export type MatrixVerificationCryptoApi = {
requestOwnUserVerification: () => Promise<unknown | null>;
requestDeviceVerification?: (
userId: string,
deviceId: string,
) => Promise<MatrixVerificationRequestLike>;
requestVerificationDM?: (
userId: string,
roomId: string,
) => Promise<MatrixVerificationRequestLike>;
};
type MatrixVerificationSession = {
id: string;
request: MatrixVerificationRequestLike;
createdAtMs: number;
updatedAtMs: number;
error?: string;
activeVerifier?: MatrixVerifierLike;
verifyPromise?: Promise<void>;
verifyStarted: boolean;
sasCallbacks?: MatrixShowSasCallbacks;
reciprocateQrCallbacks?: MatrixShowQrCodeCallbacks;
};
const MAX_TRACKED_VERIFICATION_SESSIONS = 256;
const TERMINAL_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000;
export class MatrixVerificationManager {
private readonly verificationSessions = new Map<string, MatrixVerificationSession>();
private verificationSessionCounter = 0;
private readonly trackedVerificationRequests = new WeakSet<object>();
private readonly trackedVerificationVerifiers = new WeakSet<object>();
private pruneVerificationSessions(nowMs: number): void {
for (const [id, session] of this.verificationSessions) {
const phase = session.request.phase;
const isTerminal = phase === VerificationPhase.Done || phase === VerificationPhase.Cancelled;
if (isTerminal && nowMs - session.updatedAtMs > TERMINAL_SESSION_RETENTION_MS) {
this.verificationSessions.delete(id);
}
}
if (this.verificationSessions.size <= MAX_TRACKED_VERIFICATION_SESSIONS) {
return;
}
const sortedByAge = Array.from(this.verificationSessions.entries()).sort(
(a, b) => a[1].updatedAtMs - b[1].updatedAtMs,
);
const overflow = this.verificationSessions.size - MAX_TRACKED_VERIFICATION_SESSIONS;
for (let i = 0; i < overflow; i += 1) {
const entry = sortedByAge[i];
if (entry) {
this.verificationSessions.delete(entry[0]);
}
}
}
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 {
this.pruneVerificationSessions(Date.now());
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<MatrixVerificationSummary | null> {
if (!crypto) {
return null;
}
const request =
(await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null;
if (!request) {
return null;
}
return this.trackVerificationRequest(request);
}
listVerifications(): MatrixVerificationSummary[] {
this.pruneVerificationSessions(Date.now());
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<MatrixVerificationSummary> {
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<MatrixVerificationSummary> {
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<MatrixVerificationSummary> {
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<MatrixVerificationSummary> {
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<MatrixVerificationSummary> {
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<MatrixVerificationSummary> {
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,
};
}
}

View File

@@ -2,22 +2,6 @@ import type { PluginRuntime } from "openclaw/plugin-sdk";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../runtime.js";
vi.mock("@vector-im/matrix-bot-sdk", () => ({
ConsoleLogger: class {
trace = vi.fn();
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
LogService: {
setLogger: vi.fn(),
},
MatrixClient: vi.fn(),
SimpleFsStorageProvider: vi.fn(),
RustSdkCryptoStorageProvider: vi.fn(),
}));
const loadWebMediaMock = vi.fn().mockResolvedValue({
buffer: Buffer.from("media"),
fileName: "photo.png",
@@ -59,7 +43,7 @@ const makeClient = () => {
sendMessage,
uploadContent,
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
} as unknown as import("./sdk.js").MatrixClient;
return { client, sendMessage, uploadContent };
};

View File

@@ -1,5 +1,5 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PollInput } from "openclaw/plugin-sdk";
import type { MatrixClient } from "./sdk.js";
import { getMatrixRuntime } from "../runtime.js";
import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js";
@@ -71,7 +71,6 @@ export async function sendMessageMatrix(
? buildThreadRelation(threadId, opts.replyToId)
: buildReplyRelation(opts.replyToId);
const sendContent = async (content: MatrixOutboundContent) => {
// @vector-im/matrix-bot-sdk uses sendMessage differently
const eventId = await client.sendMessage(roomId, content);
return eventId;
};
@@ -175,7 +174,6 @@ export async function sendPollMatrix(
const pollPayload = threadId
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
: pollContent;
// @vector-im/matrix-bot-sdk sendEvent returns eventId string directly
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
return {

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js";
@@ -49,6 +49,8 @@ export async function resolveMatrixClient(opts: {
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
password: auth.password,
deviceId: auth.deviceId,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
});
@@ -60,7 +62,6 @@ export async function resolveMatrixClient(opts: {
// Ignore crypto prep failures for one-off sends; normal sync will retry.
}
}
// @vector-im/matrix-bot-sdk uses start() instead of startClient()
await client.start();
return { client, stopOnDone: true };
}

View File

@@ -1,3 +1,4 @@
import { parseBuffer, type IFileInfo } from "music-metadata";
import type {
DimensionalFileInfo,
EncryptedFile,
@@ -5,8 +6,7 @@ import type {
MatrixClient,
TimedFileInfo,
VideoFileInfo,
} from "@vector-im/matrix-bot-sdk";
import { parseBuffer, type IFileInfo } from "music-metadata";
} from "../sdk.js";
import { getMatrixRuntime } from "../../runtime.js";
import { applyMatrixFormatting } from "./formatting.js";
import {

View File

@@ -1,5 +1,5 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { EventType } from "./types.js";
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
import { EventType, type MatrixDirectAccountData } from "./types.js";
function normalizeTarget(raw: string): string {

View File

@@ -6,7 +6,7 @@ import type {
TextualMessageEventContent,
TimedFileInfo,
VideoFileInfo,
} from "@vector-im/matrix-bot-sdk";
} from "../sdk.js";
// Message types
export const MsgType = {
@@ -85,7 +85,7 @@ export type MatrixSendResult = {
};
export type MatrixSendOpts = {
client?: import("@vector-im/matrix-bot-sdk").MatrixClient;
client?: import("../sdk.js").MatrixClient;
mediaUrl?: string;
accountId?: string;
replyToId?: string;

View File

@@ -37,8 +37,9 @@ async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
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"),
@@ -192,11 +193,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
statusLines: [
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
],
selectionHint: !sdkReady
? "install @vector-im/matrix-bot-sdk"
: configured
? "configured"
: "needs auth",
selectionHint: !sdkReady ? "install matrix-js-sdk" : configured ? "configured" : "needs auth",
};
},
configure: async ({ cfg, runtime, prompter, forceAllowFrom }) => {
@@ -270,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({
@@ -280,6 +278,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
accessToken = "";
password = "";
userId = "";
register = false;
}
}
@@ -290,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)",
},
],
});
@@ -303,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",
@@ -330,6 +334,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
register = authMode === "register";
}
}
@@ -357,6 +362,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
userId: userId || undefined,
accessToken: accessToken || undefined,
password: password || undefined,
register,
deviceName: deviceName || undefined,
encryption: enableEncryption || undefined,
},

View File

@@ -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<string, unknown>, 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}`);
}

View File

@@ -36,6 +36,7 @@ export type MatrixActionConfig = {
pins?: boolean;
memberInfo?: boolean;
channelInfo?: boolean;
verification?: boolean;
};
export type MatrixConfig = {
@@ -51,9 +52,13 @@ 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 (default: @vector-im/matrix-bot-sdk default). */
/** Initial sync limit for startup (defaults to matrix-js-sdk behavior). */
initialSyncLimit?: number;
/** Enable end-to-end encryption (E2EE). Default: false. */
encryption?: boolean;

1000
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff