mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 21:04:28 +00:00
Matrix: stabilize E2EE verification and modularize SDK
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
220
extensions/matrix/src/matrix/actions/verification.ts
Normal file
220
extensions/matrix/src/matrix/actions/verification.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConsoleLogger, LogService } from "../sdk.js";
|
||||
import { ConsoleLogger, LogService } from "../sdk/logger.js";
|
||||
|
||||
let matrixSdkLoggingConfigured = false;
|
||||
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
277
extensions/matrix/src/matrix/sdk/decrypt-bridge.ts
Normal file
277
extensions/matrix/src/matrix/sdk/decrypt-bridge.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
164
extensions/matrix/src/matrix/sdk/idb-persistence.ts
Normal file
164
extensions/matrix/src/matrix/sdk/idb-persistence.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
57
extensions/matrix/src/matrix/sdk/logger.ts
Normal file
57
extensions/matrix/src/matrix/sdk/logger.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
163
extensions/matrix/src/matrix/sdk/transport.ts
Normal file
163
extensions/matrix/src/matrix/sdk/transport.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
434
extensions/matrix/src/matrix/sdk/verification-manager.ts
Normal file
434
extensions/matrix/src/matrix/sdk/verification-manager.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user