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

@@ -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). */