Matrix: stabilize E2EE verification and modularize SDK

This commit is contained in:
gustavo
2026-02-08 15:20:29 -05:00
parent 66c0f4bcc7
commit afd46ce9b8
27 changed files with 2734 additions and 323 deletions

View File

@@ -65,13 +65,16 @@ Details: [Plugins](/tools/plugin)
- Or set `channels.matrix.userId` + `channels.matrix.password`: OpenClaw calls the same
login endpoint, stores the access token in `~/.openclaw/credentials/matrix/credentials.json`,
and reuses it on next start.
- Optional registration mode: set `channels.matrix.register: true` to attempt account creation
when password login fails (for homeservers that allow open registration).
4. Configure credentials:
- Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`)
- Or config: `channels.matrix.*`
- If both are set, config takes precedence.
- With access token: user ID is fetched automatically via `/whoami`.
- With access token: user ID and device ID are fetched automatically via `/whoami` if missing.
- When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`).
- Optional: set `channels.matrix.deviceId` (or `MATRIX_DEVICE_ID`) to pin to a known device ID.
5. Restart the gateway (or finish onboarding).
6. Start a DM with the bot or invite it to a room from any Matrix client
(Element, Beeper, etc.; see [https://matrix.org/ecosystem/clients/](https://matrix.org/ecosystem/clients/)). Beeper requires E2EE,
@@ -116,8 +119,11 @@ Enable with `channels.matrix.encryption: true`:
- If the crypto module loads, encrypted rooms are decrypted automatically.
- Outbound media is encrypted when sending to encrypted rooms.
- On first connection, OpenClaw requests device verification from your other sessions.
- Verify the device in another Matrix client (Element, etc.) to enable key sharing.
- Cross-signing and secret storage are bootstrapped at startup when possible.
- OpenClaw creates or reuses a recovery key for secret storage and stores it at:
`~/.openclaw/credentials/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/recovery-key.json`
- On startup, OpenClaw requests self-verification and can accept incoming verification requests.
- Verify in another Matrix client (Element, etc.) to establish trust and improve key sharing.
- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt;
OpenClaw logs a warning.
- If you see missing crypto module errors (for example, `@matrix-org/matrix-sdk-crypto-nodejs-*`),
@@ -126,8 +132,9 @@ Enable with `channels.matrix.encryption: true`:
`node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`.
Crypto state is stored per account + access token in
`~/.openclaw/matrix/accounts/<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 +143,25 @@ When E2EE is enabled, the bot will request verification from your other sessions
Open Element (or another client) and approve the verification request to establish trust.
Once verified, the bot can decrypt messages in encrypted rooms.
## Verification operations
When E2EE is enabled and `channels.matrix.actions.verification` is on, the Matrix
`permissions` action exposes verification operations:
- `encryption-status`: report encryption and recovery key status.
- `verification-list`: list tracked verification requests.
- `verification-request`: start verification (`ownUser`, `userId+deviceId`, or `userId+roomId`).
- `verification-accept`, `verification-cancel`: accept or cancel a request.
- `verification-start`: start SAS verification.
- `verification-sas`, `verification-confirm`, `verification-mismatch`: read and confirm or reject SAS.
- `verification-generate-qr`, `verification-scan-qr`, `verification-confirm-qr`: QR-based flows.
Use these via the `permissions` action by setting `operation` (or `mode`) to one of:
`encryption-status`, `verification-list`, `verification-request`, `verification-accept`,
`verification-cancel`, `verification-start`, `verification-generate-qr`,
`verification-scan-qr`, `verification-sas`, `verification-confirm`,
`verification-mismatch`, `verification-confirm-qr`.
## Routing model
- Replies always go back to Matrix.
@@ -239,6 +265,8 @@ Provider options:
- `channels.matrix.userId`: Matrix user ID (optional with access token).
- `channels.matrix.accessToken`: access token.
- `channels.matrix.password`: password for login (token stored).
- `channels.matrix.register`: try account registration if password login fails.
- `channels.matrix.deviceId`: preferred device ID (used for E2EE initialization).
- `channels.matrix.deviceName`: device display name.
- `channels.matrix.encryption`: enable E2EE (default: false).
- `channels.matrix.initialSyncLimit`: initial sync limit.
@@ -257,3 +285,4 @@ Provider options:
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join.
- `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo).
- `channels.matrix.actions.verification`: enable verification action operations.

View File

@@ -5,6 +5,7 @@
"type": "module",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"fake-indexeddb": "^6.2.5",
"markdown-it": "14.1.0",
"matrix-js-sdk": "^40.1.0",
"music-metadata": "^11.11.2",

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,7 @@ export async function resolveActionClient(
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: auth.deviceId,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
});

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,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixAuth, resolveMatrixConfig } from "./client.js";
import * as credentialsModule from "./credentials.js";
import * as sdkModule from "./sdk.js";
const saveMatrixCredentialsMock = vi.fn();
@@ -39,6 +40,8 @@ describe("resolveMatrixConfig", () => {
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
register: false,
deviceId: undefined,
deviceName: "CfgDevice",
initialSyncLimit: 5,
encryption: false,
@@ -52,6 +55,7 @@ describe("resolveMatrixConfig", () => {
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_ID: "ENVDEVICE",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfig(cfg, env);
@@ -59,10 +63,32 @@ describe("resolveMatrixConfig", () => {
expect(resolved.userId).toBe("@env:example.org");
expect(resolved.accessToken).toBe("env-token");
expect(resolved.password).toBe("env-pass");
expect(resolved.register).toBe(false);
expect(resolved.deviceId).toBe("ENVDEVICE");
expect(resolved.deviceName).toBe("EnvDevice");
expect(resolved.initialSyncLimit).toBeUndefined();
expect(resolved.encryption).toBe(false);
});
it("reads register flag from config and env", () => {
const cfg = {
channels: {
matrix: {
register: true,
},
},
} as CoreConfig;
const resolvedFromCfg = resolveMatrixConfig(cfg, {} as NodeJS.ProcessEnv);
expect(resolvedFromCfg.register).toBe(true);
const resolvedFromEnv = resolveMatrixConfig(
{} as CoreConfig,
{
MATRIX_REGISTER: "1",
} as NodeJS.ProcessEnv,
);
expect(resolvedFromEnv.register).toBe(true);
});
});
describe("resolveMatrixAuth", () => {
@@ -119,4 +145,155 @@ describe("resolveMatrixAuth", () => {
}),
);
});
it("can register account when password login fails and register mode is enabled", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest");
doRequestSpy
.mockRejectedValueOnce(new Error("Invalid username or password"))
.mockResolvedValueOnce({
access_token: "tok-registered",
user_id: "@newbot:example.org",
device_id: "REGDEVICE123",
});
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@newbot:example.org",
password: "secret",
register: true,
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(doRequestSpy).toHaveBeenNthCalledWith(
1,
"POST",
"/_matrix/client/v3/login",
undefined,
expect.objectContaining({
type: "m.login.password",
}),
);
expect(doRequestSpy).toHaveBeenNthCalledWith(
2,
"POST",
"/_matrix/client/v3/register",
undefined,
expect.objectContaining({
username: "newbot",
auth: { type: "m.login.dummy" },
}),
);
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@newbot:example.org",
accessToken: "tok-registered",
deviceId: "REGDEVICE123",
encryption: true,
});
});
it("falls back to config deviceId when cached credentials are missing it", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(auth.deviceId).toBe("DEVICE123");
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
expect.objectContaining({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
}),
);
});
it("resolves missing whoami identity fields for token auth", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
});
it("uses config deviceId with cached credentials when token is loaded from cache", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
deviceId: "DEVICE123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
});
});

View File

@@ -8,6 +8,88 @@ function clean(value?: string): string {
return value?.trim() ?? "";
}
function parseOptionalBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") {
return value;
}
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return undefined;
}
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return undefined;
}
function resolveMatrixLocalpart(userId: string): string {
const trimmed = userId.trim();
const noPrefix = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
const localpart = noPrefix.split(":")[0]?.trim() || "";
if (!localpart) {
throw new Error(`Invalid Matrix userId for registration: ${userId}`);
}
return localpart;
}
async function registerMatrixPasswordAccount(params: {
homeserver: string;
userId: string;
password: string;
deviceName?: string;
}): Promise<{
access_token?: string;
user_id?: string;
device_id?: string;
}> {
const registerClient = new MatrixClient(params.homeserver, "");
const payload = {
username: resolveMatrixLocalpart(params.userId),
password: params.password,
inhibit_login: false,
initial_device_display_name: params.deviceName ?? "OpenClaw Gateway",
};
let firstError: unknown = null;
try {
return (await registerClient.doRequest("POST", "/_matrix/client/v3/register", undefined, {
...payload,
auth: { type: "m.login.dummy" },
})) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
} catch (err) {
firstError = err;
}
try {
return (await registerClient.doRequest(
"POST",
"/_matrix/client/v3/register",
undefined,
payload,
)) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
} catch (err) {
const firstMessage = firstError instanceof Error ? firstError.message : String(firstError);
const secondMessage = err instanceof Error ? err.message : String(err);
throw new Error(
`Matrix registration failed (dummy auth: ${firstMessage}; plain registration: ${secondMessage})`,
);
}
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
@@ -17,6 +99,9 @@ export function resolveMatrixConfig(
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
const register =
parseOptionalBoolean(matrix.register) ?? parseOptionalBoolean(env.MATRIX_REGISTER) ?? false;
const deviceId = clean(matrix.deviceId) || clean(env.MATRIX_DEVICE_ID) || undefined;
const deviceName = clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
const initialSyncLimit =
typeof matrix.initialSyncLimit === "number"
@@ -28,6 +113,8 @@ export function resolveMatrixConfig(
userId,
accessToken,
password,
register,
deviceId,
deviceName,
initialSyncLimit,
encryption,
@@ -65,24 +152,44 @@ export async function resolveMatrixAuth(params?: {
// If we have an access token, we can fetch userId via whoami if not provided
if (resolved.accessToken) {
let userId = resolved.userId;
const knownDeviceId =
cachedCredentials && cachedCredentials.accessToken === resolved.accessToken
? cachedCredentials.deviceId
: undefined;
if (!userId) {
// Fetch userId from access token via whoami
const hasMatchingCachedToken = cachedCredentials?.accessToken === resolved.accessToken;
let knownDeviceId = hasMatchingCachedToken
? cachedCredentials?.deviceId || resolved.deviceId
: resolved.deviceId;
if (!userId || !knownDeviceId) {
// Fetch whoami when we need to resolve userId and/or deviceId from token auth.
ensureMatrixSdkLoggingConfigured();
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
const whoami = await tempClient.getUserId();
userId = whoami;
// Save the credentials with the fetched userId
const whoami = (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
user_id?: string;
device_id?: string;
};
if (!userId) {
const fetchedUserId = whoami.user_id?.trim();
if (!fetchedUserId) {
throw new Error("Matrix whoami did not return user_id");
}
userId = fetchedUserId;
}
if (!knownDeviceId) {
knownDeviceId = whoami.device_id?.trim() || resolved.deviceId;
}
}
const shouldRefreshCachedCredentials =
!cachedCredentials ||
!hasMatchingCachedToken ||
cachedCredentials.userId !== userId ||
(cachedCredentials.deviceId || undefined) !== knownDeviceId;
if (shouldRefreshCachedCredentials) {
saveMatrixCredentials({
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
deviceId: knownDeviceId,
});
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
} else if (hasMatchingCachedToken) {
touchMatrixCredentials(env);
}
return {
@@ -102,7 +209,7 @@ export async function resolveMatrixAuth(params?: {
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
deviceId: cachedCredentials.deviceId,
deviceId: cachedCredentials.deviceId || resolved.deviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
@@ -122,20 +229,46 @@ export async function resolveMatrixAuth(params?: {
// Login with password using the same hardened request path as other Matrix HTTP calls.
ensureMatrixSdkLoggingConfigured();
const loginClient = new MatrixClient(resolved.homeserver, "");
const login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, {
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
})) as {
let login: {
access_token?: string;
user_id?: string;
device_id?: string;
};
try {
login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, {
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
})) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
} catch (loginErr) {
if (!resolved.register) {
throw loginErr;
}
try {
login = await registerMatrixPasswordAccount({
homeserver: resolved.homeserver,
userId: resolved.userId,
password: resolved.password,
deviceName: resolved.deviceName,
});
} catch (registerErr) {
const loginMessage = loginErr instanceof Error ? loginErr.message : String(loginErr);
const registerMessage =
registerErr instanceof Error ? registerErr.message : String(registerErr);
throw new Error(
`Matrix login failed (${loginMessage}) and account registration failed (${registerMessage})`,
);
}
}
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error("Matrix login did not return an access token");
throw new Error("Matrix login/registration did not return an access token");
}
const auth: MatrixAuth = {

View File

@@ -39,11 +39,16 @@ export async function createMatrixClient(params: {
accountId: params.accountId,
});
const cryptoDatabasePrefix = `openclaw-matrix-${storagePaths.accountKey}-${storagePaths.tokenHash}`;
return new MatrixClient(params.homeserver, params.accessToken, undefined, undefined, {
userId: matrixClientUserId,
deviceId: params.deviceId,
encryption: params.encryption,
localTimeoutMs: params.localTimeoutMs,
initialSyncLimit: params.initialSyncLimit,
recoveryKeyPath: storagePaths.recoveryKeyPath,
idbSnapshotPath: storagePaths.idbSnapshotPath,
cryptoDatabasePrefix,
});
}

View File

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

View File

@@ -1,7 +1,7 @@
import type { MatrixClient } from "../sdk.js";
import type { CoreConfig } from "../types.js";
import type { MatrixAuth } from "./types.js";
import { LogService } from "../sdk.js";
import { LogService } from "../sdk/logger.js";
import { resolveMatrixAuth } from "./config.js";
import { createMatrixClient } from "./create-client.js";
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";

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

@@ -4,6 +4,7 @@ export type MatrixResolvedConfig = {
accessToken?: string;
deviceId?: string;
password?: string;
register?: boolean;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
@@ -31,6 +32,8 @@ export type MatrixStoragePaths = {
storagePath: string;
cryptoPath: string;
metaPath: string;
recoveryKeyPath: string;
idbSnapshotPath: string;
accountKey: string;
tokenHash: string;
};

View File

@@ -1,4 +1,7 @@
import { EventEmitter } from "node:events";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
class FakeMatrixEvent extends EventEmitter {
@@ -83,6 +86,7 @@ type MatrixJsClientStub = EventEmitter & {
stopClient: ReturnType<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>;
@@ -101,6 +105,7 @@ type MatrixJsClientStub = EventEmitter & {
sendTyping: ReturnType<typeof vi.fn>;
getRoom: ReturnType<typeof vi.fn>;
getCrypto: ReturnType<typeof vi.fn>;
decryptEventIfNeeded: ReturnType<typeof vi.fn>;
};
function createMatrixJsClientStub(): MatrixJsClientStub {
@@ -109,6 +114,7 @@ function createMatrixJsClientStub(): MatrixJsClientStub {
client.stopClient = vi.fn();
client.initRustCrypto = vi.fn(async () => {});
client.getUserId = vi.fn(() => "@bot:example.org");
client.getDeviceId = vi.fn(() => "DEVICE123");
client.getJoinedRooms = vi.fn(async () => ({ joined_rooms: [] }));
client.getJoinedRoomMembers = vi.fn(async () => ({ joined: {} }));
client.getStateEvent = vi.fn(async () => ({}));
@@ -127,15 +133,20 @@ function createMatrixJsClientStub(): MatrixJsClientStub {
client.sendTyping = vi.fn(async () => {});
client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false }));
client.getCrypto = vi.fn(() => undefined);
client.decryptEventIfNeeded = vi.fn(async () => {});
return client;
}
let matrixJsClient = createMatrixJsClientStub();
let lastCreateClientOpts: Record<string, unknown> | null = null;
vi.mock("matrix-js-sdk", () => ({
ClientEvent: { Event: "event" },
MatrixEventEvent: { Decrypted: "decrypted" },
createClient: vi.fn(() => matrixJsClient),
createClient: vi.fn((opts: Record<string, unknown>) => {
lastCreateClientOpts = opts;
return matrixJsClient;
}),
}));
import { MatrixClient } from "./sdk.js";
@@ -143,6 +154,7 @@ import { MatrixClient } from "./sdk.js";
describe("MatrixClient request hardening", () => {
beforeEach(() => {
matrixJsClient = createMatrixJsClientStub();
lastCreateClientOpts = null;
vi.useRealTimers();
vi.unstubAllGlobals();
});
@@ -226,6 +238,12 @@ describe("MatrixClient request hardening", () => {
describe("MatrixClient event bridge", () => {
beforeEach(() => {
matrixJsClient = createMatrixJsClientStub();
lastCreateClientOpts = null;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("emits room.message only after encrypted events decrypt", async () => {
@@ -313,6 +331,123 @@ describe("MatrixClient event bridge", () => {
expect(delivered).toHaveLength(0);
});
it("retries failed decryption and emits room.message after late key availability", async () => {
vi.useFakeTimers();
const client = new MatrixClient("https://matrix.example.org", "token");
const failed: string[] = [];
const delivered: string[] = [];
client.on("room.failed_decryption", (_roomId, _event, error) => {
failed.push(error.message);
});
client.on("room.message", (_roomId, event) => {
delivered.push(event.type);
});
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
decryptionFailure: true,
});
const decrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.message",
ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
},
});
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
encrypted.emit("decrypted", decrypted);
});
await client.start();
matrixJsClient.emit("event", encrypted);
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
expect(failed).toEqual(["missing room key"]);
expect(delivered).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_600);
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(failed).toEqual(["missing room key"]);
expect(delivered).toEqual(["m.room.message"]);
});
it("retries failed decryptions immediately on crypto key update signals", async () => {
vi.useFakeTimers();
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
const failed: string[] = [];
const delivered: string[] = [];
const cryptoListeners = new Map<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("emits room.invite when a membership invite targets the current user", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
const invites: string[] = [];
@@ -340,3 +475,108 @@ describe("MatrixClient event bridge", () => {
expect(invites).toEqual(["!room:example.org"]);
});
});
describe("MatrixClient crypto bootstrapping", () => {
beforeEach(() => {
matrixJsClient = createMatrixJsClientStub();
lastCreateClientOpts = null;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("passes cryptoDatabasePrefix into initRustCrypto", async () => {
matrixJsClient.getCrypto = vi.fn(() => undefined);
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
cryptoDatabasePrefix: "openclaw-matrix-test",
});
await client.start();
expect(matrixJsClient.initRustCrypto).toHaveBeenCalledWith({
cryptoDatabasePrefix: "openclaw-matrix-test",
});
});
it("bootstraps cross-signing with setupNewCrossSigning enabled", async () => {
const bootstrapCrossSigning = vi.fn(async () => {});
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning,
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
await client.start();
expect(bootstrapCrossSigning).toHaveBeenCalledWith({
setupNewCrossSigning: true,
});
});
it("provides secret storage callbacks and resolves stored recovery key", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-"));
const recoveryKeyPath = path.join(tmpDir, "recovery-key.json");
const privateKeyBase64 = Buffer.from([1, 2, 3, 4]).toString("base64");
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "SSSSKEY",
privateKeyBase64,
}),
"utf8",
);
new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
recoveryKeyPath,
});
const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as {
getSecretStorageKey?: (
params: { keys: Record<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);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,277 @@
import { MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk";
import { LogService, noop } from "./logger.js";
type MatrixDecryptIfNeededClient = {
decryptEventIfNeeded?: (
event: MatrixEvent,
opts?: {
isRetry?: boolean;
},
) => Promise<void>;
};
type MatrixDecryptRetryState = {
event: MatrixEvent;
roomId: string;
eventId: string;
attempts: number;
inFlight: boolean;
timer: ReturnType<typeof setTimeout> | null;
};
type DecryptBridgeRawEvent = {
event_id: string;
};
const MATRIX_DECRYPT_RETRY_BASE_DELAY_MS = 1_500;
const MATRIX_DECRYPT_RETRY_MAX_DELAY_MS = 30_000;
const MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS = 8;
function resolveDecryptRetryKey(roomId: string, eventId: string): string | null {
if (!roomId || !eventId) {
return null;
}
return `${roomId}|${eventId}`;
}
function isDecryptionFailure(event: MatrixEvent): boolean {
return (
typeof (event as { isDecryptionFailure?: () => boolean }).isDecryptionFailure === "function" &&
(event as { isDecryptionFailure: () => boolean }).isDecryptionFailure()
);
}
export class MatrixDecryptBridge<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>();
constructor(
private readonly deps: {
client: MatrixDecryptIfNeededClient;
toRaw: (event: MatrixEvent) => TRawEvent;
emitDecryptedEvent: (roomId: string, event: TRawEvent) => void;
emitMessage: (roomId: string, event: TRawEvent) => void;
emitFailedDecryption: (roomId: string, event: TRawEvent, error: Error) => void;
},
) {}
shouldEmitUnencryptedMessage(roomId: string, eventId: string): boolean {
if (!eventId) {
return true;
}
const key = `${roomId}|${eventId}`;
const createdAt = this.decryptedMessageDedupe.get(key);
if (createdAt === undefined) {
return true;
}
this.decryptedMessageDedupe.delete(key);
return false;
}
attachEncryptedEvent(event: MatrixEvent, roomId: string): void {
if (this.trackedEncryptedEvents.has(event)) {
return;
}
this.trackedEncryptedEvents.add(event);
event.on(MatrixEventEvent.Decrypted, (decryptedEvent: MatrixEvent, err?: Error) => {
this.handleEncryptedEventDecrypted({
roomId,
encryptedEvent: event,
decryptedEvent,
err,
});
});
}
retryPendingNow(reason: string): void {
const pending = Array.from(this.decryptRetries.entries());
if (pending.length === 0) {
return;
}
LogService.debug("MatrixClientLite", `Retrying pending decryptions due to ${reason}`);
for (const [retryKey, state] of pending) {
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
if (state.inFlight) {
continue;
}
this.runDecryptRetry(retryKey).catch(noop);
}
}
stop(): void {
for (const retryKey of this.decryptRetries.keys()) {
this.clearDecryptRetry(retryKey);
}
}
private handleEncryptedEventDecrypted(params: {
roomId: string;
encryptedEvent: MatrixEvent;
decryptedEvent: MatrixEvent;
err?: Error;
}): void {
const decryptedRoomId = params.decryptedEvent.getRoomId() || params.roomId;
const decryptedRaw = this.deps.toRaw(params.decryptedEvent);
const retryEventId = decryptedRaw.event_id || params.encryptedEvent.getId() || "";
const retryKey = resolveDecryptRetryKey(decryptedRoomId, retryEventId);
if (params.err) {
this.emitFailedDecryptionOnce(retryKey, decryptedRoomId, decryptedRaw, params.err);
this.scheduleDecryptRetry({
event: params.encryptedEvent,
roomId: decryptedRoomId,
eventId: retryEventId,
});
return;
}
if (isDecryptionFailure(params.decryptedEvent)) {
this.emitFailedDecryptionOnce(
retryKey,
decryptedRoomId,
decryptedRaw,
new Error("Matrix event failed to decrypt"),
);
this.scheduleDecryptRetry({
event: params.encryptedEvent,
roomId: decryptedRoomId,
eventId: retryEventId,
});
return;
}
if (retryKey) {
this.clearDecryptRetry(retryKey);
}
this.rememberDecryptedMessage(decryptedRoomId, decryptedRaw.event_id);
this.deps.emitDecryptedEvent(decryptedRoomId, decryptedRaw);
this.deps.emitMessage(decryptedRoomId, decryptedRaw);
}
private emitFailedDecryptionOnce(
retryKey: string | null,
roomId: string,
event: TRawEvent,
error: Error,
): void {
if (retryKey) {
if (this.failedDecryptionsNotified.has(retryKey)) {
return;
}
this.failedDecryptionsNotified.add(retryKey);
}
this.deps.emitFailedDecryption(roomId, event, error);
}
private scheduleDecryptRetry(params: {
event: MatrixEvent;
roomId: string;
eventId: string;
}): void {
const retryKey = resolveDecryptRetryKey(params.roomId, params.eventId);
if (!retryKey) {
return;
}
const existing = this.decryptRetries.get(retryKey);
if (existing?.timer || existing?.inFlight) {
return;
}
const attempts = (existing?.attempts ?? 0) + 1;
if (attempts > MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS) {
this.clearDecryptRetry(retryKey);
LogService.debug(
"MatrixClientLite",
`Giving up decryption retry for ${params.eventId} in ${params.roomId} after ${attempts - 1} attempts`,
);
return;
}
const delayMs = Math.min(
MATRIX_DECRYPT_RETRY_BASE_DELAY_MS * 2 ** (attempts - 1),
MATRIX_DECRYPT_RETRY_MAX_DELAY_MS,
);
const next: MatrixDecryptRetryState = {
event: params.event,
roomId: params.roomId,
eventId: params.eventId,
attempts,
inFlight: false,
timer: null,
};
next.timer = setTimeout(() => {
this.runDecryptRetry(retryKey).catch(noop);
}, delayMs);
this.decryptRetries.set(retryKey, next);
}
private async runDecryptRetry(retryKey: string): Promise<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,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,163 @@
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;
}): Promise<{ response: Response; text: string; buffer: Buffer }> {
const baseUrl =
params.endpoint.startsWith("http://") || params.endpoint.startsWith("https://")
? new URL(params.endpoint)
: new URL(normalizeEndpoint(params.endpoint), params.homeserver);
applyQuery(baseUrl, params.qs);
const headers = new Headers();
headers.set("Accept", params.raw ? "*/*" : "application/json");
if (params.accessToken) {
headers.set("Authorization", `Bearer ${params.accessToken}`);
}
let body: BodyInit | undefined;
if (params.body !== undefined) {
if (
params.body instanceof Uint8Array ||
params.body instanceof ArrayBuffer ||
typeof params.body === "string"
) {
body = params.body as BodyInit;
} else {
headers.set("Content-Type", "application/json");
body = JSON.stringify(params.body);
}
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs);
try {
const response = await fetchWithSafeRedirects(baseUrl, {
method: params.method,
headers,
body,
signal: controller.signal,
});
if (params.raw) {
const bytes = Buffer.from(await response.arrayBuffer());
return {
response,
text: bytes.toString("utf8"),
buffer: bytes,
};
}
const text = await response.text();
return {
response,
text,
buffer: Buffer.from(text, "utf8"),
};
} finally {
clearTimeout(timeoutId);
}
}

View File

@@ -0,0 +1,434 @@
import {
VerificationPhase,
VerificationRequestEvent,
VerifierEvent,
} from "matrix-js-sdk/src/crypto-api/verification.ts";
import { VerificationMethod } from "matrix-js-sdk/src/types.ts";
export type MatrixVerificationMethod = "sas" | "show-qr" | "scan-qr";
export type MatrixVerificationSummary = {
id: string;
transactionId?: string;
roomId?: string;
otherUserId: string;
otherDeviceId?: string;
isSelfVerification: boolean;
initiatedByMe: boolean;
phase: number;
phaseName: string;
pending: boolean;
methods: string[];
chosenMethod?: string | null;
canAccept: boolean;
hasSas: boolean;
hasReciprocateQr: boolean;
completed: boolean;
error?: string;
createdAt: string;
updatedAt: string;
};
export type MatrixShowSasCallbacks = {
sas: {
decimal?: [number, number, number];
emoji?: Array<[string, string]>;
};
confirm: () => Promise<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;
};
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 getVerificationPhaseName(phase: number): string {
switch (phase) {
case VerificationPhase.Unsent:
return "unsent";
case VerificationPhase.Requested:
return "requested";
case VerificationPhase.Ready:
return "ready";
case VerificationPhase.Started:
return "started";
case VerificationPhase.Cancelled:
return "cancelled";
case VerificationPhase.Done:
return "done";
default:
return `unknown(${phase})`;
}
}
private touchVerificationSession(session: MatrixVerificationSession): void {
session.updatedAtMs = Date.now();
}
private buildVerificationSummary(session: MatrixVerificationSession): MatrixVerificationSummary {
const request = session.request;
const phase = request.phase;
const canAccept = phase < VerificationPhase.Ready && !request.accepting && !request.declining;
return {
id: session.id,
transactionId: request.transactionId,
roomId: request.roomId,
otherUserId: request.otherUserId,
otherDeviceId: request.otherDeviceId,
isSelfVerification: request.isSelfVerification,
initiatedByMe: request.initiatedByMe,
phase,
phaseName: this.getVerificationPhaseName(phase),
pending: request.pending,
methods: Array.isArray(request.methods) ? request.methods : [],
chosenMethod: request.chosenMethod ?? null,
canAccept,
hasSas: Boolean(session.sasCallbacks),
hasReciprocateQr: Boolean(session.reciprocateQrCallbacks),
completed: phase === VerificationPhase.Done,
error: session.error,
createdAt: new Date(session.createdAtMs).toISOString(),
updatedAt: new Date(session.updatedAtMs).toISOString(),
};
}
private findVerificationSession(id: string): MatrixVerificationSession {
const direct = this.verificationSessions.get(id);
if (direct) {
return direct;
}
for (const session of this.verificationSessions.values()) {
if (session.request.transactionId === id) {
return session;
}
}
throw new Error(`Matrix verification request not found: ${id}`);
}
private ensureVerificationRequestTracked(session: MatrixVerificationSession): void {
const requestObj = session.request as unknown as object;
if (this.trackedVerificationRequests.has(requestObj)) {
return;
}
this.trackedVerificationRequests.add(requestObj);
session.request.on(VerificationRequestEvent.Change, () => {
this.touchVerificationSession(session);
if (session.request.verifier) {
this.attachVerifierToVerificationSession(session, session.request.verifier);
}
});
}
private attachVerifierToVerificationSession(
session: MatrixVerificationSession,
verifier: MatrixVerifierLike,
): void {
session.activeVerifier = verifier;
this.touchVerificationSession(session);
const maybeSas = verifier.getShowSasCallbacks();
if (maybeSas) {
session.sasCallbacks = maybeSas;
}
const maybeReciprocateQr = verifier.getReciprocateQrCodeCallbacks();
if (maybeReciprocateQr) {
session.reciprocateQrCallbacks = maybeReciprocateQr;
}
const verifierObj = verifier as unknown as object;
if (this.trackedVerificationVerifiers.has(verifierObj)) {
return;
}
this.trackedVerificationVerifiers.add(verifierObj);
verifier.on(VerifierEvent.ShowSas, (sas) => {
session.sasCallbacks = sas as MatrixShowSasCallbacks;
this.touchVerificationSession(session);
});
verifier.on(VerifierEvent.ShowReciprocateQr, (qr) => {
session.reciprocateQrCallbacks = qr as MatrixShowQrCodeCallbacks;
this.touchVerificationSession(session);
});
verifier.on(VerifierEvent.Cancel, (err) => {
session.error = err instanceof Error ? err.message : String(err);
this.touchVerificationSession(session);
});
}
private ensureVerificationStarted(session: MatrixVerificationSession): void {
if (!session.activeVerifier || session.verifyStarted) {
return;
}
session.verifyStarted = true;
const verifier = session.activeVerifier;
session.verifyPromise = verifier
.verify()
.then(() => {
this.touchVerificationSession(session);
})
.catch((err) => {
session.error = err instanceof Error ? err.message : String(err);
this.touchVerificationSession(session);
});
}
trackVerificationRequest(request: MatrixVerificationRequestLike): MatrixVerificationSummary {
const txId = request.transactionId?.trim();
if (txId) {
for (const existing of this.verificationSessions.values()) {
if (existing.request.transactionId === txId) {
existing.request = request;
this.ensureVerificationRequestTracked(existing);
if (request.verifier) {
this.attachVerifierToVerificationSession(existing, request.verifier);
}
this.touchVerificationSession(existing);
return this.buildVerificationSummary(existing);
}
}
}
const now = Date.now();
const id = `verification-${++this.verificationSessionCounter}`;
const session: MatrixVerificationSession = {
id,
request,
createdAtMs: now,
updatedAtMs: now,
verifyStarted: false,
};
this.verificationSessions.set(session.id, session);
this.ensureVerificationRequestTracked(session);
if (request.verifier) {
this.attachVerifierToVerificationSession(session, request.verifier);
}
return this.buildVerificationSummary(session);
}
async requestOwnUserVerification(
crypto: MatrixVerificationCryptoApi | undefined,
): Promise<MatrixVerificationSummary | null> {
if (!crypto) {
return null;
}
const request =
(await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null;
if (!request) {
return null;
}
return this.trackVerificationRequest(request);
}
listVerifications(): MatrixVerificationSummary[] {
const summaries = Array.from(this.verificationSessions.values()).map((session) =>
this.buildVerificationSummary(session),
);
return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
async requestVerification(
crypto: MatrixVerificationCryptoApi | undefined,
params: {
ownUser?: boolean;
userId?: string;
deviceId?: string;
roomId?: string;
},
): Promise<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

@@ -49,6 +49,7 @@ export async function resolveMatrixClient(opts: {
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: auth.deviceId,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
});

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"),
@@ -266,6 +267,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
let accessToken = existing.accessToken ?? "";
let password = existing.password ?? "";
let userId = existing.userId ?? "";
let register = existing.register === true;
if (accessToken || password) {
const keep = await prompter.confirm({
@@ -276,6 +278,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
accessToken = "";
password = "";
userId = "";
register = false;
}
}
@@ -286,6 +289,10 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
options: [
{ value: "token", label: "Access token (user ID fetched automatically)" },
{ value: "password", label: "Password (requires user ID)" },
{
value: "register",
label: "Register account (open homeserver registration required)",
},
],
});
@@ -299,8 +306,9 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
// With access token, we can fetch the userId automatically - don't prompt for it
// The client.ts will use whoami() to get it
userId = "";
register = false;
} else {
// Password auth requires user ID upfront
// Password auth and registration mode require user ID upfront
userId = String(
await prompter.text({
message: "Matrix user ID",
@@ -326,6 +334,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
register = authMode === "register";
}
}
@@ -353,6 +362,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
userId: userId || undefined,
accessToken: accessToken || undefined,
password: password || undefined,
register,
deviceName: deviceName || undefined,
encryption: enableEncryption || undefined,
},

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,6 +52,10 @@ export type MatrixConfig = {
accessToken?: string;
/** Matrix password (used only to fetch access token). */
password?: string;
/** Auto-register account when password login fails (open registration homeservers). */
register?: boolean;
/** Optional Matrix device id (recommended when using access tokens + E2EE). */
deviceId?: string;
/** Optional device name when logging in via password. */
deviceName?: string;
/** Initial sync limit for startup (defaults to matrix-js-sdk behavior). */

9
pnpm-lock.yaml generated
View File

@@ -370,6 +370,9 @@ importers:
'@matrix-org/matrix-sdk-crypto-nodejs':
specifier: ^0.4.0
version: 0.4.0
fake-indexeddb:
specifier: ^6.2.5
version: 6.2.5
markdown-it:
specifier: 14.1.0
version: 14.1.0
@@ -3487,6 +3490,10 @@ packages:
fast-content-type-parse@3.0.0:
resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==}
fake-indexeddb@6.2.5:
resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==}
engines: {node: '>=18'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -8601,6 +8608,8 @@ snapshots:
fast-content-type-parse@3.0.0: {}
fake-indexeddb@6.2.5: {}
fast-deep-equal@3.1.3: {}
fast-uri@3.1.0: {}