Compare commits
8 Commits
secrets/pr
...
pr-11705
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
668c8f76f3 | ||
|
|
cc47efd430 | ||
|
|
bba2de35dc | ||
|
|
a7fb08e6bd | ||
|
|
ba8e08186d | ||
|
|
afd46ce9b8 | ||
|
|
66c0f4bcc7 | ||
|
|
35f60d65d5 |
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
|
||||
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
|
||||
- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras.
|
||||
- Matrix plugin: harden E2EE bootstrap and verification flows (cross-signing + secret storage + own-device trust), add bounded decrypt retry with crypto-key signals, enforce safe absolute-endpoint request handling, and split SDK internals into focused modules with coverage. (#11705) Thanks @gumadeiras.
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ on any homeserver, so you need a Matrix account for the bot. Once it is logged i
|
||||
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
|
||||
but it requires E2EE to be enabled.
|
||||
|
||||
Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
|
||||
Status: supported via plugin (`matrix-js-sdk`). Direct messages, rooms, threads, media, reactions,
|
||||
polls (send + poll-start as text), location, and E2EE (with crypto support).
|
||||
|
||||
## Plugin required
|
||||
@@ -65,13 +65,16 @@ Details: [Plugins](/tools/plugin)
|
||||
- Or set `channels.matrix.userId` + `channels.matrix.password`: OpenClaw calls the same
|
||||
login endpoint, stores the access token in `~/.openclaw/credentials/matrix/credentials.json`,
|
||||
and reuses it on next start.
|
||||
- Optional registration mode: set `channels.matrix.register: true` to attempt account creation
|
||||
when password login fails (for homeservers that allow open registration).
|
||||
|
||||
4. Configure credentials:
|
||||
- Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`)
|
||||
- Or config: `channels.matrix.*`
|
||||
- If both are set, config takes precedence.
|
||||
- With access token: user ID is fetched automatically via `/whoami`.
|
||||
- With access token: user ID and device ID are fetched automatically via `/whoami` if missing.
|
||||
- When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`).
|
||||
- Optional: set `channels.matrix.deviceId` (or `MATRIX_DEVICE_ID`) to pin to a known device ID.
|
||||
5. Restart the gateway (or finish onboarding).
|
||||
6. Start a DM with the bot or invite it to a room from any Matrix client
|
||||
(Element, Beeper, etc.; see [https://matrix.org/ecosystem/clients/](https://matrix.org/ecosystem/clients/)). Beeper requires E2EE,
|
||||
@@ -116,8 +119,15 @@ Enable with `channels.matrix.encryption: true`:
|
||||
|
||||
- If the crypto module loads, encrypted rooms are decrypted automatically.
|
||||
- Outbound media is encrypted when sending to encrypted rooms.
|
||||
- On first connection, OpenClaw requests device verification from your other sessions.
|
||||
- Verify the device in another Matrix client (Element, etc.) to enable key sharing.
|
||||
- Cross-signing and secret storage are bootstrapped at startup when possible.
|
||||
- OpenClaw creates or reuses a recovery key for secret storage and stores it at:
|
||||
`~/.openclaw/credentials/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/recovery-key.json`
|
||||
- On startup, OpenClaw requests self-verification and can accept incoming verification requests.
|
||||
- OpenClaw also marks and cross-signs its own device when crypto APIs are available, which improves
|
||||
trust establishment on fresh sessions.
|
||||
- Failed decryptions are retried with bounded backoff and retried immediately again when new room keys
|
||||
arrive, so new key-sharing events recover without waiting for the next retry window.
|
||||
- Verify in another Matrix client (Element, etc.) to establish trust and improve key sharing.
|
||||
- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt;
|
||||
OpenClaw logs a warning.
|
||||
- If you see missing crypto module errors (for example, `@matrix-org/matrix-sdk-crypto-nodejs-*`),
|
||||
@@ -126,8 +136,9 @@ Enable with `channels.matrix.encryption: true`:
|
||||
`node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`.
|
||||
|
||||
Crypto state is stored per account + access token in
|
||||
`~/.openclaw/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/crypto/`
|
||||
(SQLite database). Sync state lives alongside it in `bot-storage.json`.
|
||||
`~/.openclaw/credentials/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/`.
|
||||
Crypto data lives in IndexedDB plus a persisted snapshot (`crypto-idb-snapshot.json`),
|
||||
with sync state in `bot-storage.json`.
|
||||
If the access token (device) changes, a new store is created and the bot must be
|
||||
re-verified for encrypted rooms.
|
||||
|
||||
@@ -136,6 +147,25 @@ When E2EE is enabled, the bot will request verification from your other sessions
|
||||
Open Element (or another client) and approve the verification request to establish trust.
|
||||
Once verified, the bot can decrypt messages in encrypted rooms.
|
||||
|
||||
## Verification operations
|
||||
|
||||
When E2EE is enabled and `channels.matrix.actions.verification` is on, the Matrix
|
||||
`permissions` action exposes verification operations:
|
||||
|
||||
- `encryption-status`: report encryption and recovery key status.
|
||||
- `verification-list`: list tracked verification requests.
|
||||
- `verification-request`: start verification (`ownUser`, `userId+deviceId`, or `userId+roomId`).
|
||||
- `verification-accept`, `verification-cancel`: accept or cancel a request.
|
||||
- `verification-start`: start SAS verification.
|
||||
- `verification-sas`, `verification-confirm`, `verification-mismatch`: read and confirm or reject SAS.
|
||||
- `verification-generate-qr`, `verification-scan-qr`, `verification-confirm-qr`: QR-based flows.
|
||||
|
||||
Use these via the `permissions` action by setting `operation` (or `mode`) to one of:
|
||||
`encryption-status`, `verification-list`, `verification-request`, `verification-accept`,
|
||||
`verification-cancel`, `verification-start`, `verification-generate-qr`,
|
||||
`verification-scan-qr`, `verification-sas`, `verification-confirm`,
|
||||
`verification-mismatch`, `verification-confirm-qr`.
|
||||
|
||||
## Routing model
|
||||
|
||||
- Replies always go back to Matrix.
|
||||
@@ -225,6 +255,11 @@ Common failures:
|
||||
- Logged in but room messages ignored: room blocked by `groupPolicy` or room allowlist.
|
||||
- DMs ignored: sender pending approval when `channels.matrix.dm.policy="pairing"`.
|
||||
- Encrypted rooms fail: crypto support or encryption settings mismatch.
|
||||
- "User verification unavailable" in Element for the bot profile:
|
||||
- Ensure `channels.matrix.encryption: true` is set and restart.
|
||||
- Ensure the bot logs in with a stable `channels.matrix.deviceId`.
|
||||
- Send at least one new encrypted message after verification. Older messages from before
|
||||
the current bot device login may remain undecryptable.
|
||||
|
||||
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
|
||||
|
||||
@@ -239,6 +274,8 @@ Provider options:
|
||||
- `channels.matrix.userId`: Matrix user ID (optional with access token).
|
||||
- `channels.matrix.accessToken`: access token.
|
||||
- `channels.matrix.password`: password for login (token stored).
|
||||
- `channels.matrix.register`: try account registration if password login fails.
|
||||
- `channels.matrix.deviceId`: preferred device ID (used for E2EE initialization).
|
||||
- `channels.matrix.deviceName`: device display name.
|
||||
- `channels.matrix.encryption`: enable E2EE (default: false).
|
||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
||||
@@ -257,3 +294,4 @@ Provider options:
|
||||
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
|
||||
- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join.
|
||||
- `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo).
|
||||
- `channels.matrix.actions.verification`: enable verification action operations.
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
|
||||
"@vector-im/matrix-bot-sdk": "0.8.0-element.3",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"markdown-it": "14.1.0",
|
||||
"matrix-js-sdk": "^40.1.0",
|
||||
"music-metadata": "^11.11.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -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,8 @@ export async function resolveActionClient(
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
password: auth.password,
|
||||
deviceId: auth.deviceId,
|
||||
encryption: auth.encryption,
|
||||
localTimeoutMs: opts.timeoutMs,
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ export async function readMatrixMessages(
|
||||
: 20;
|
||||
const token = opts.before?.trim() || opts.after?.trim() || undefined;
|
||||
const dir = opts.after ? "f" : "b";
|
||||
// @vector-im/matrix-bot-sdk uses doRequest for room messages
|
||||
// Room history is queried via the low-level endpoint for compatibility.
|
||||
const res = (await client.doRequest(
|
||||
"GET",
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function listMatrixReactions(
|
||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||
? Math.max(1, Math.floor(opts.limit))
|
||||
: 100;
|
||||
// @vector-im/matrix-bot-sdk uses doRequest for relations
|
||||
// Relations are queried via the low-level endpoint for compatibility.
|
||||
const res = (await client.doRequest(
|
||||
"GET",
|
||||
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
||||
|
||||
@@ -9,10 +9,8 @@ export async function getMatrixMemberInfo(
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
|
||||
// @vector-im/matrix-bot-sdk uses getUserProfile
|
||||
const profile = await client.getUserProfile(userId);
|
||||
// Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
|
||||
// We'd need to fetch room state separately if needed
|
||||
// Membership and power levels are not included in profile calls; fetch state separately if needed.
|
||||
return {
|
||||
userId,
|
||||
profile: {
|
||||
@@ -35,7 +33,6 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
// @vector-im/matrix-bot-sdk uses getRoomState for state events
|
||||
let name: string | null = null;
|
||||
let topic: string | null = null;
|
||||
let canonicalAlias: string | null = null;
|
||||
@@ -43,21 +40,21 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
|
||||
|
||||
try {
|
||||
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
|
||||
name = nameState?.name ?? null;
|
||||
name = typeof nameState?.name === "string" ? nameState.name : null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
|
||||
topic = topicState?.topic ?? null;
|
||||
topic = typeof topicState?.topic === "string" ? topicState.topic : null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
|
||||
canonicalAlias = aliasState?.alias ?? null;
|
||||
canonicalAlias = typeof aliasState?.alias === "string" ? aliasState.alias : null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import {
|
||||
EventType,
|
||||
type MatrixMessageSummary,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MatrixClient, MatrixRawEvent, MessageEventContent } from "../sdk.js";
|
||||
|
||||
export const MsgType = {
|
||||
Text: "m.text",
|
||||
@@ -16,7 +16,7 @@ export const EventType = {
|
||||
Reaction: "m.reaction",
|
||||
} as const;
|
||||
|
||||
export type RoomMessageEventContent = {
|
||||
export type RoomMessageEventContent = MessageEventContent & {
|
||||
msgtype: string;
|
||||
body: string;
|
||||
"m.new_content"?: RoomMessageEventContent;
|
||||
@@ -43,17 +43,6 @@ export type RoomTopicEventContent = {
|
||||
topic?: string;
|
||||
};
|
||||
|
||||
export type MatrixRawEvent = {
|
||||
event_id: string;
|
||||
sender: string;
|
||||
type: string;
|
||||
origin_server_ts: number;
|
||||
content: Record<string, unknown>;
|
||||
unsigned?: {
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixActionClientOpts = {
|
||||
client?: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
|
||||
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,4 +1,4 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
let activeClient: MatrixClient | null = null;
|
||||
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { resolveMatrixConfig } from "./client.js";
|
||||
import { resolveMatrixAuth, resolveMatrixConfig } from "./client.js";
|
||||
import * as credentialsModule from "./credentials.js";
|
||||
import * as sdkModule from "./sdk.js";
|
||||
|
||||
const saveMatrixCredentialsMock = vi.fn();
|
||||
const prepareMatrixRegisterModeMock = vi.fn(async () => null);
|
||||
const finalizeMatrixRegisterConfigAfterSuccessMock = vi.fn(async () => false);
|
||||
|
||||
vi.mock("./credentials.js", () => ({
|
||||
loadMatrixCredentials: vi.fn(() => null),
|
||||
saveMatrixCredentials: (...args: unknown[]) => saveMatrixCredentialsMock(...args),
|
||||
credentialsMatchConfig: vi.fn(() => false),
|
||||
touchMatrixCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./client/register-mode.js", () => ({
|
||||
prepareMatrixRegisterMode: (...args: unknown[]) => prepareMatrixRegisterModeMock(...args),
|
||||
finalizeMatrixRegisterConfigAfterSuccess: (...args: unknown[]) =>
|
||||
finalizeMatrixRegisterConfigAfterSuccessMock(...args),
|
||||
resetPreparedMatrixRegisterModesForTests: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("resolveMatrixConfig", () => {
|
||||
it("prefers config over env", () => {
|
||||
@@ -29,6 +49,8 @@ describe("resolveMatrixConfig", () => {
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
register: false,
|
||||
deviceId: undefined,
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
encryption: false,
|
||||
@@ -42,6 +64,7 @@ describe("resolveMatrixConfig", () => {
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_ID: "ENVDEVICE",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
@@ -49,8 +72,328 @@ describe("resolveMatrixConfig", () => {
|
||||
expect(resolved.userId).toBe("@env:example.org");
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
expect(resolved.register).toBe(false);
|
||||
expect(resolved.deviceId).toBe("ENVDEVICE");
|
||||
expect(resolved.deviceName).toBe("EnvDevice");
|
||||
expect(resolved.initialSyncLimit).toBeUndefined();
|
||||
expect(resolved.encryption).toBe(false);
|
||||
});
|
||||
|
||||
it("reads register flag from config and env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
register: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const resolvedFromCfg = resolveMatrixConfig(cfg, {} as NodeJS.ProcessEnv);
|
||||
expect(resolvedFromCfg.register).toBe(true);
|
||||
|
||||
const resolvedFromEnv = resolveMatrixConfig(
|
||||
{} as CoreConfig,
|
||||
{
|
||||
MATRIX_REGISTER: "1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
);
|
||||
expect(resolvedFromEnv.register).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMatrixAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
saveMatrixCredentialsMock.mockReset();
|
||||
prepareMatrixRegisterModeMock.mockReset();
|
||||
finalizeMatrixRegisterConfigAfterSuccessMock.mockReset();
|
||||
});
|
||||
|
||||
it("uses the hardened client request path for password login and persists deviceId", async () => {
|
||||
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
|
||||
access_token: "tok-123",
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(doRequestSpy).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/_matrix/client/v3/login",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
type: "m.login.password",
|
||||
}),
|
||||
);
|
||||
expect(auth).toMatchObject({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
});
|
||||
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("can register account when password login fails and register mode is enabled", async () => {
|
||||
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest");
|
||||
doRequestSpy
|
||||
.mockRejectedValueOnce(new Error("Invalid username or password"))
|
||||
.mockResolvedValueOnce({
|
||||
access_token: "tok-registered",
|
||||
user_id: "@newbot:example.org",
|
||||
device_id: "REGDEVICE123",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@newbot:example.org",
|
||||
password: "secret",
|
||||
register: true,
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(doRequestSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"POST",
|
||||
"/_matrix/client/v3/login",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
type: "m.login.password",
|
||||
device_id: undefined,
|
||||
}),
|
||||
);
|
||||
expect(doRequestSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"POST",
|
||||
"/_matrix/client/v3/register",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
username: "newbot",
|
||||
auth: { type: "m.login.dummy" },
|
||||
}),
|
||||
);
|
||||
expect(auth).toMatchObject({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@newbot:example.org",
|
||||
accessToken: "tok-registered",
|
||||
deviceId: "REGDEVICE123",
|
||||
encryption: true,
|
||||
});
|
||||
expect(prepareMatrixRegisterModeMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@newbot:example.org",
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(finalizeMatrixRegisterConfigAfterSuccessMock).toHaveBeenCalledWith({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@newbot:example.org",
|
||||
deviceId: "REGDEVICE123",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores cached credentials when matrix.register=true", async () => {
|
||||
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "cached-token",
|
||||
deviceId: "CACHEDDEVICE",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
|
||||
access_token: "tok-123",
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
register: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
|
||||
|
||||
expect(doRequestSpy).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/_matrix/client/v3/login",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
type: "m.login.password",
|
||||
}),
|
||||
);
|
||||
expect(auth.accessToken).toBe("tok-123");
|
||||
expect(prepareMatrixRegisterModeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("requires matrix.password when matrix.register=true", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
register: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
|
||||
"Matrix password is required when matrix.register=true",
|
||||
);
|
||||
expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled();
|
||||
expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires matrix.userId when matrix.register=true", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
password: "secret",
|
||||
register: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
|
||||
"Matrix userId is required when matrix.register=true",
|
||||
);
|
||||
expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled();
|
||||
expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to config deviceId when cached credentials are missing it", async () => {
|
||||
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
|
||||
|
||||
expect(auth.deviceId).toBe("DEVICE123");
|
||||
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves missing whoami identity fields for token auth", async () => {
|
||||
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
|
||||
expect(auth).toMatchObject({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses config deviceId with cached credentials when token is loaded from cache", async () => {
|
||||
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
|
||||
|
||||
expect(auth).toMatchObject({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,101 @@
|
||||
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { MatrixClient } from "../sdk.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import {
|
||||
finalizeMatrixRegisterConfigAfterSuccess,
|
||||
prepareMatrixRegisterMode,
|
||||
} from "./register-mode.js";
|
||||
|
||||
function clean(value?: string): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function parseOptionalBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (["0", "false", "no", "off"].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveMatrixLocalpart(userId: string): string {
|
||||
const trimmed = userId.trim();
|
||||
const noPrefix = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
||||
const localpart = noPrefix.split(":")[0]?.trim() || "";
|
||||
if (!localpart) {
|
||||
throw new Error(`Invalid Matrix userId for registration: ${userId}`);
|
||||
}
|
||||
return localpart;
|
||||
}
|
||||
|
||||
async function registerMatrixPasswordAccount(params: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
password: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
}): Promise<{
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
}> {
|
||||
const registerClient = new MatrixClient(params.homeserver, "");
|
||||
const payload = {
|
||||
username: resolveMatrixLocalpart(params.userId),
|
||||
password: params.password,
|
||||
inhibit_login: false,
|
||||
device_id: params.deviceId,
|
||||
initial_device_display_name: params.deviceName ?? "OpenClaw Gateway",
|
||||
};
|
||||
|
||||
let firstError: unknown = null;
|
||||
try {
|
||||
return (await registerClient.doRequest("POST", "/_matrix/client/v3/register", undefined, {
|
||||
...payload,
|
||||
auth: { type: "m.login.dummy" },
|
||||
})) as {
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
} catch (err) {
|
||||
firstError = err;
|
||||
}
|
||||
|
||||
try {
|
||||
return (await registerClient.doRequest(
|
||||
"POST",
|
||||
"/_matrix/client/v3/register",
|
||||
undefined,
|
||||
payload,
|
||||
)) as {
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
} catch (err) {
|
||||
const firstMessage = firstError instanceof Error ? firstError.message : String(firstError);
|
||||
const secondMessage = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`Matrix registration failed (dummy auth: ${firstMessage}; plain registration: ${secondMessage})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMatrixConfig(
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
@@ -17,6 +105,9 @@ export function resolveMatrixConfig(
|
||||
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
|
||||
const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
|
||||
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
|
||||
const register =
|
||||
parseOptionalBoolean(matrix.register) ?? parseOptionalBoolean(env.MATRIX_REGISTER) ?? false;
|
||||
const deviceId = clean(matrix.deviceId) || clean(env.MATRIX_DEVICE_ID) || undefined;
|
||||
const deviceName = clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
|
||||
const initialSyncLimit =
|
||||
typeof matrix.initialSyncLimit === "number"
|
||||
@@ -28,6 +119,8 @@ export function resolveMatrixConfig(
|
||||
userId,
|
||||
accessToken,
|
||||
password,
|
||||
register,
|
||||
deviceId,
|
||||
deviceName,
|
||||
initialSyncLimit,
|
||||
encryption,
|
||||
@@ -41,6 +134,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
const env = params?.env ?? process.env;
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
const registerFromConfig = cfg.channels?.matrix?.register === true;
|
||||
if (!resolved.homeserver) {
|
||||
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
||||
}
|
||||
@@ -62,40 +156,84 @@ export async function resolveMatrixAuth(params?: {
|
||||
? cached
|
||||
: null;
|
||||
|
||||
if (registerFromConfig) {
|
||||
if (!resolved.userId) {
|
||||
throw new Error("Matrix userId is required when matrix.register=true");
|
||||
}
|
||||
if (!resolved.password) {
|
||||
throw new Error("Matrix password is required when matrix.register=true");
|
||||
}
|
||||
await prepareMatrixRegisterMode({
|
||||
cfg,
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
// If we have an access token, we can fetch userId via whoami if not provided
|
||||
if (resolved.accessToken) {
|
||||
if (resolved.accessToken && !registerFromConfig) {
|
||||
let userId = resolved.userId;
|
||||
if (!userId) {
|
||||
// Fetch userId from access token via whoami
|
||||
const hasMatchingCachedToken = cachedCredentials?.accessToken === resolved.accessToken;
|
||||
let knownDeviceId = hasMatchingCachedToken
|
||||
? cachedCredentials?.deviceId || resolved.deviceId
|
||||
: resolved.deviceId;
|
||||
|
||||
if (!userId || !knownDeviceId) {
|
||||
// Fetch whoami when we need to resolve userId and/or deviceId from token auth.
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
|
||||
const whoami = await tempClient.getUserId();
|
||||
userId = whoami;
|
||||
// Save the credentials with the fetched userId
|
||||
const whoami = (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
if (!userId) {
|
||||
const fetchedUserId = whoami.user_id?.trim();
|
||||
if (!fetchedUserId) {
|
||||
throw new Error("Matrix whoami did not return user_id");
|
||||
}
|
||||
userId = fetchedUserId;
|
||||
}
|
||||
if (!knownDeviceId) {
|
||||
knownDeviceId = whoami.device_id?.trim() || resolved.deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRefreshCachedCredentials =
|
||||
!cachedCredentials ||
|
||||
!hasMatchingCachedToken ||
|
||||
cachedCredentials.userId !== userId ||
|
||||
(cachedCredentials.deviceId || undefined) !== knownDeviceId;
|
||||
if (shouldRefreshCachedCredentials) {
|
||||
saveMatrixCredentials({
|
||||
homeserver: resolved.homeserver,
|
||||
userId,
|
||||
accessToken: resolved.accessToken,
|
||||
deviceId: knownDeviceId,
|
||||
});
|
||||
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
|
||||
} else if (hasMatchingCachedToken) {
|
||||
touchMatrixCredentials(env);
|
||||
}
|
||||
return {
|
||||
homeserver: resolved.homeserver,
|
||||
userId,
|
||||
accessToken: resolved.accessToken,
|
||||
password: resolved.password,
|
||||
deviceId: knownDeviceId,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
};
|
||||
}
|
||||
|
||||
if (cachedCredentials) {
|
||||
if (cachedCredentials && !registerFromConfig) {
|
||||
touchMatrixCredentials(env);
|
||||
return {
|
||||
homeserver: cachedCredentials.homeserver,
|
||||
userId: cachedCredentials.userId,
|
||||
accessToken: cachedCredentials.accessToken,
|
||||
password: resolved.password,
|
||||
deviceId: cachedCredentials.deviceId || resolved.deviceId,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
@@ -112,38 +250,59 @@ export async function resolveMatrixAuth(params?: {
|
||||
);
|
||||
}
|
||||
|
||||
// Login with password using HTTP API
|
||||
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: resolved.userId },
|
||||
password: resolved.password,
|
||||
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
const errorText = await loginResponse.text();
|
||||
throw new Error(`Matrix login failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const login = (await loginResponse.json()) as {
|
||||
// Login with password using the same hardened request path as other Matrix HTTP calls.
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const loginClient = new MatrixClient(resolved.homeserver, "");
|
||||
let login: {
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
try {
|
||||
login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, {
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: resolved.userId },
|
||||
password: resolved.password,
|
||||
device_id: resolved.deviceId,
|
||||
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
|
||||
})) as {
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
} catch (loginErr) {
|
||||
if (!resolved.register) {
|
||||
throw loginErr;
|
||||
}
|
||||
try {
|
||||
login = await registerMatrixPasswordAccount({
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
password: resolved.password,
|
||||
deviceId: resolved.deviceId,
|
||||
deviceName: resolved.deviceName,
|
||||
});
|
||||
} catch (registerErr) {
|
||||
const loginMessage = loginErr instanceof Error ? loginErr.message : String(loginErr);
|
||||
const registerMessage =
|
||||
registerErr instanceof Error ? registerErr.message : String(registerErr);
|
||||
throw new Error(
|
||||
`Matrix login failed (${loginMessage}) and account registration failed (${registerMessage})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const accessToken = login.access_token?.trim();
|
||||
if (!accessToken) {
|
||||
throw new Error("Matrix login did not return an access token");
|
||||
throw new Error("Matrix login/registration did not return an access token");
|
||||
}
|
||||
|
||||
const auth: MatrixAuth = {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: login.user_id ?? resolved.userId,
|
||||
accessToken,
|
||||
password: resolved.password,
|
||||
deviceId: login.device_id ?? resolved.deviceId,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
@@ -153,8 +312,16 @@ export async function resolveMatrixAuth(params?: {
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
deviceId: login.device_id,
|
||||
deviceId: auth.deviceId,
|
||||
});
|
||||
|
||||
if (registerFromConfig) {
|
||||
await finalizeMatrixRegisterConfigAfterSuccess({
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
deviceId: auth.deviceId,
|
||||
});
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
|
||||
import {
|
||||
LogService,
|
||||
MatrixClient,
|
||||
SimpleFsStorageProvider,
|
||||
RustSdkCryptoStorageProvider,
|
||||
} from "@vector-im/matrix-bot-sdk";
|
||||
import fs from "node:fs";
|
||||
import { MatrixClient } from "../sdk.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import {
|
||||
maybeMigrateLegacyStorage,
|
||||
@@ -13,111 +7,50 @@ import {
|
||||
writeStorageMeta,
|
||||
} from "./storage.js";
|
||||
|
||||
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
||||
if (input == null) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(input)) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
`Expected ${label} list to be an array, got ${typeof input}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const filtered = input.filter(
|
||||
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
|
||||
);
|
||||
if (filtered.length !== input.length) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
|
||||
);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export async function createMatrixClient(params: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
userId?: string;
|
||||
accessToken: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
encryption?: boolean;
|
||||
localTimeoutMs?: number;
|
||||
initialSyncLimit?: number;
|
||||
accountId?: string | null;
|
||||
}): Promise<MatrixClient> {
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const env = process.env;
|
||||
const userId = params.userId?.trim() || "unknown";
|
||||
const matrixClientUserId = params.userId?.trim() || undefined;
|
||||
|
||||
// Create storage provider
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
userId,
|
||||
accessToken: params.accessToken,
|
||||
accountId: params.accountId,
|
||||
env,
|
||||
});
|
||||
maybeMigrateLegacyStorage({ storagePaths, env });
|
||||
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
|
||||
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath);
|
||||
|
||||
// Create crypto storage if encryption is enabled
|
||||
let cryptoStorage: ICryptoStorageProvider | undefined;
|
||||
if (params.encryption) {
|
||||
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
|
||||
|
||||
try {
|
||||
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
cryptoStorage = new RustSdkCryptoStorageProvider(storagePaths.cryptoPath, StoreType.Sqlite);
|
||||
} catch (err) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
"Failed to initialize crypto storage, E2EE disabled:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
writeStorageMeta({
|
||||
storagePaths,
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
userId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
const client = new MatrixClient(params.homeserver, params.accessToken, storage, cryptoStorage);
|
||||
const cryptoDatabasePrefix = `openclaw-matrix-${storagePaths.accountKey}-${storagePaths.tokenHash}`;
|
||||
|
||||
if (client.crypto) {
|
||||
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
|
||||
client.crypto.updateSyncData = async (
|
||||
toDeviceMessages,
|
||||
otkCounts,
|
||||
unusedFallbackKeyAlgs,
|
||||
changedDeviceLists,
|
||||
leftDeviceLists,
|
||||
) => {
|
||||
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
|
||||
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
|
||||
try {
|
||||
return await originalUpdateSyncData(
|
||||
toDeviceMessages,
|
||||
otkCounts,
|
||||
unusedFallbackKeyAlgs,
|
||||
safeChanged,
|
||||
safeLeft,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
|
||||
if (message.includes("Expect value to be String")) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
"Ignoring malformed device list entries during crypto sync",
|
||||
message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return client;
|
||||
return new MatrixClient(params.homeserver, params.accessToken, undefined, undefined, {
|
||||
userId: matrixClientUserId,
|
||||
password: params.password,
|
||||
deviceId: params.deviceId,
|
||||
encryption: params.encryption,
|
||||
localTimeoutMs: params.localTimeoutMs,
|
||||
initialSyncLimit: params.initialSyncLimit,
|
||||
recoveryKeyPath: storagePaths.recoveryKeyPath,
|
||||
idbSnapshotPath: storagePaths.idbSnapshotPath,
|
||||
cryptoDatabasePrefix,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk";
|
||||
import { ConsoleLogger, LogService } from "../sdk/logger.js";
|
||||
|
||||
let matrixSdkLoggingConfigured = false;
|
||||
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||
|
||||
97
extensions/matrix/src/matrix/client/register-mode.test.ts
Normal file
97
extensions/matrix/src/matrix/client/register-mode.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import * as runtimeModule from "../../runtime.js";
|
||||
import {
|
||||
finalizeMatrixRegisterConfigAfterSuccess,
|
||||
prepareMatrixRegisterMode,
|
||||
resetPreparedMatrixRegisterModesForTests,
|
||||
} from "./register-mode.js";
|
||||
|
||||
describe("matrix register mode helpers", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
resetPreparedMatrixRegisterModesForTests();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("moves existing matrix state into a .bak snapshot before fresh registration", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-register-mode-"));
|
||||
tempDirs.push(stateDir);
|
||||
const credentialsDir = path.join(stateDir, "credentials", "matrix");
|
||||
const accountsDir = path.join(credentialsDir, "accounts");
|
||||
fs.mkdirSync(accountsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(credentialsDir, "credentials.json"), '{"accessToken":"old"}\n');
|
||||
fs.writeFileSync(path.join(accountsDir, "dummy.txt"), "old-state\n");
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
register: true,
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const backupDir = await prepareMatrixRegisterMode({
|
||||
cfg,
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(backupDir).toBeTruthy();
|
||||
expect(fs.existsSync(path.join(credentialsDir, "credentials.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(credentialsDir, "accounts"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(backupDir as string, "credentials.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(backupDir as string, "accounts", "dummy.txt"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(backupDir as string, "matrix-config.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("updates matrix config after successful register mode auth", async () => {
|
||||
const writeConfigFile = vi.fn(async () => {});
|
||||
vi.spyOn(runtimeModule, "getMatrixRuntime").mockReturnValue({
|
||||
config: {
|
||||
loadConfig: () =>
|
||||
({
|
||||
channels: {
|
||||
matrix: {
|
||||
register: true,
|
||||
accessToken: "stale-token",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
},
|
||||
},
|
||||
}) as CoreConfig,
|
||||
writeConfigFile,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const updated = await finalizeMatrixRegisterConfigAfterSuccess({
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
deviceId: "DEVICE123",
|
||||
});
|
||||
expect(updated).toBe(true);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: expect.objectContaining({
|
||||
matrix: expect.objectContaining({
|
||||
register: false,
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
deviceId: "DEVICE123",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const written = writeConfigFile.mock.calls[0]?.[0] as CoreConfig;
|
||||
expect(written.channels?.matrix?.accessToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
125
extensions/matrix/src/matrix/client/register-mode.ts
Normal file
125
extensions/matrix/src/matrix/client/register-mode.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { resolveMatrixCredentialsDir } from "../credentials.js";
|
||||
|
||||
const preparedRegisterKeys = new Set<string>();
|
||||
|
||||
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv): string {
|
||||
try {
|
||||
return getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
} catch {
|
||||
// fall through to deterministic fallback for tests/early init
|
||||
}
|
||||
const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
|
||||
if (override) {
|
||||
if (override.startsWith("~")) {
|
||||
const expanded = override.replace(/^~(?=$|[\\/])/, os.homedir());
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
return path.resolve(override);
|
||||
}
|
||||
return path.join(os.homedir(), ".openclaw");
|
||||
}
|
||||
|
||||
function buildRegisterKey(params: { homeserver: string; userId: string }): string {
|
||||
return `${params.homeserver.trim().toLowerCase()}|${params.userId.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function buildBackupDirName(now = new Date()): string {
|
||||
const ts = now.toISOString().replace(/[:.]/g, "-");
|
||||
const suffix = Math.random().toString(16).slice(2, 8);
|
||||
return `${ts}-${suffix}`;
|
||||
}
|
||||
|
||||
export async function prepareMatrixRegisterMode(params: {
|
||||
cfg: CoreConfig;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<string | null> {
|
||||
const env = params.env ?? process.env;
|
||||
const registerKey = buildRegisterKey({
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
});
|
||||
if (preparedRegisterKeys.has(registerKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stateDir = resolveStateDirFromEnv(env);
|
||||
const credentialsDir = resolveMatrixCredentialsDir(env, stateDir);
|
||||
if (!fs.existsSync(credentialsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(credentialsDir).filter((name) => name !== ".bak");
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const backupRoot = path.join(credentialsDir, ".bak");
|
||||
fs.mkdirSync(backupRoot, { recursive: true });
|
||||
const backupDir = path.join(backupRoot, buildBackupDirName());
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
const matrixConfig = params.cfg.channels?.matrix ?? {};
|
||||
fs.writeFileSync(
|
||||
path.join(backupDir, "matrix-config.json"),
|
||||
JSON.stringify(matrixConfig, null, 2).trimEnd().concat("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
for (const entry of entries) {
|
||||
fs.renameSync(path.join(credentialsDir, entry), path.join(backupDir, entry));
|
||||
}
|
||||
|
||||
preparedRegisterKeys.add(registerKey);
|
||||
return backupDir;
|
||||
}
|
||||
|
||||
export async function finalizeMatrixRegisterConfigAfterSuccess(params: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
deviceId?: string;
|
||||
}): Promise<boolean> {
|
||||
let runtime: ReturnType<typeof getMatrixRuntime> | null = null;
|
||||
try {
|
||||
runtime = getMatrixRuntime();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cfg = runtime.config.loadConfig() as CoreConfig;
|
||||
if (cfg.channels?.matrix?.register !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const matrixCfg = cfg.channels?.matrix ?? {};
|
||||
const nextMatrix: Record<string, unknown> = {
|
||||
...matrixCfg,
|
||||
register: false,
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
...(params.deviceId?.trim() ? { deviceId: params.deviceId.trim() } : {}),
|
||||
};
|
||||
// Registration mode should continue relying on password + cached credentials, not stale inline token.
|
||||
delete nextMatrix.accessToken;
|
||||
|
||||
const next: CoreConfig = {
|
||||
...cfg,
|
||||
channels: {
|
||||
...(cfg.channels ?? {}),
|
||||
matrix: nextMatrix as CoreConfig["channels"]["matrix"],
|
||||
},
|
||||
};
|
||||
|
||||
await runtime.config.writeConfigFile(next as never);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resetPreparedMatrixRegisterModesForTests(): void {
|
||||
preparedRegisterKeys.clear();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { LogService } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import type { MatrixAuth } from "./types.js";
|
||||
import { LogService } from "../sdk/logger.js";
|
||||
import { resolveMatrixAuth } from "./config.js";
|
||||
import { createMatrixClient } from "./create-client.js";
|
||||
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
|
||||
@@ -36,8 +36,11 @@ async function createSharedMatrixClient(params: {
|
||||
homeserver: params.auth.homeserver,
|
||||
userId: params.auth.userId,
|
||||
accessToken: params.auth.accessToken,
|
||||
password: params.auth.password,
|
||||
deviceId: params.auth.deviceId,
|
||||
encryption: params.auth.encryption,
|
||||
localTimeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: params.auth.initialSyncLimit,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return {
|
||||
@@ -158,7 +161,7 @@ export async function waitForMatrixSync(_params: {
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
// @vector-im/matrix-bot-sdk handles sync internally in start()
|
||||
// matrix-js-sdk handles sync lifecycle in start() for this integration.
|
||||
// This is kept for API compatibility but is essentially a no-op now
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,9 @@ export type MatrixResolvedConfig = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken?: string;
|
||||
deviceId?: string;
|
||||
password?: string;
|
||||
register?: boolean;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
encryption?: boolean;
|
||||
@@ -19,6 +21,8 @@ export type MatrixAuth = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
encryption?: boolean;
|
||||
@@ -29,6 +33,8 @@ export type MatrixStoragePaths = {
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
metaPath: string;
|
||||
recoveryKeyPath: string;
|
||||
idbSnapshotPath: string;
|
||||
accountKey: string;
|
||||
tokenHash: string;
|
||||
};
|
||||
|
||||
@@ -5,18 +5,28 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
|
||||
const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
|
||||
const REQUIRED_MATRIX_PACKAGES = ["matrix-js-sdk", "@matrix-org/matrix-sdk-crypto-nodejs"];
|
||||
|
||||
export function isMatrixSdkAvailable(): boolean {
|
||||
function resolveMissingMatrixPackages(): string[] {
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
req.resolve(MATRIX_SDK_PACKAGE);
|
||||
return true;
|
||||
return REQUIRED_MATRIX_PACKAGES.filter((pkg) => {
|
||||
try {
|
||||
req.resolve(pkg);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
return [...REQUIRED_MATRIX_PACKAGES];
|
||||
}
|
||||
}
|
||||
|
||||
export function isMatrixSdkAvailable(): boolean {
|
||||
return resolveMissingMatrixPackages().length === 0;
|
||||
}
|
||||
|
||||
function resolvePluginRoot(): string {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
return path.resolve(currentDir, "..", "..");
|
||||
@@ -31,9 +41,13 @@ export async function ensureMatrixSdkInstalled(params: {
|
||||
}
|
||||
const confirm = params.confirm;
|
||||
if (confirm) {
|
||||
const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");
|
||||
const ok = await confirm(
|
||||
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs. Install now?",
|
||||
);
|
||||
if (!ok) {
|
||||
throw new Error("Matrix requires @vector-im/matrix-bot-sdk (install dependencies first).");
|
||||
throw new Error(
|
||||
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs (install dependencies first).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +67,11 @@ export async function ensureMatrixSdkInstalled(params: {
|
||||
);
|
||||
}
|
||||
if (!isMatrixSdkAvailable()) {
|
||||
const missing = resolveMissingMatrixPackages();
|
||||
throw new Error(
|
||||
"Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.",
|
||||
missing.length > 0
|
||||
? `Matrix dependency install completed but required packages are still missing: ${missing.join(", ")}`
|
||||
: "Matrix dependency install completed but Matrix dependencies are still missing.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
127
extensions/matrix/src/matrix/monitor/auto-join.test.ts
Normal file
127
extensions/matrix/src/matrix/monitor/auto-join.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
import { registerMatrixAutoJoin } from "./auto-join.js";
|
||||
|
||||
type InviteHandler = (roomId: string, inviteEvent: unknown) => Promise<void>;
|
||||
|
||||
function createClientStub() {
|
||||
let inviteHandler: InviteHandler | null = null;
|
||||
const client = {
|
||||
on: vi.fn((eventName: string, listener: unknown) => {
|
||||
if (eventName === "room.invite") {
|
||||
inviteHandler = listener as InviteHandler;
|
||||
}
|
||||
return client;
|
||||
}),
|
||||
joinRoom: vi.fn(async () => {}),
|
||||
getRoomStateEvent: vi.fn(async () => ({})),
|
||||
} as unknown as import("../sdk.js").MatrixClient;
|
||||
|
||||
return {
|
||||
client,
|
||||
getInviteHandler: () => inviteHandler,
|
||||
joinRoom: (client as unknown as { joinRoom: ReturnType<typeof vi.fn> }).joinRoom,
|
||||
getRoomStateEvent: (client as unknown as { getRoomStateEvent: ReturnType<typeof vi.fn> })
|
||||
.getRoomStateEvent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerMatrixAutoJoin", () => {
|
||||
beforeEach(() => {
|
||||
setMatrixRuntime({
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
});
|
||||
|
||||
it("joins all invites when autoJoin=always", async () => {
|
||||
const { client, getInviteHandler, joinRoom } = createClientStub();
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
autoJoin: "always",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
cfg,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
|
||||
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
|
||||
});
|
||||
|
||||
it("ignores invites outside allowlist when autoJoin=allowlist", async () => {
|
||||
const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub();
|
||||
getRoomStateEvent.mockResolvedValue({
|
||||
alias: "#other:example.org",
|
||||
alt_aliases: ["#else:example.org"],
|
||||
});
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: ["#allowed:example.org"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
cfg,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
|
||||
expect(joinRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("joins invite when alias matches allowlist", async () => {
|
||||
const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub();
|
||||
getRoomStateEvent.mockResolvedValue({
|
||||
alias: "#allowed:example.org",
|
||||
alt_aliases: ["#backup:example.org"],
|
||||
});
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: [" #allowed:example.org "],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
cfg,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
|
||||
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
export function registerMatrixAutoJoin(params: {
|
||||
@@ -18,47 +17,52 @@ export function registerMatrixAutoJoin(params: {
|
||||
runtime.log?.(message);
|
||||
};
|
||||
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
||||
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
|
||||
const autoJoinAllowlist = new Set(
|
||||
(cfg.channels?.matrix?.autoJoinAllowlist ?? [])
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
if (autoJoin === "off") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoJoin === "always") {
|
||||
// Use the built-in autojoin mixin for "always" mode
|
||||
AutojoinRoomsMixin.setupOnClient(client);
|
||||
logVerbose("matrix: auto-join enabled for all invites");
|
||||
return;
|
||||
} else {
|
||||
logVerbose("matrix: auto-join enabled for allowlist invites");
|
||||
}
|
||||
|
||||
// For "allowlist" mode, handle invites manually
|
||||
// Handle invites directly so both "always" and "allowlist" modes share the same path.
|
||||
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
|
||||
if (autoJoin !== "allowlist") {
|
||||
return;
|
||||
}
|
||||
if (autoJoin === "allowlist") {
|
||||
let alias: string | undefined;
|
||||
let altAliases: string[] = [];
|
||||
try {
|
||||
const aliasState = await client
|
||||
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
|
||||
.catch(() => null);
|
||||
alias = aliasState && typeof aliasState.alias === "string" ? aliasState.alias : undefined;
|
||||
altAliases =
|
||||
aliasState && Array.isArray(aliasState.alt_aliases)
|
||||
? aliasState.alt_aliases
|
||||
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
// Get room alias if available
|
||||
let alias: string | undefined;
|
||||
let altAliases: string[] = [];
|
||||
try {
|
||||
const aliasState = await client
|
||||
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
|
||||
.catch(() => null);
|
||||
alias = aliasState?.alias;
|
||||
altAliases = Array.isArray(aliasState?.alt_aliases) ? aliasState.alt_aliases : [];
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
const allowed =
|
||||
autoJoinAllowlist.has("*") ||
|
||||
autoJoinAllowlist.has(roomId) ||
|
||||
(alias ? autoJoinAllowlist.has(alias) : false) ||
|
||||
altAliases.some((value) => autoJoinAllowlist.has(value));
|
||||
|
||||
const allowed =
|
||||
autoJoinAllowlist.includes("*") ||
|
||||
autoJoinAllowlist.includes(roomId) ||
|
||||
(alias ? autoJoinAllowlist.includes(alias) : false) ||
|
||||
altAliases.some((value) => autoJoinAllowlist.includes(value));
|
||||
|
||||
if (!allowed) {
|
||||
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
|
||||
return;
|
||||
if (!allowed) {
|
||||
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
type DirectMessageCheck = {
|
||||
roomId: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import type { MatrixAuth } from "../client.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
import { EventType } from "./types.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
||||
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
|
||||
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
|
||||
import {
|
||||
formatPollAsText,
|
||||
@@ -116,7 +116,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
try {
|
||||
const eventType = event.type;
|
||||
if (eventType === EventType.RoomMessageEncrypted) {
|
||||
// Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled
|
||||
// Encrypted payloads are emitted separately after decryption.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -446,7 +446,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
threadReplies,
|
||||
messageId,
|
||||
threadRootId,
|
||||
isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available
|
||||
isThreadRoot: false, // Raw event payload does not carry explicit thread-root metadata.
|
||||
});
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
|
||||
@@ -300,7 +300,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
logVerboseMessage("matrix: client started");
|
||||
|
||||
// @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient
|
||||
// Shared client is already started via resolveSharedMatrixClient.
|
||||
logger.info(`matrix: logged in as ${auth.userId}`);
|
||||
|
||||
// If E2EE is enabled, trigger device verification
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk";
|
||||
import {
|
||||
formatLocationText,
|
||||
toLocationContext,
|
||||
type NormalizedLocation,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { LocationMessageEventContent } from "../sdk.js";
|
||||
import { EventType } from "./types.js";
|
||||
|
||||
export type MatrixLocationPayload = {
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("downloadMatrixMedia", () => {
|
||||
const client = {
|
||||
crypto: { decryptMedia },
|
||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
|
||||
} as unknown as import("../sdk.js").MatrixClient;
|
||||
|
||||
const file = {
|
||||
url: "mxc://example/file",
|
||||
@@ -69,7 +69,7 @@ describe("downloadMatrixMedia", () => {
|
||||
const client = {
|
||||
crypto: { decryptMedia },
|
||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
|
||||
} as unknown as import("../sdk.js").MatrixClient;
|
||||
|
||||
const file = {
|
||||
url: "mxc://example/file",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
// Type for encrypted file info
|
||||
@@ -21,7 +21,7 @@ async function fetchMatrixMediaBuffer(params: {
|
||||
mxcUrl: string;
|
||||
maxBytes: number;
|
||||
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
|
||||
// @vector-im/matrix-bot-sdk provides mxcToHttp helper
|
||||
// The client wrapper exposes mxcToHttp for Matrix media URIs.
|
||||
const url = params.client.mxcToHttp(params.mxcUrl);
|
||||
if (!url) {
|
||||
return null;
|
||||
@@ -41,7 +41,7 @@ async function fetchMatrixMediaBuffer(params: {
|
||||
|
||||
/**
|
||||
* Download and decrypt encrypted media from a Matrix room.
|
||||
* Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption.
|
||||
* Uses the Matrix crypto adapter's decryptMedia helper.
|
||||
*/
|
||||
async function fetchEncryptedMediaBuffer(params: {
|
||||
client: MatrixClient;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { sendMessageMatrix } from "../send.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
export type MatrixRoomInfo = {
|
||||
name?: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Type for raw Matrix event from @vector-im/matrix-bot-sdk
|
||||
// Type for raw Matrix event payload consumed by thread helpers.
|
||||
type MatrixRawEvent = {
|
||||
event_id: string;
|
||||
sender: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk";
|
||||
import type { EncryptedFile, MatrixRawEvent, MessageEventContent } from "../sdk.js";
|
||||
|
||||
export const EventType = {
|
||||
RoomMessage: "m.room.message",
|
||||
@@ -12,18 +12,6 @@ export const RelationType = {
|
||||
Thread: "m.thread",
|
||||
} as const;
|
||||
|
||||
export type MatrixRawEvent = {
|
||||
event_id: string;
|
||||
sender: string;
|
||||
type: string;
|
||||
origin_server_ts: number;
|
||||
content: Record<string, unknown>;
|
||||
unsigned?: {
|
||||
age?: number;
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type RoomMessageEventContent = MessageEventContent & {
|
||||
url?: string;
|
||||
file?: EncryptedFile;
|
||||
|
||||
53
extensions/matrix/src/matrix/probe.test.ts
Normal file
53
extensions/matrix/src/matrix/probe.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createMatrixClientMock = vi.fn();
|
||||
const isBunRuntimeMock = vi.fn(() => false);
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
|
||||
isBunRuntime: () => isBunRuntimeMock(),
|
||||
}));
|
||||
|
||||
import { probeMatrix } from "./probe.js";
|
||||
|
||||
describe("probeMatrix", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isBunRuntimeMock.mockReturnValue(false);
|
||||
createMatrixClientMock.mockResolvedValue({
|
||||
getUserId: vi.fn(async () => "@bot:example.org"),
|
||||
});
|
||||
});
|
||||
|
||||
it("passes undefined userId when not provided", async () => {
|
||||
const result = await probeMatrix({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok",
|
||||
timeoutMs: 1234,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(createMatrixClientMock).toHaveBeenCalledWith({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: undefined,
|
||||
accessToken: "tok",
|
||||
localTimeoutMs: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("trims provided userId before client creation", async () => {
|
||||
await probeMatrix({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok",
|
||||
userId: " @bot:example.org ",
|
||||
timeoutMs: 500,
|
||||
});
|
||||
|
||||
expect(createMatrixClientMock).toHaveBeenCalledWith({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok",
|
||||
localTimeoutMs: 500,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,13 +43,14 @@ export async function probeMatrix(params: {
|
||||
};
|
||||
}
|
||||
try {
|
||||
const inputUserId = params.userId?.trim() || undefined;
|
||||
const client = await createMatrixClient({
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId ?? "",
|
||||
userId: inputUserId,
|
||||
accessToken: params.accessToken,
|
||||
localTimeoutMs: params.timeoutMs,
|
||||
});
|
||||
// @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally
|
||||
// The client wrapper resolves user ID via whoami when needed.
|
||||
const userId = await client.getUserId();
|
||||
result.ok = true;
|
||||
result.userId = userId ?? null;
|
||||
|
||||
751
extensions/matrix/src/matrix/sdk.test.ts
Normal file
751
extensions/matrix/src/matrix/sdk.test.ts
Normal file
@@ -0,0 +1,751 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
class FakeMatrixEvent extends EventEmitter {
|
||||
private readonly roomId: string;
|
||||
private readonly eventId: string;
|
||||
private readonly sender: string;
|
||||
private readonly type: string;
|
||||
private readonly ts: number;
|
||||
private readonly content: Record<string, unknown>;
|
||||
private readonly stateKey?: string;
|
||||
private readonly unsigned?: {
|
||||
age?: number;
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
private readonly decryptionFailure: boolean;
|
||||
|
||||
constructor(params: {
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
sender: string;
|
||||
type: string;
|
||||
ts: number;
|
||||
content: Record<string, unknown>;
|
||||
stateKey?: string;
|
||||
unsigned?: {
|
||||
age?: number;
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
decryptionFailure?: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.roomId = params.roomId;
|
||||
this.eventId = params.eventId;
|
||||
this.sender = params.sender;
|
||||
this.type = params.type;
|
||||
this.ts = params.ts;
|
||||
this.content = params.content;
|
||||
this.stateKey = params.stateKey;
|
||||
this.unsigned = params.unsigned;
|
||||
this.decryptionFailure = params.decryptionFailure === true;
|
||||
}
|
||||
|
||||
getRoomId(): string {
|
||||
return this.roomId;
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.eventId;
|
||||
}
|
||||
|
||||
getSender(): string {
|
||||
return this.sender;
|
||||
}
|
||||
|
||||
getType(): string {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
getTs(): number {
|
||||
return this.ts;
|
||||
}
|
||||
|
||||
getContent(): Record<string, unknown> {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
getUnsigned(): { age?: number; redacted_because?: unknown } {
|
||||
return this.unsigned ?? {};
|
||||
}
|
||||
|
||||
getStateKey(): string | undefined {
|
||||
return this.stateKey;
|
||||
}
|
||||
|
||||
isDecryptionFailure(): boolean {
|
||||
return this.decryptionFailure;
|
||||
}
|
||||
}
|
||||
|
||||
type MatrixJsClientStub = EventEmitter & {
|
||||
startClient: ReturnType<typeof vi.fn>;
|
||||
stopClient: ReturnType<typeof vi.fn>;
|
||||
initRustCrypto: ReturnType<typeof vi.fn>;
|
||||
getUserId: ReturnType<typeof vi.fn>;
|
||||
getDeviceId: ReturnType<typeof vi.fn>;
|
||||
getJoinedRooms: ReturnType<typeof vi.fn>;
|
||||
getJoinedRoomMembers: ReturnType<typeof vi.fn>;
|
||||
getStateEvent: ReturnType<typeof vi.fn>;
|
||||
getAccountData: ReturnType<typeof vi.fn>;
|
||||
setAccountData: ReturnType<typeof vi.fn>;
|
||||
getRoomIdForAlias: ReturnType<typeof vi.fn>;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
sendEvent: ReturnType<typeof vi.fn>;
|
||||
sendStateEvent: ReturnType<typeof vi.fn>;
|
||||
redactEvent: ReturnType<typeof vi.fn>;
|
||||
getProfileInfo: ReturnType<typeof vi.fn>;
|
||||
joinRoom: ReturnType<typeof vi.fn>;
|
||||
mxcUrlToHttp: ReturnType<typeof vi.fn>;
|
||||
uploadContent: ReturnType<typeof vi.fn>;
|
||||
fetchRoomEvent: ReturnType<typeof vi.fn>;
|
||||
sendTyping: ReturnType<typeof vi.fn>;
|
||||
getRoom: ReturnType<typeof vi.fn>;
|
||||
getRooms: ReturnType<typeof vi.fn>;
|
||||
getCrypto: ReturnType<typeof vi.fn>;
|
||||
decryptEventIfNeeded: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createMatrixJsClientStub(): MatrixJsClientStub {
|
||||
const client = new EventEmitter() as MatrixJsClientStub;
|
||||
client.startClient = vi.fn(async () => {});
|
||||
client.stopClient = vi.fn();
|
||||
client.initRustCrypto = vi.fn(async () => {});
|
||||
client.getUserId = vi.fn(() => "@bot:example.org");
|
||||
client.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
client.getJoinedRooms = vi.fn(async () => ({ joined_rooms: [] }));
|
||||
client.getJoinedRoomMembers = vi.fn(async () => ({ joined: {} }));
|
||||
client.getStateEvent = vi.fn(async () => ({}));
|
||||
client.getAccountData = vi.fn(() => undefined);
|
||||
client.setAccountData = vi.fn(async () => {});
|
||||
client.getRoomIdForAlias = vi.fn(async () => ({ room_id: "!resolved:example.org" }));
|
||||
client.sendMessage = vi.fn(async () => ({ event_id: "$sent" }));
|
||||
client.sendEvent = vi.fn(async () => ({ event_id: "$sent-event" }));
|
||||
client.sendStateEvent = vi.fn(async () => ({ event_id: "$state" }));
|
||||
client.redactEvent = vi.fn(async () => ({ event_id: "$redact" }));
|
||||
client.getProfileInfo = vi.fn(async () => ({}));
|
||||
client.joinRoom = vi.fn(async () => ({}));
|
||||
client.mxcUrlToHttp = vi.fn(() => null);
|
||||
client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" }));
|
||||
client.fetchRoomEvent = vi.fn(async () => ({}));
|
||||
client.sendTyping = vi.fn(async () => {});
|
||||
client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false }));
|
||||
client.getRooms = vi.fn(() => []);
|
||||
client.getCrypto = vi.fn(() => undefined);
|
||||
client.decryptEventIfNeeded = vi.fn(async () => {});
|
||||
return client;
|
||||
}
|
||||
|
||||
let matrixJsClient = createMatrixJsClientStub();
|
||||
let lastCreateClientOpts: Record<string, unknown> | null = null;
|
||||
|
||||
vi.mock("matrix-js-sdk", () => ({
|
||||
ClientEvent: { Event: "event", Room: "Room" },
|
||||
MatrixEventEvent: { Decrypted: "decrypted" },
|
||||
createClient: vi.fn((opts: Record<string, unknown>) => {
|
||||
lastCreateClientOpts = opts;
|
||||
return matrixJsClient;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { MatrixClient } from "./sdk.js";
|
||||
|
||||
describe("MatrixClient request hardening", () => {
|
||||
beforeEach(() => {
|
||||
matrixJsClient = createMatrixJsClientStub();
|
||||
lastCreateClientOpts = null;
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("blocks absolute endpoints unless explicitly allowed", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow(
|
||||
"Absolute Matrix endpoint is blocked by default",
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks cross-protocol redirects when absolute endpoints are allowed", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response("", {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: "http://evil.example.org/next",
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
|
||||
await expect(
|
||||
client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, {
|
||||
allowAbsoluteEndpoint: true,
|
||||
}),
|
||||
).rejects.toThrow("Blocked cross-protocol redirect");
|
||||
});
|
||||
|
||||
it("strips authorization when redirect crosses origin", async () => {
|
||||
const calls: Array<{ url: string; headers: Headers }> = [];
|
||||
const fetchMock = vi.fn(async (url: URL | string, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
headers: new Headers(init?.headers),
|
||||
});
|
||||
if (calls.length === 1) {
|
||||
return new Response("", {
|
||||
status: 302,
|
||||
headers: { location: "https://cdn.example.org/next" },
|
||||
});
|
||||
}
|
||||
return new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
await client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, {
|
||||
allowAbsoluteEndpoint: true,
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.url).toBe("https://matrix.example.org/start");
|
||||
expect(calls[0]?.headers.get("authorization")).toBe("Bearer token");
|
||||
expect(calls[1]?.url).toBe("https://cdn.example.org/next");
|
||||
expect(calls[1]?.headers.get("authorization")).toBeNull();
|
||||
});
|
||||
|
||||
it("aborts requests after timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn((_: URL | string, init?: RequestInit) => {
|
||||
return new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
localTimeoutMs: 25,
|
||||
});
|
||||
|
||||
const pending = client.doRequest("GET", "/_matrix/client/v3/account/whoami");
|
||||
const assertion = expect(pending).rejects.toThrow("aborted");
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
|
||||
await assertion;
|
||||
});
|
||||
});
|
||||
|
||||
describe("MatrixClient event bridge", () => {
|
||||
beforeEach(() => {
|
||||
matrixJsClient = createMatrixJsClientStub();
|
||||
lastCreateClientOpts = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits room.message only after encrypted events decrypt", async () => {
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const messageEvents: Array<{ roomId: string; type: string }> = [];
|
||||
|
||||
client.on("room.message", (roomId, event) => {
|
||||
messageEvents.push({ roomId, type: event.type });
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
});
|
||||
const decrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
expect(messageEvents).toHaveLength(0);
|
||||
|
||||
encrypted.emit("decrypted", decrypted);
|
||||
// Simulate a second normal event emission from the SDK after decryption.
|
||||
matrixJsClient.emit("event", decrypted);
|
||||
expect(messageEvents).toEqual([
|
||||
{
|
||||
roomId: "!room:example.org",
|
||||
type: "m.room.message",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits room.failed_decryption when decrypting fails", async () => {
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const failed: string[] = [];
|
||||
const delivered: string[] = [];
|
||||
|
||||
client.on("room.failed_decryption", (_roomId, _event, error) => {
|
||||
failed.push(error.message);
|
||||
});
|
||||
client.on("room.message", (_roomId, event) => {
|
||||
delivered.push(event.type);
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
});
|
||||
const decrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
encrypted.emit("decrypted", decrypted, new Error("decrypt failed"));
|
||||
|
||||
expect(failed).toEqual(["decrypt failed"]);
|
||||
expect(delivered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("retries failed decryption and emits room.message after late key availability", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const failed: string[] = [];
|
||||
const delivered: string[] = [];
|
||||
|
||||
client.on("room.failed_decryption", (_roomId, _event, error) => {
|
||||
failed.push(error.message);
|
||||
});
|
||||
client.on("room.message", (_roomId, event) => {
|
||||
delivered.push(event.type);
|
||||
});
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
decryptionFailure: true,
|
||||
});
|
||||
const decrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
|
||||
encrypted.emit("decrypted", decrypted);
|
||||
});
|
||||
|
||||
await client.start();
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
|
||||
|
||||
expect(failed).toEqual(["missing room key"]);
|
||||
expect(delivered).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_600);
|
||||
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(failed).toEqual(["missing room key"]);
|
||||
expect(delivered).toEqual(["m.room.message"]);
|
||||
});
|
||||
|
||||
it("retries failed decryptions immediately on crypto key update signals", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
});
|
||||
const failed: string[] = [];
|
||||
const delivered: string[] = [];
|
||||
const cryptoListeners = new Map<string, (...args: unknown[]) => void>();
|
||||
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
|
||||
cryptoListeners.set(eventName, listener);
|
||||
}),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
client.on("room.failed_decryption", (_roomId, _event, error) => {
|
||||
failed.push(error.message);
|
||||
});
|
||||
client.on("room.message", (_roomId, event) => {
|
||||
delivered.push(event.type);
|
||||
});
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
decryptionFailure: true,
|
||||
});
|
||||
const decrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
|
||||
encrypted.emit("decrypted", decrypted);
|
||||
});
|
||||
|
||||
await client.start();
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
|
||||
|
||||
expect(failed).toEqual(["missing room key"]);
|
||||
expect(delivered).toHaveLength(0);
|
||||
|
||||
const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached");
|
||||
expect(trigger).toBeTypeOf("function");
|
||||
trigger?.();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(delivered).toEqual(["m.room.message"]);
|
||||
});
|
||||
|
||||
it("stops decryption retries after hitting retry cap", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const failed: string[] = [];
|
||||
|
||||
client.on("room.failed_decryption", (_roomId, _event, error) => {
|
||||
failed.push(error.message);
|
||||
});
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
decryptionFailure: true,
|
||||
});
|
||||
|
||||
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
|
||||
throw new Error("still missing key");
|
||||
});
|
||||
|
||||
await client.start();
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
|
||||
|
||||
expect(failed).toEqual(["missing room key"]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200_000);
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200_000);
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it("does not start duplicate retries when crypto signals fire while retry is in-flight", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
});
|
||||
const delivered: string[] = [];
|
||||
const cryptoListeners = new Map<string, (...args: unknown[]) => void>();
|
||||
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
|
||||
cryptoListeners.set(eventName, listener);
|
||||
}),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
client.on("room.message", (_roomId, event) => {
|
||||
delivered.push(event.type);
|
||||
});
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
decryptionFailure: true,
|
||||
});
|
||||
const decrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
let releaseRetry: (() => void) | null = null;
|
||||
matrixJsClient.decryptEventIfNeeded = vi.fn(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseRetry = () => {
|
||||
encrypted.emit("decrypted", decrypted);
|
||||
resolve();
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await client.start();
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
|
||||
|
||||
const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached");
|
||||
expect(trigger).toBeTypeOf("function");
|
||||
trigger?.();
|
||||
trigger?.();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
releaseRetry?.();
|
||||
await Promise.resolve();
|
||||
expect(delivered).toEqual(["m.room.message"]);
|
||||
});
|
||||
|
||||
it("emits room.invite when a membership invite targets the current user", async () => {
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const invites: string[] = [];
|
||||
|
||||
client.on("room.invite", (roomId) => {
|
||||
invites.push(roomId);
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
const inviteMembership = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$invite",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.member",
|
||||
ts: Date.now(),
|
||||
stateKey: "@bot:example.org",
|
||||
content: {
|
||||
membership: "invite",
|
||||
},
|
||||
});
|
||||
|
||||
matrixJsClient.emit("event", inviteMembership);
|
||||
|
||||
expect(invites).toEqual(["!room:example.org"]);
|
||||
});
|
||||
|
||||
it("emits room.invite when SDK emits Room event with invite membership", async () => {
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const invites: string[] = [];
|
||||
client.on("room.invite", (roomId) => {
|
||||
invites.push(roomId);
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
matrixJsClient.emit("Room", {
|
||||
roomId: "!invite:example.org",
|
||||
getMyMembership: () => "invite",
|
||||
});
|
||||
|
||||
expect(invites).toEqual(["!invite:example.org"]);
|
||||
});
|
||||
|
||||
it("replays outstanding invite rooms at startup", async () => {
|
||||
matrixJsClient.getRooms = vi.fn(() => [
|
||||
{
|
||||
roomId: "!pending:example.org",
|
||||
getMyMembership: () => "invite",
|
||||
},
|
||||
{
|
||||
roomId: "!joined:example.org",
|
||||
getMyMembership: () => "join",
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const invites: string[] = [];
|
||||
client.on("room.invite", (roomId) => {
|
||||
invites.push(roomId);
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
expect(invites).toEqual(["!pending:example.org"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MatrixClient crypto bootstrapping", () => {
|
||||
beforeEach(() => {
|
||||
matrixJsClient = createMatrixJsClientStub();
|
||||
lastCreateClientOpts = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("passes cryptoDatabasePrefix into initRustCrypto", async () => {
|
||||
matrixJsClient.getCrypto = vi.fn(() => undefined);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
cryptoDatabasePrefix: "openclaw-matrix-test",
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
expect(matrixJsClient.initRustCrypto).toHaveBeenCalledWith({
|
||||
cryptoDatabasePrefix: "openclaw-matrix-test",
|
||||
});
|
||||
});
|
||||
|
||||
it("bootstraps cross-signing with setupNewCrossSigning enabled", async () => {
|
||||
const bootstrapCrossSigning = vi.fn(async () => {});
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning,
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
expect(bootstrapCrossSigning).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authUploadDeviceSigningKeys: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("provides secret storage callbacks and resolves stored recovery key", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-"));
|
||||
const recoveryKeyPath = path.join(tmpDir, "recovery-key.json");
|
||||
const privateKeyBase64 = Buffer.from([1, 2, 3, 4]).toString("base64");
|
||||
fs.writeFileSync(
|
||||
recoveryKeyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: "SSSSKEY",
|
||||
privateKeyBase64,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
recoveryKeyPath,
|
||||
});
|
||||
|
||||
const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as {
|
||||
getSecretStorageKey?: (
|
||||
params: { keys: Record<string, unknown> },
|
||||
name: string,
|
||||
) => Promise<[string, Uint8Array] | null>;
|
||||
} | null;
|
||||
expect(callbacks?.getSecretStorageKey).toBeTypeOf("function");
|
||||
|
||||
const resolved = await callbacks?.getSecretStorageKey?.(
|
||||
{ keys: { SSSSKEY: { algorithm: "m.secret_storage.v1.aes-hmac-sha2" } } },
|
||||
"m.cross_signing.master",
|
||||
);
|
||||
expect(resolved?.[0]).toBe("SSSSKEY");
|
||||
expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("schedules periodic crypto snapshot persistence with fake timers", async () => {
|
||||
vi.useFakeTimers();
|
||||
const databasesSpy = vi.spyOn(indexedDB, "databases").mockResolvedValue([]);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
idbSnapshotPath: path.join(os.tmpdir(), "matrix-idb-interval.json"),
|
||||
cryptoDatabasePrefix: "openclaw-matrix-interval",
|
||||
});
|
||||
|
||||
await client.start();
|
||||
const callsAfterStart = databasesSpy.mock.calls.length;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(databasesSpy.mock.calls.length).toBeGreaterThan(callsAfterStart);
|
||||
|
||||
client.stop();
|
||||
const callsAfterStop = databasesSpy.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(databasesSpy.mock.calls.length).toBe(callsAfterStop);
|
||||
});
|
||||
});
|
||||
527
extensions/matrix/src/matrix/sdk.ts
Normal file
527
extensions/matrix/src/matrix/sdk.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
// Polyfill IndexedDB for WASM crypto in Node.js
|
||||
import "fake-indexeddb/auto";
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient as createMatrixJsClient,
|
||||
type MatrixClient as MatrixJsClient,
|
||||
type MatrixEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type {
|
||||
MatrixClientEventMap,
|
||||
MatrixCryptoBootstrapApi,
|
||||
MatrixRawEvent,
|
||||
MessageEventContent,
|
||||
} from "./sdk/types.js";
|
||||
import { MatrixCryptoBootstrapper } from "./sdk/crypto-bootstrap.js";
|
||||
import { createMatrixCryptoFacade, type MatrixCryptoFacade } from "./sdk/crypto-facade.js";
|
||||
import { MatrixDecryptBridge } from "./sdk/decrypt-bridge.js";
|
||||
import { matrixEventToRaw, parseMxc } from "./sdk/event-helpers.js";
|
||||
import { MatrixAuthedHttpClient } from "./sdk/http-client.js";
|
||||
import { persistIdbToDisk, restoreIdbFromDisk } from "./sdk/idb-persistence.js";
|
||||
import { ConsoleLogger, LogService, noop } from "./sdk/logger.js";
|
||||
import { MatrixRecoveryKeyStore } from "./sdk/recovery-key-store.js";
|
||||
import { type HttpMethod, type QueryParams } from "./sdk/transport.js";
|
||||
import { MatrixVerificationManager } from "./sdk/verification-manager.js";
|
||||
|
||||
export { ConsoleLogger, LogService };
|
||||
export type {
|
||||
DimensionalFileInfo,
|
||||
FileWithThumbnailInfo,
|
||||
TimedFileInfo,
|
||||
VideoFileInfo,
|
||||
} from "./sdk/types.js";
|
||||
export type {
|
||||
EncryptedFile,
|
||||
LocationMessageEventContent,
|
||||
MessageEventContent,
|
||||
TextualMessageEventContent,
|
||||
} from "./sdk/types.js";
|
||||
|
||||
export class MatrixClient {
|
||||
private readonly client: MatrixJsClient;
|
||||
private readonly emitter = new EventEmitter();
|
||||
private readonly httpClient: MatrixAuthedHttpClient;
|
||||
private readonly localTimeoutMs: number;
|
||||
private readonly initialSyncLimit?: number;
|
||||
private readonly encryptionEnabled: boolean;
|
||||
private readonly idbSnapshotPath?: string;
|
||||
private readonly cryptoDatabasePrefix?: string;
|
||||
private bridgeRegistered = false;
|
||||
private started = false;
|
||||
private selfUserId: string | null;
|
||||
private readonly dmRoomIds = new Set<string>();
|
||||
private cryptoInitialized = false;
|
||||
private readonly decryptBridge: MatrixDecryptBridge<MatrixRawEvent>;
|
||||
private readonly verificationManager = new MatrixVerificationManager();
|
||||
private readonly recoveryKeyStore: MatrixRecoveryKeyStore;
|
||||
private readonly cryptoBootstrapper: MatrixCryptoBootstrapper<MatrixRawEvent>;
|
||||
|
||||
readonly dms = {
|
||||
update: async (): Promise<void> => {
|
||||
await this.refreshDmCache();
|
||||
},
|
||||
isDm: (roomId: string): boolean => this.dmRoomIds.has(roomId),
|
||||
};
|
||||
|
||||
crypto?: MatrixCryptoFacade;
|
||||
|
||||
constructor(
|
||||
homeserver: string,
|
||||
accessToken: string,
|
||||
_storage?: unknown,
|
||||
_cryptoStorage?: unknown,
|
||||
opts: {
|
||||
userId?: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
localTimeoutMs?: number;
|
||||
encryption?: boolean;
|
||||
initialSyncLimit?: number;
|
||||
recoveryKeyPath?: string;
|
||||
idbSnapshotPath?: string;
|
||||
cryptoDatabasePrefix?: string;
|
||||
} = {},
|
||||
) {
|
||||
this.httpClient = new MatrixAuthedHttpClient(homeserver, accessToken);
|
||||
this.localTimeoutMs = Math.max(1, opts.localTimeoutMs ?? 60_000);
|
||||
this.initialSyncLimit = opts.initialSyncLimit;
|
||||
this.encryptionEnabled = opts.encryption === true;
|
||||
this.idbSnapshotPath = opts.idbSnapshotPath;
|
||||
this.cryptoDatabasePrefix = opts.cryptoDatabasePrefix;
|
||||
this.selfUserId = opts.userId?.trim() || null;
|
||||
this.recoveryKeyStore = new MatrixRecoveryKeyStore(opts.recoveryKeyPath);
|
||||
const cryptoCallbacks = this.encryptionEnabled
|
||||
? this.recoveryKeyStore.buildCryptoCallbacks()
|
||||
: undefined;
|
||||
this.client = createMatrixJsClient({
|
||||
baseUrl: homeserver,
|
||||
accessToken,
|
||||
userId: opts.userId,
|
||||
deviceId: opts.deviceId,
|
||||
localTimeoutMs: this.localTimeoutMs,
|
||||
cryptoCallbacks,
|
||||
verificationMethods: [
|
||||
VerificationMethod.Sas,
|
||||
VerificationMethod.ShowQrCode,
|
||||
VerificationMethod.ScanQrCode,
|
||||
VerificationMethod.Reciprocate,
|
||||
],
|
||||
});
|
||||
this.decryptBridge = new MatrixDecryptBridge<MatrixRawEvent>({
|
||||
client: this.client,
|
||||
toRaw: (event) => matrixEventToRaw(event),
|
||||
emitDecryptedEvent: (roomId, event) => {
|
||||
this.emitter.emit("room.decrypted_event", roomId, event);
|
||||
},
|
||||
emitMessage: (roomId, event) => {
|
||||
this.emitter.emit("room.message", roomId, event);
|
||||
},
|
||||
emitFailedDecryption: (roomId, event, error) => {
|
||||
this.emitter.emit("room.failed_decryption", roomId, event, error);
|
||||
},
|
||||
});
|
||||
this.cryptoBootstrapper = new MatrixCryptoBootstrapper<MatrixRawEvent>({
|
||||
getUserId: () => this.getUserId(),
|
||||
getPassword: () => opts.password,
|
||||
getDeviceId: () => this.client.getDeviceId(),
|
||||
verificationManager: this.verificationManager,
|
||||
recoveryKeyStore: this.recoveryKeyStore,
|
||||
decryptBridge: this.decryptBridge,
|
||||
});
|
||||
|
||||
if (this.encryptionEnabled) {
|
||||
this.crypto = createMatrixCryptoFacade({
|
||||
client: this.client,
|
||||
verificationManager: this.verificationManager,
|
||||
recoveryKeyStore: this.recoveryKeyStore,
|
||||
getRoomStateEvent: (roomId, eventType, stateKey = "") =>
|
||||
this.getRoomStateEvent(roomId, eventType, stateKey),
|
||||
downloadContent: (mxcUrl) => this.downloadContent(mxcUrl),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
on<TEvent extends keyof MatrixClientEventMap>(
|
||||
eventName: TEvent,
|
||||
listener: (...args: MatrixClientEventMap[TEvent]) => void,
|
||||
): this;
|
||||
on(eventName: string, listener: (...args: unknown[]) => void): this;
|
||||
on(eventName: string, listener: (...args: unknown[]) => void): this {
|
||||
this.emitter.on(eventName, listener as (...args: unknown[]) => void);
|
||||
return this;
|
||||
}
|
||||
|
||||
off<TEvent extends keyof MatrixClientEventMap>(
|
||||
eventName: TEvent,
|
||||
listener: (...args: MatrixClientEventMap[TEvent]) => void,
|
||||
): this;
|
||||
off(eventName: string, listener: (...args: unknown[]) => void): this;
|
||||
off(eventName: string, listener: (...args: unknown[]) => void): this {
|
||||
this.emitter.off(eventName, listener as (...args: unknown[]) => void);
|
||||
return this;
|
||||
}
|
||||
|
||||
private idbPersistTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerBridge();
|
||||
await this.initializeCryptoIfNeeded();
|
||||
|
||||
await this.client.startClient({
|
||||
initialSyncLimit: this.initialSyncLimit,
|
||||
});
|
||||
this.started = true;
|
||||
this.emitOutstandingInviteEvents();
|
||||
await this.refreshDmCache().catch(noop);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.idbPersistTimer) {
|
||||
clearInterval(this.idbPersistTimer);
|
||||
this.idbPersistTimer = null;
|
||||
}
|
||||
this.decryptBridge.stop();
|
||||
// Final persist on shutdown
|
||||
persistIdbToDisk({
|
||||
snapshotPath: this.idbSnapshotPath,
|
||||
databasePrefix: this.cryptoDatabasePrefix,
|
||||
}).catch(noop);
|
||||
this.client.stopClient();
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
private async initializeCryptoIfNeeded(): Promise<void> {
|
||||
if (!this.encryptionEnabled || this.cryptoInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore persisted IndexedDB crypto store before initializing WASM crypto.
|
||||
await restoreIdbFromDisk(this.idbSnapshotPath);
|
||||
|
||||
try {
|
||||
await this.client.initRustCrypto({
|
||||
cryptoDatabasePrefix: this.cryptoDatabasePrefix,
|
||||
});
|
||||
this.cryptoInitialized = true;
|
||||
|
||||
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
|
||||
if (crypto) {
|
||||
await this.cryptoBootstrapper.bootstrap(crypto);
|
||||
}
|
||||
|
||||
// Persist the crypto store after successful init (captures fresh keys on first run).
|
||||
await persistIdbToDisk({
|
||||
snapshotPath: this.idbSnapshotPath,
|
||||
databasePrefix: this.cryptoDatabasePrefix,
|
||||
});
|
||||
|
||||
// Periodically persist to capture new Olm sessions and room keys.
|
||||
this.idbPersistTimer = setInterval(() => {
|
||||
persistIdbToDisk({
|
||||
snapshotPath: this.idbSnapshotPath,
|
||||
databasePrefix: this.cryptoDatabasePrefix,
|
||||
}).catch(noop);
|
||||
}, 60_000);
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to initialize rust crypto:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserId(): Promise<string> {
|
||||
const fromClient = this.client.getUserId();
|
||||
if (fromClient) {
|
||||
this.selfUserId = fromClient;
|
||||
return fromClient;
|
||||
}
|
||||
if (this.selfUserId) {
|
||||
return this.selfUserId;
|
||||
}
|
||||
const whoami = (await this.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
|
||||
user_id?: string;
|
||||
};
|
||||
const resolved = whoami.user_id?.trim();
|
||||
if (!resolved) {
|
||||
throw new Error("Matrix whoami did not return user_id");
|
||||
}
|
||||
this.selfUserId = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async getJoinedRooms(): Promise<string[]> {
|
||||
const joined = await this.client.getJoinedRooms();
|
||||
return Array.isArray(joined.joined_rooms) ? joined.joined_rooms : [];
|
||||
}
|
||||
|
||||
async getJoinedRoomMembers(roomId: string): Promise<string[]> {
|
||||
const members = await this.client.getJoinedRoomMembers(roomId);
|
||||
const joined = members?.joined;
|
||||
if (!joined || typeof joined !== "object") {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(joined);
|
||||
}
|
||||
|
||||
async getRoomStateEvent(
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
stateKey = "",
|
||||
): Promise<Record<string, unknown>> {
|
||||
const state = await this.client.getStateEvent(roomId, eventType, stateKey);
|
||||
return (state ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async getAccountData(eventType: string): Promise<Record<string, unknown> | undefined> {
|
||||
const event = this.client.getAccountData(eventType);
|
||||
return (event?.getContent() as Record<string, unknown> | undefined) ?? undefined;
|
||||
}
|
||||
|
||||
async setAccountData(eventType: string, content: Record<string, unknown>): Promise<void> {
|
||||
await this.client.setAccountData(eventType as never, content as never);
|
||||
await this.refreshDmCache().catch(noop);
|
||||
}
|
||||
|
||||
async resolveRoom(aliasOrRoomId: string): Promise<string | null> {
|
||||
if (aliasOrRoomId.startsWith("!")) {
|
||||
return aliasOrRoomId;
|
||||
}
|
||||
if (!aliasOrRoomId.startsWith("#")) {
|
||||
return aliasOrRoomId;
|
||||
}
|
||||
try {
|
||||
const resolved = await this.client.getRoomIdForAlias(aliasOrRoomId);
|
||||
return resolved.room_id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(roomId: string, content: MessageEventContent): Promise<string> {
|
||||
const sent = await this.client.sendMessage(roomId, content as never);
|
||||
return sent.event_id;
|
||||
}
|
||||
|
||||
async sendEvent(
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
content: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const sent = await this.client.sendEvent(roomId, eventType as never, content as never);
|
||||
return sent.event_id;
|
||||
}
|
||||
|
||||
async sendStateEvent(
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
stateKey: string,
|
||||
content: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const sent = await this.client.sendStateEvent(
|
||||
roomId,
|
||||
eventType as never,
|
||||
content as never,
|
||||
stateKey,
|
||||
);
|
||||
return sent.event_id;
|
||||
}
|
||||
|
||||
async redactEvent(roomId: string, eventId: string, reason?: string): Promise<string> {
|
||||
const sent = await this.client.redactEvent(
|
||||
roomId,
|
||||
eventId,
|
||||
undefined,
|
||||
reason?.trim() ? { reason } : undefined,
|
||||
);
|
||||
return sent.event_id;
|
||||
}
|
||||
|
||||
async doRequest(
|
||||
method: HttpMethod,
|
||||
endpoint: string,
|
||||
qs?: QueryParams,
|
||||
body?: unknown,
|
||||
opts?: { allowAbsoluteEndpoint?: boolean },
|
||||
): Promise<unknown> {
|
||||
return await this.httpClient.requestJson({
|
||||
method,
|
||||
endpoint,
|
||||
qs,
|
||||
body,
|
||||
timeoutMs: this.localTimeoutMs,
|
||||
allowAbsoluteEndpoint: opts?.allowAbsoluteEndpoint,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserProfile(userId: string): Promise<{ displayname?: string; avatar_url?: string }> {
|
||||
return await this.client.getProfileInfo(userId);
|
||||
}
|
||||
|
||||
async joinRoom(roomId: string): Promise<void> {
|
||||
await this.client.joinRoom(roomId);
|
||||
}
|
||||
|
||||
mxcToHttp(mxcUrl: string): string | null {
|
||||
return this.client.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, true, false, true);
|
||||
}
|
||||
|
||||
async downloadContent(mxcUrl: string, allowRemote = true): Promise<Buffer> {
|
||||
const parsed = parseMxc(mxcUrl);
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid Matrix content URI: ${mxcUrl}`);
|
||||
}
|
||||
const endpoint = `/_matrix/media/v3/download/${encodeURIComponent(parsed.server)}/${encodeURIComponent(parsed.mediaId)}`;
|
||||
const response = await this.httpClient.requestRaw({
|
||||
method: "GET",
|
||||
endpoint,
|
||||
qs: { allow_remote: allowRemote },
|
||||
timeoutMs: this.localTimeoutMs,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async uploadContent(file: Buffer, contentType?: string, filename?: string): Promise<string> {
|
||||
const uploaded = await this.client.uploadContent(file, {
|
||||
type: contentType || "application/octet-stream",
|
||||
name: filename,
|
||||
includeFilename: Boolean(filename),
|
||||
});
|
||||
return uploaded.content_uri;
|
||||
}
|
||||
|
||||
async getEvent(roomId: string, eventId: string): Promise<Record<string, unknown>> {
|
||||
return (await this.client.fetchRoomEvent(roomId, eventId)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async setTyping(roomId: string, typing: boolean, timeoutMs: number): Promise<void> {
|
||||
await this.client.sendTyping(roomId, typing, timeoutMs);
|
||||
}
|
||||
|
||||
async sendReadReceipt(roomId: string, eventId: string): Promise<void> {
|
||||
await this.httpClient.requestJson({
|
||||
method: "POST",
|
||||
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/receipt/m.read/${encodeURIComponent(
|
||||
eventId,
|
||||
)}`,
|
||||
body: {},
|
||||
timeoutMs: this.localTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
private registerBridge(): void {
|
||||
if (this.bridgeRegistered) {
|
||||
return;
|
||||
}
|
||||
this.bridgeRegistered = true;
|
||||
|
||||
this.client.on(ClientEvent.Event, (event: MatrixEvent) => {
|
||||
const roomId = event.getRoomId();
|
||||
if (!roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = matrixEventToRaw(event);
|
||||
const isEncryptedEvent = raw.type === "m.room.encrypted";
|
||||
this.emitter.emit("room.event", roomId, raw);
|
||||
if (isEncryptedEvent) {
|
||||
this.emitter.emit("room.encrypted_event", roomId, raw);
|
||||
} else {
|
||||
if (this.decryptBridge.shouldEmitUnencryptedMessage(roomId, raw.event_id)) {
|
||||
this.emitter.emit("room.message", roomId, raw);
|
||||
}
|
||||
}
|
||||
|
||||
const stateKey = raw.state_key ?? "";
|
||||
const selfUserId = this.client.getUserId() ?? this.selfUserId ?? "";
|
||||
const membership =
|
||||
raw.type === "m.room.member"
|
||||
? (raw.content as { membership?: string }).membership
|
||||
: undefined;
|
||||
if (stateKey && selfUserId && stateKey === selfUserId) {
|
||||
if (membership === "invite") {
|
||||
this.emitter.emit("room.invite", roomId, raw);
|
||||
} else if (membership === "join") {
|
||||
this.emitter.emit("room.join", roomId, raw);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEncryptedEvent) {
|
||||
this.decryptBridge.attachEncryptedEvent(event, roomId);
|
||||
}
|
||||
});
|
||||
|
||||
// Some SDK invite transitions are surfaced as room lifecycle events instead of raw timeline events.
|
||||
this.client.on(ClientEvent.Room, (room) => {
|
||||
this.emitMembershipForRoom(room);
|
||||
});
|
||||
}
|
||||
|
||||
private emitMembershipForRoom(room: unknown): void {
|
||||
const roomObj = room as {
|
||||
roomId?: string;
|
||||
getMyMembership?: () => string | null | undefined;
|
||||
selfMembership?: string | null | undefined;
|
||||
};
|
||||
const roomId = roomObj.roomId?.trim();
|
||||
if (!roomId) {
|
||||
return;
|
||||
}
|
||||
const membership = roomObj.getMyMembership?.() ?? roomObj.selfMembership ?? undefined;
|
||||
const selfUserId = this.client.getUserId() ?? this.selfUserId ?? "";
|
||||
if (!selfUserId) {
|
||||
return;
|
||||
}
|
||||
const raw: MatrixRawEvent = {
|
||||
type: "m.room.member",
|
||||
room_id: roomId,
|
||||
sender: selfUserId,
|
||||
state_key: selfUserId,
|
||||
content: { membership },
|
||||
origin_server_ts: Date.now(),
|
||||
unsigned: { age: 0 },
|
||||
};
|
||||
if (membership === "invite") {
|
||||
this.emitter.emit("room.invite", roomId, raw);
|
||||
return;
|
||||
}
|
||||
if (membership === "join") {
|
||||
this.emitter.emit("room.join", roomId, raw);
|
||||
}
|
||||
}
|
||||
|
||||
private emitOutstandingInviteEvents(): void {
|
||||
const listRooms = (this.client as { getRooms?: () => unknown[] }).getRooms;
|
||||
if (typeof listRooms !== "function") {
|
||||
return;
|
||||
}
|
||||
const rooms = listRooms.call(this.client);
|
||||
if (!Array.isArray(rooms)) {
|
||||
return;
|
||||
}
|
||||
for (const room of rooms) {
|
||||
this.emitMembershipForRoom(room);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshDmCache(): Promise<void> {
|
||||
const direct = await this.getAccountData("m.direct");
|
||||
this.dmRoomIds.clear();
|
||||
if (!direct || typeof direct !== "object") {
|
||||
return;
|
||||
}
|
||||
for (const value of Object.values(direct)) {
|
||||
if (!Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
for (const roomId of value) {
|
||||
if (typeof roomId === "string" && roomId.trim()) {
|
||||
this.dmRoomIds.add(roomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
241
extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts
Normal file
241
extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js";
|
||||
import { MatrixCryptoBootstrapper, type MatrixCryptoBootstrapperDeps } from "./crypto-bootstrap.js";
|
||||
|
||||
function createBootstrapperDeps() {
|
||||
return {
|
||||
getUserId: vi.fn(async () => "@bot:example.org"),
|
||||
getPassword: vi.fn(() => "super-secret-password"),
|
||||
getDeviceId: vi.fn(() => "DEVICE123"),
|
||||
verificationManager: {
|
||||
trackVerificationRequest: vi.fn(),
|
||||
},
|
||||
recoveryKeyStore: {
|
||||
bootstrapSecretStorageWithRecoveryKey: vi.fn(async () => {}),
|
||||
},
|
||||
decryptBridge: {
|
||||
bindCryptoRetrySignals: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createCryptoApi(overrides?: Partial<MatrixCryptoBootstrapApi>): MatrixCryptoBootstrapApi {
|
||||
return {
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MatrixCryptoBootstrapper", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("bootstraps cross-signing/secret-storage and binds decrypt retry signals", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const crypto = createCryptoApi({
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
expect(crypto.bootstrapCrossSigning).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authUploadDeviceSigningKeys: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
|
||||
crypto,
|
||||
);
|
||||
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledTimes(2);
|
||||
expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledWith(crypto);
|
||||
});
|
||||
|
||||
it("forces new cross-signing keys only when readiness check still fails", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const bootstrapCrossSigning = vi.fn(async () => {});
|
||||
const crypto = createCryptoApi({
|
||||
bootstrapCrossSigning,
|
||||
isCrossSigningReady: vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true),
|
||||
userHasCrossSigningKeys: vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
|
||||
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
authUploadDeviceSigningKeys: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
setupNewCrossSigning: true,
|
||||
authUploadDeviceSigningKeys: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses password UIA fallback when null and dummy auth fail", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const bootstrapCrossSigning = vi.fn(async () => {});
|
||||
const crypto = createCryptoApi({
|
||||
bootstrapCrossSigning,
|
||||
isCrossSigningReady: vi.fn(async () => true),
|
||||
userHasCrossSigningKeys: vi.fn(async () => true),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
const firstCall = bootstrapCrossSigning.mock.calls[0]?.[0] as {
|
||||
authUploadDeviceSigningKeys?: <T>(
|
||||
makeRequest: (authData: Record<string, unknown> | null) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
};
|
||||
expect(firstCall.authUploadDeviceSigningKeys).toBeTypeOf("function");
|
||||
|
||||
const seenAuthStages: Array<Record<string, unknown> | null> = [];
|
||||
const result = await firstCall.authUploadDeviceSigningKeys?.(async (authData) => {
|
||||
seenAuthStages.push(authData);
|
||||
if (authData === null) {
|
||||
throw new Error("need auth");
|
||||
}
|
||||
if (authData.type === "m.login.dummy") {
|
||||
throw new Error("dummy rejected");
|
||||
}
|
||||
if (authData.type === "m.login.password") {
|
||||
return "ok";
|
||||
}
|
||||
throw new Error("unexpected auth stage");
|
||||
});
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(seenAuthStages).toEqual([
|
||||
null,
|
||||
{ type: "m.login.dummy" },
|
||||
{
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: "@bot:example.org" },
|
||||
password: "super-secret-password",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("resets cross-signing when first bootstrap attempt throws", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const bootstrapCrossSigning = vi
|
||||
.fn<() => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("first attempt failed"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const crypto = createCryptoApi({
|
||||
bootstrapCrossSigning,
|
||||
isCrossSigningReady: vi.fn(async () => true),
|
||||
userHasCrossSigningKeys: vi.fn(async () => true),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
|
||||
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
setupNewCrossSigning: true,
|
||||
authUploadDeviceSigningKeys: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks own device verified and cross-signs it when needed", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const setDeviceVerified = vi.fn(async () => {});
|
||||
const crossSignDevice = vi.fn(async () => {});
|
||||
const crypto = createCryptoApi({
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => false,
|
||||
localVerified: false,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
})),
|
||||
setDeviceVerified,
|
||||
crossSignDevice,
|
||||
isCrossSigningReady: vi.fn(async () => true),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true);
|
||||
expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123");
|
||||
});
|
||||
|
||||
it("auto-accepts incoming verification requests from other users", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const listeners = new Map<string, (...args: unknown[]) => void>();
|
||||
const crypto = createCryptoApi({
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
|
||||
listeners.set(eventName, listener);
|
||||
}),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
const verificationRequest = {
|
||||
otherUserId: "@alice:example.org",
|
||||
isSelfVerification: false,
|
||||
initiatedByMe: false,
|
||||
accept: vi.fn(async () => {}),
|
||||
};
|
||||
const listener = Array.from(listeners.entries()).find(([eventName]) =>
|
||||
eventName.toLowerCase().includes("verificationrequest"),
|
||||
)?.[1];
|
||||
expect(listener).toBeTypeOf("function");
|
||||
await listener?.(verificationRequest);
|
||||
|
||||
expect(deps.verificationManager.trackVerificationRequest).toHaveBeenCalledWith(
|
||||
verificationRequest,
|
||||
);
|
||||
expect(verificationRequest.accept).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
226
extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts
Normal file
226
extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
|
||||
import type { MatrixDecryptBridge } from "./decrypt-bridge.js";
|
||||
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
|
||||
import type {
|
||||
MatrixAuthDict,
|
||||
MatrixCryptoBootstrapApi,
|
||||
MatrixRawEvent,
|
||||
MatrixUiAuthCallback,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
MatrixVerificationManager,
|
||||
MatrixVerificationRequestLike,
|
||||
} from "./verification-manager.js";
|
||||
import { LogService } from "./logger.js";
|
||||
|
||||
export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
|
||||
getUserId: () => Promise<string>;
|
||||
getPassword?: () => string | undefined;
|
||||
getDeviceId: () => string | null | undefined;
|
||||
verificationManager: MatrixVerificationManager;
|
||||
recoveryKeyStore: MatrixRecoveryKeyStore;
|
||||
decryptBridge: Pick<MatrixDecryptBridge<TRawEvent>, "bindCryptoRetrySignals">;
|
||||
};
|
||||
|
||||
export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
constructor(private readonly deps: MatrixCryptoBootstrapperDeps<TRawEvent>) {}
|
||||
|
||||
async bootstrap(crypto: MatrixCryptoBootstrapApi): Promise<void> {
|
||||
await this.bootstrapSecretStorage(crypto);
|
||||
await this.bootstrapCrossSigning(crypto);
|
||||
await this.bootstrapSecretStorage(crypto);
|
||||
await this.ensureOwnDeviceTrust(crypto);
|
||||
this.registerVerificationRequestHandler(crypto);
|
||||
}
|
||||
|
||||
private createSigningKeysUiAuthCallback(params: {
|
||||
userId: string;
|
||||
password?: string;
|
||||
}): MatrixUiAuthCallback {
|
||||
return async <T>(makeRequest: (authData: MatrixAuthDict | null) => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await makeRequest(null);
|
||||
} catch {
|
||||
// Some homeservers require an explicit dummy UIA stage even when no user interaction is needed.
|
||||
try {
|
||||
return await makeRequest({ type: "m.login.dummy" });
|
||||
} catch {
|
||||
if (!params.password?.trim()) {
|
||||
throw new Error(
|
||||
"Matrix cross-signing key upload requires UIA; provide matrix.password for m.login.password fallback",
|
||||
);
|
||||
}
|
||||
return await makeRequest({
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: params.userId },
|
||||
password: params.password,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async bootstrapCrossSigning(crypto: MatrixCryptoBootstrapApi): Promise<void> {
|
||||
const userId = await this.deps.getUserId();
|
||||
const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({
|
||||
userId,
|
||||
password: this.deps.getPassword?.(),
|
||||
});
|
||||
const hasPublishedCrossSigningKeys = async (): Promise<boolean> => {
|
||||
if (typeof crypto.userHasCrossSigningKeys !== "function") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return await crypto.userHasCrossSigningKeys(userId, true);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const isCrossSigningReady = async (): Promise<boolean> => {
|
||||
if (typeof crypto.isCrossSigningReady !== "function") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return await crypto.isCrossSigningReady();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// First pass: preserve existing cross-signing identity and ensure public keys are uploaded.
|
||||
try {
|
||||
await crypto.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
"Initial cross-signing bootstrap failed, trying reset:",
|
||||
err,
|
||||
);
|
||||
try {
|
||||
await crypto.bootstrapCrossSigning({
|
||||
setupNewCrossSigning: true,
|
||||
authUploadDeviceSigningKeys,
|
||||
});
|
||||
} catch (resetErr) {
|
||||
LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", resetErr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const firstPassReady = await isCrossSigningReady();
|
||||
const firstPassPublished = await hasPublishedCrossSigningKeys();
|
||||
if (firstPassReady && firstPassPublished) {
|
||||
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: recover from broken local/server state by creating a fresh identity.
|
||||
try {
|
||||
await crypto.bootstrapCrossSigning({
|
||||
setupNewCrossSigning: true,
|
||||
authUploadDeviceSigningKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalReady = await isCrossSigningReady();
|
||||
const finalPublished = await hasPublishedCrossSigningKeys();
|
||||
if (finalReady && finalPublished) {
|
||||
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
|
||||
return;
|
||||
}
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
"Cross-signing bootstrap finished but server keys are still not published",
|
||||
);
|
||||
}
|
||||
|
||||
private async bootstrapSecretStorage(crypto: MatrixCryptoBootstrapApi): Promise<void> {
|
||||
try {
|
||||
await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto);
|
||||
LogService.info("MatrixClientLite", "Secret storage bootstrap complete");
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private registerVerificationRequestHandler(crypto: MatrixCryptoBootstrapApi): void {
|
||||
// Auto-accept incoming verification requests from other users/devices.
|
||||
crypto.on(CryptoEvent.VerificationRequestReceived, async (request) => {
|
||||
const verificationRequest = request as MatrixVerificationRequestLike;
|
||||
this.deps.verificationManager.trackVerificationRequest(verificationRequest);
|
||||
const otherUserId = verificationRequest.otherUserId;
|
||||
const isSelfVerification = verificationRequest.isSelfVerification;
|
||||
const initiatedByMe = verificationRequest.initiatedByMe;
|
||||
|
||||
if (isSelfVerification || initiatedByMe) {
|
||||
LogService.debug(
|
||||
"MatrixClientLite",
|
||||
`Ignoring ${isSelfVerification ? "self" : "initiated"} verification request from ${otherUserId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
LogService.info(
|
||||
"MatrixClientLite",
|
||||
`Auto-accepting verification request from ${otherUserId}`,
|
||||
);
|
||||
await verificationRequest.accept();
|
||||
LogService.info(
|
||||
"MatrixClientLite",
|
||||
`Verification request from ${otherUserId} accepted, waiting for SAS...`,
|
||||
);
|
||||
} catch (err) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
`Failed to auto-accept verification from ${otherUserId}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.deps.decryptBridge.bindCryptoRetrySignals(crypto);
|
||||
LogService.info("MatrixClientLite", "Verification request handler registered");
|
||||
}
|
||||
|
||||
private async ensureOwnDeviceTrust(crypto: MatrixCryptoBootstrapApi): Promise<void> {
|
||||
const deviceId = this.deps.getDeviceId()?.trim();
|
||||
if (!deviceId) {
|
||||
return;
|
||||
}
|
||||
const userId = await this.deps.getUserId();
|
||||
|
||||
const deviceStatus =
|
||||
typeof crypto.getDeviceVerificationStatus === "function"
|
||||
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
|
||||
: null;
|
||||
const alreadyVerified =
|
||||
deviceStatus?.isVerified?.() === true ||
|
||||
deviceStatus?.localVerified === true ||
|
||||
deviceStatus?.crossSigningVerified === true ||
|
||||
deviceStatus?.signedByOwner === true;
|
||||
|
||||
if (alreadyVerified) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof crypto.setDeviceVerified === "function") {
|
||||
await crypto.setDeviceVerified(userId, deviceId, true);
|
||||
}
|
||||
|
||||
if (typeof crypto.crossSignDevice === "function") {
|
||||
const crossSigningReady =
|
||||
typeof crypto.isCrossSigningReady === "function"
|
||||
? await crypto.isCrossSigningReady()
|
||||
: true;
|
||||
if (crossSigningReady) {
|
||||
await crypto.crossSignDevice(deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
131
extensions/matrix/src/matrix/sdk/crypto-facade.test.ts
Normal file
131
extensions/matrix/src/matrix/sdk/crypto-facade.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
|
||||
import type { MatrixVerificationManager } from "./verification-manager.js";
|
||||
import { createMatrixCryptoFacade } from "./crypto-facade.js";
|
||||
|
||||
describe("createMatrixCryptoFacade", () => {
|
||||
it("detects encrypted rooms from cached room state", async () => {
|
||||
const facade = createMatrixCryptoFacade({
|
||||
client: {
|
||||
getRoom: () => ({
|
||||
hasEncryptionStateEvent: () => true,
|
||||
}),
|
||||
getCrypto: () => undefined,
|
||||
},
|
||||
verificationManager: {
|
||||
requestOwnUserVerification: vi.fn(),
|
||||
listVerifications: vi.fn(async () => []),
|
||||
requestVerification: vi.fn(),
|
||||
acceptVerification: vi.fn(),
|
||||
cancelVerification: vi.fn(),
|
||||
startVerification: vi.fn(),
|
||||
generateVerificationQr: vi.fn(),
|
||||
scanVerificationQr: vi.fn(),
|
||||
confirmVerificationSas: vi.fn(),
|
||||
mismatchVerificationSas: vi.fn(),
|
||||
confirmVerificationReciprocateQr: vi.fn(),
|
||||
getVerificationSas: vi.fn(),
|
||||
} as unknown as MatrixVerificationManager,
|
||||
recoveryKeyStore: {
|
||||
getRecoveryKeySummary: vi.fn(() => null),
|
||||
} as unknown as MatrixRecoveryKeyStore,
|
||||
getRoomStateEvent: vi.fn(async () => ({ algorithm: "m.megolm.v1.aes-sha2" })),
|
||||
downloadContent: vi.fn(async () => Buffer.alloc(0)),
|
||||
});
|
||||
|
||||
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to server room state when room cache has no encryption event", async () => {
|
||||
const getRoomStateEvent = vi.fn(async () => ({
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
}));
|
||||
const facade = createMatrixCryptoFacade({
|
||||
client: {
|
||||
getRoom: () => ({
|
||||
hasEncryptionStateEvent: () => false,
|
||||
}),
|
||||
getCrypto: () => undefined,
|
||||
},
|
||||
verificationManager: {
|
||||
requestOwnUserVerification: vi.fn(),
|
||||
listVerifications: vi.fn(async () => []),
|
||||
requestVerification: vi.fn(),
|
||||
acceptVerification: vi.fn(),
|
||||
cancelVerification: vi.fn(),
|
||||
startVerification: vi.fn(),
|
||||
generateVerificationQr: vi.fn(),
|
||||
scanVerificationQr: vi.fn(),
|
||||
confirmVerificationSas: vi.fn(),
|
||||
mismatchVerificationSas: vi.fn(),
|
||||
confirmVerificationReciprocateQr: vi.fn(),
|
||||
getVerificationSas: vi.fn(),
|
||||
} as unknown as MatrixVerificationManager,
|
||||
recoveryKeyStore: {
|
||||
getRecoveryKeySummary: vi.fn(() => null),
|
||||
} as unknown as MatrixRecoveryKeyStore,
|
||||
getRoomStateEvent,
|
||||
downloadContent: vi.fn(async () => Buffer.alloc(0)),
|
||||
});
|
||||
|
||||
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
|
||||
expect(getRoomStateEvent).toHaveBeenCalledWith("!room:example.org", "m.room.encryption", "");
|
||||
});
|
||||
|
||||
it("forwards verification requests and uses client crypto API", async () => {
|
||||
const crypto = { requestOwnUserVerification: vi.fn(async () => null) };
|
||||
const requestVerification = vi.fn(async () => ({
|
||||
id: "verification-1",
|
||||
otherUserId: "@alice:example.org",
|
||||
isSelfVerification: false,
|
||||
initiatedByMe: true,
|
||||
phase: 2,
|
||||
phaseName: "ready",
|
||||
pending: true,
|
||||
methods: ["m.sas.v1"],
|
||||
canAccept: false,
|
||||
hasSas: false,
|
||||
hasReciprocateQr: false,
|
||||
completed: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
const facade = createMatrixCryptoFacade({
|
||||
client: {
|
||||
getRoom: () => null,
|
||||
getCrypto: () => crypto,
|
||||
},
|
||||
verificationManager: {
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
listVerifications: vi.fn(async () => []),
|
||||
requestVerification,
|
||||
acceptVerification: vi.fn(),
|
||||
cancelVerification: vi.fn(),
|
||||
startVerification: vi.fn(),
|
||||
generateVerificationQr: vi.fn(),
|
||||
scanVerificationQr: vi.fn(),
|
||||
confirmVerificationSas: vi.fn(),
|
||||
mismatchVerificationSas: vi.fn(),
|
||||
confirmVerificationReciprocateQr: vi.fn(),
|
||||
getVerificationSas: vi.fn(),
|
||||
} as unknown as MatrixVerificationManager,
|
||||
recoveryKeyStore: {
|
||||
getRecoveryKeySummary: vi.fn(() => ({ keyId: "KEY" })),
|
||||
} as unknown as MatrixRecoveryKeyStore,
|
||||
getRoomStateEvent: vi.fn(async () => ({})),
|
||||
downloadContent: vi.fn(async () => Buffer.alloc(0)),
|
||||
});
|
||||
|
||||
const result = await facade.requestVerification({
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "DEVICE",
|
||||
});
|
||||
|
||||
expect(requestVerification).toHaveBeenCalledWith(crypto, {
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "DEVICE",
|
||||
});
|
||||
expect(result.id).toBe("verification-1");
|
||||
await expect(facade.getRecoveryKey()).resolves.toMatchObject({ keyId: "KEY" });
|
||||
});
|
||||
});
|
||||
173
extensions/matrix/src/matrix/sdk/crypto-facade.ts
Normal file
173
extensions/matrix/src/matrix/sdk/crypto-facade.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs";
|
||||
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
|
||||
import type { EncryptedFile } from "./types.js";
|
||||
import type {
|
||||
MatrixVerificationCryptoApi,
|
||||
MatrixVerificationManager,
|
||||
MatrixVerificationMethod,
|
||||
MatrixVerificationSummary,
|
||||
} from "./verification-manager.js";
|
||||
|
||||
type MatrixCryptoFacadeClient = {
|
||||
getRoom: (roomId: string) => { hasEncryptionStateEvent: () => boolean } | null;
|
||||
getCrypto: () => unknown;
|
||||
};
|
||||
|
||||
export type MatrixCryptoFacade = {
|
||||
prepare: (joinedRooms: string[]) => Promise<void>;
|
||||
updateSyncData: (
|
||||
toDeviceMessages: unknown,
|
||||
otkCounts: unknown,
|
||||
unusedFallbackKeyAlgs: unknown,
|
||||
changedDeviceLists: unknown,
|
||||
leftDeviceLists: unknown,
|
||||
) => Promise<void>;
|
||||
isRoomEncrypted: (roomId: string) => Promise<boolean>;
|
||||
requestOwnUserVerification: () => Promise<unknown | null>;
|
||||
encryptMedia: (buffer: Buffer) => Promise<{ buffer: Buffer; file: Omit<EncryptedFile, "url"> }>;
|
||||
decryptMedia: (file: EncryptedFile) => Promise<Buffer>;
|
||||
getRecoveryKey: () => Promise<{
|
||||
encodedPrivateKey?: string;
|
||||
keyId?: string | null;
|
||||
createdAt?: string;
|
||||
} | null>;
|
||||
listVerifications: () => Promise<MatrixVerificationSummary[]>;
|
||||
requestVerification: (params: {
|
||||
ownUser?: boolean;
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
roomId?: string;
|
||||
}) => Promise<MatrixVerificationSummary>;
|
||||
acceptVerification: (id: string) => Promise<MatrixVerificationSummary>;
|
||||
cancelVerification: (
|
||||
id: string,
|
||||
params?: { reason?: string; code?: string },
|
||||
) => Promise<MatrixVerificationSummary>;
|
||||
startVerification: (
|
||||
id: string,
|
||||
method?: MatrixVerificationMethod,
|
||||
) => Promise<MatrixVerificationSummary>;
|
||||
generateVerificationQr: (id: string) => Promise<{ qrDataBase64: string }>;
|
||||
scanVerificationQr: (id: string, qrDataBase64: string) => Promise<MatrixVerificationSummary>;
|
||||
confirmVerificationSas: (id: string) => Promise<MatrixVerificationSummary>;
|
||||
mismatchVerificationSas: (id: string) => Promise<MatrixVerificationSummary>;
|
||||
confirmVerificationReciprocateQr: (id: string) => Promise<MatrixVerificationSummary>;
|
||||
getVerificationSas: (
|
||||
id: string,
|
||||
) => Promise<{ decimal?: [number, number, number]; emoji?: Array<[string, string]> }>;
|
||||
};
|
||||
|
||||
export function createMatrixCryptoFacade(deps: {
|
||||
client: MatrixCryptoFacadeClient;
|
||||
verificationManager: MatrixVerificationManager;
|
||||
recoveryKeyStore: MatrixRecoveryKeyStore;
|
||||
getRoomStateEvent: (
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
stateKey?: string,
|
||||
) => Promise<Record<string, unknown>>;
|
||||
downloadContent: (mxcUrl: string) => Promise<Buffer>;
|
||||
}): MatrixCryptoFacade {
|
||||
return {
|
||||
prepare: async (_joinedRooms: string[]) => {
|
||||
// matrix-js-sdk performs crypto prep during startup; no extra work required here.
|
||||
},
|
||||
updateSyncData: async (
|
||||
_toDeviceMessages: unknown,
|
||||
_otkCounts: unknown,
|
||||
_unusedFallbackKeyAlgs: unknown,
|
||||
_changedDeviceLists: unknown,
|
||||
_leftDeviceLists: unknown,
|
||||
) => {
|
||||
// compatibility no-op
|
||||
},
|
||||
isRoomEncrypted: async (roomId: string): Promise<boolean> => {
|
||||
const room = deps.client.getRoom(roomId);
|
||||
if (room?.hasEncryptionStateEvent()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const event = await deps.getRoomStateEvent(roomId, "m.room.encryption", "");
|
||||
return typeof event.algorithm === "string" && event.algorithm.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
requestOwnUserVerification: async (): Promise<unknown | null> => {
|
||||
const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined;
|
||||
return await deps.verificationManager.requestOwnUserVerification(crypto);
|
||||
},
|
||||
encryptMedia: async (
|
||||
buffer: Buffer,
|
||||
): Promise<{ buffer: Buffer; file: Omit<EncryptedFile, "url"> }> => {
|
||||
const encrypted = Attachment.encrypt(new Uint8Array(buffer));
|
||||
const mediaInfoJson = encrypted.mediaEncryptionInfo;
|
||||
if (!mediaInfoJson) {
|
||||
throw new Error("Matrix media encryption failed: missing media encryption info");
|
||||
}
|
||||
const parsed = JSON.parse(mediaInfoJson) as EncryptedFile;
|
||||
return {
|
||||
buffer: Buffer.from(encrypted.encryptedData),
|
||||
file: {
|
||||
key: parsed.key,
|
||||
iv: parsed.iv,
|
||||
hashes: parsed.hashes,
|
||||
v: parsed.v,
|
||||
},
|
||||
};
|
||||
},
|
||||
decryptMedia: async (file: EncryptedFile): Promise<Buffer> => {
|
||||
const encrypted = await deps.downloadContent(file.url);
|
||||
const metadata: EncryptedFile = {
|
||||
url: file.url,
|
||||
key: file.key,
|
||||
iv: file.iv,
|
||||
hashes: file.hashes,
|
||||
v: file.v,
|
||||
};
|
||||
const attachment = new EncryptedAttachment(
|
||||
new Uint8Array(encrypted),
|
||||
JSON.stringify(metadata),
|
||||
);
|
||||
const decrypted = Attachment.decrypt(attachment);
|
||||
return Buffer.from(decrypted);
|
||||
},
|
||||
getRecoveryKey: async () => {
|
||||
return deps.recoveryKeyStore.getRecoveryKeySummary();
|
||||
},
|
||||
listVerifications: async () => {
|
||||
return deps.verificationManager.listVerifications();
|
||||
},
|
||||
requestVerification: async (params) => {
|
||||
const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined;
|
||||
return await deps.verificationManager.requestVerification(crypto, params);
|
||||
},
|
||||
acceptVerification: async (id) => {
|
||||
return await deps.verificationManager.acceptVerification(id);
|
||||
},
|
||||
cancelVerification: async (id, params) => {
|
||||
return await deps.verificationManager.cancelVerification(id, params);
|
||||
},
|
||||
startVerification: async (id, method = "sas") => {
|
||||
return await deps.verificationManager.startVerification(id, method);
|
||||
},
|
||||
generateVerificationQr: async (id) => {
|
||||
return await deps.verificationManager.generateVerificationQr(id);
|
||||
},
|
||||
scanVerificationQr: async (id, qrDataBase64) => {
|
||||
return await deps.verificationManager.scanVerificationQr(id, qrDataBase64);
|
||||
},
|
||||
confirmVerificationSas: async (id) => {
|
||||
return await deps.verificationManager.confirmVerificationSas(id);
|
||||
},
|
||||
mismatchVerificationSas: async (id) => {
|
||||
return deps.verificationManager.mismatchVerificationSas(id);
|
||||
},
|
||||
confirmVerificationReciprocateQr: async (id) => {
|
||||
return deps.verificationManager.confirmVerificationReciprocateQr(id);
|
||||
},
|
||||
getVerificationSas: async (id) => {
|
||||
return deps.verificationManager.getVerificationSas(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
307
extensions/matrix/src/matrix/sdk/decrypt-bridge.ts
Normal file
307
extensions/matrix/src/matrix/sdk/decrypt-bridge.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk";
|
||||
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
|
||||
import { LogService, noop } from "./logger.js";
|
||||
|
||||
type MatrixDecryptIfNeededClient = {
|
||||
decryptEventIfNeeded?: (
|
||||
event: MatrixEvent,
|
||||
opts?: {
|
||||
isRetry?: boolean;
|
||||
},
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
type MatrixDecryptRetryState = {
|
||||
event: MatrixEvent;
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
attempts: number;
|
||||
inFlight: boolean;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
type DecryptBridgeRawEvent = {
|
||||
event_id: string;
|
||||
};
|
||||
|
||||
type MatrixCryptoRetrySignalSource = {
|
||||
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
|
||||
};
|
||||
|
||||
const MATRIX_DECRYPT_RETRY_BASE_DELAY_MS = 1_500;
|
||||
const MATRIX_DECRYPT_RETRY_MAX_DELAY_MS = 30_000;
|
||||
const MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS = 8;
|
||||
|
||||
function resolveDecryptRetryKey(roomId: string, eventId: string): string | null {
|
||||
if (!roomId || !eventId) {
|
||||
return null;
|
||||
}
|
||||
return `${roomId}|${eventId}`;
|
||||
}
|
||||
|
||||
function isDecryptionFailure(event: MatrixEvent): boolean {
|
||||
return (
|
||||
typeof (event as { isDecryptionFailure?: () => boolean }).isDecryptionFailure === "function" &&
|
||||
(event as { isDecryptionFailure: () => boolean }).isDecryptionFailure()
|
||||
);
|
||||
}
|
||||
|
||||
export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
|
||||
private readonly trackedEncryptedEvents = new WeakSet<object>();
|
||||
private readonly decryptedMessageDedupe = new Map<string, number>();
|
||||
private readonly decryptRetries = new Map<string, MatrixDecryptRetryState>();
|
||||
private readonly failedDecryptionsNotified = new Set<string>();
|
||||
private cryptoRetrySignalsBound = false;
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
client: MatrixDecryptIfNeededClient;
|
||||
toRaw: (event: MatrixEvent) => TRawEvent;
|
||||
emitDecryptedEvent: (roomId: string, event: TRawEvent) => void;
|
||||
emitMessage: (roomId: string, event: TRawEvent) => void;
|
||||
emitFailedDecryption: (roomId: string, event: TRawEvent, error: Error) => void;
|
||||
},
|
||||
) {}
|
||||
|
||||
shouldEmitUnencryptedMessage(roomId: string, eventId: string): boolean {
|
||||
if (!eventId) {
|
||||
return true;
|
||||
}
|
||||
const key = `${roomId}|${eventId}`;
|
||||
const createdAt = this.decryptedMessageDedupe.get(key);
|
||||
if (createdAt === undefined) {
|
||||
return true;
|
||||
}
|
||||
this.decryptedMessageDedupe.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
attachEncryptedEvent(event: MatrixEvent, roomId: string): void {
|
||||
if (this.trackedEncryptedEvents.has(event)) {
|
||||
return;
|
||||
}
|
||||
this.trackedEncryptedEvents.add(event);
|
||||
event.on(MatrixEventEvent.Decrypted, (decryptedEvent: MatrixEvent, err?: Error) => {
|
||||
this.handleEncryptedEventDecrypted({
|
||||
roomId,
|
||||
encryptedEvent: event,
|
||||
decryptedEvent,
|
||||
err,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
retryPendingNow(reason: string): void {
|
||||
const pending = Array.from(this.decryptRetries.entries());
|
||||
if (pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
LogService.debug("MatrixClientLite", `Retrying pending decryptions due to ${reason}`);
|
||||
for (const [retryKey, state] of pending) {
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
if (state.inFlight) {
|
||||
continue;
|
||||
}
|
||||
this.runDecryptRetry(retryKey).catch(noop);
|
||||
}
|
||||
}
|
||||
|
||||
bindCryptoRetrySignals(crypto: MatrixCryptoRetrySignalSource | undefined): void {
|
||||
if (!crypto || this.cryptoRetrySignalsBound) {
|
||||
return;
|
||||
}
|
||||
this.cryptoRetrySignalsBound = true;
|
||||
|
||||
const trigger = (reason: string): void => {
|
||||
this.retryPendingNow(reason);
|
||||
};
|
||||
|
||||
crypto.on(CryptoEvent.KeyBackupDecryptionKeyCached, () => {
|
||||
trigger("crypto.keyBackupDecryptionKeyCached");
|
||||
});
|
||||
crypto.on(CryptoEvent.RehydrationCompleted, () => {
|
||||
trigger("dehydration.RehydrationCompleted");
|
||||
});
|
||||
crypto.on(CryptoEvent.DevicesUpdated, () => {
|
||||
trigger("crypto.devicesUpdated");
|
||||
});
|
||||
crypto.on(CryptoEvent.KeysChanged, () => {
|
||||
trigger("crossSigning.keysChanged");
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const retryKey of this.decryptRetries.keys()) {
|
||||
this.clearDecryptRetry(retryKey);
|
||||
}
|
||||
}
|
||||
|
||||
private handleEncryptedEventDecrypted(params: {
|
||||
roomId: string;
|
||||
encryptedEvent: MatrixEvent;
|
||||
decryptedEvent: MatrixEvent;
|
||||
err?: Error;
|
||||
}): void {
|
||||
const decryptedRoomId = params.decryptedEvent.getRoomId() || params.roomId;
|
||||
const decryptedRaw = this.deps.toRaw(params.decryptedEvent);
|
||||
const retryEventId = decryptedRaw.event_id || params.encryptedEvent.getId() || "";
|
||||
const retryKey = resolveDecryptRetryKey(decryptedRoomId, retryEventId);
|
||||
|
||||
if (params.err) {
|
||||
this.emitFailedDecryptionOnce(retryKey, decryptedRoomId, decryptedRaw, params.err);
|
||||
this.scheduleDecryptRetry({
|
||||
event: params.encryptedEvent,
|
||||
roomId: decryptedRoomId,
|
||||
eventId: retryEventId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDecryptionFailure(params.decryptedEvent)) {
|
||||
this.emitFailedDecryptionOnce(
|
||||
retryKey,
|
||||
decryptedRoomId,
|
||||
decryptedRaw,
|
||||
new Error("Matrix event failed to decrypt"),
|
||||
);
|
||||
this.scheduleDecryptRetry({
|
||||
event: params.encryptedEvent,
|
||||
roomId: decryptedRoomId,
|
||||
eventId: retryEventId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryKey) {
|
||||
this.clearDecryptRetry(retryKey);
|
||||
}
|
||||
this.rememberDecryptedMessage(decryptedRoomId, decryptedRaw.event_id);
|
||||
this.deps.emitDecryptedEvent(decryptedRoomId, decryptedRaw);
|
||||
this.deps.emitMessage(decryptedRoomId, decryptedRaw);
|
||||
}
|
||||
|
||||
private emitFailedDecryptionOnce(
|
||||
retryKey: string | null,
|
||||
roomId: string,
|
||||
event: TRawEvent,
|
||||
error: Error,
|
||||
): void {
|
||||
if (retryKey) {
|
||||
if (this.failedDecryptionsNotified.has(retryKey)) {
|
||||
return;
|
||||
}
|
||||
this.failedDecryptionsNotified.add(retryKey);
|
||||
}
|
||||
this.deps.emitFailedDecryption(roomId, event, error);
|
||||
}
|
||||
|
||||
private scheduleDecryptRetry(params: {
|
||||
event: MatrixEvent;
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
}): void {
|
||||
const retryKey = resolveDecryptRetryKey(params.roomId, params.eventId);
|
||||
if (!retryKey) {
|
||||
return;
|
||||
}
|
||||
const existing = this.decryptRetries.get(retryKey);
|
||||
if (existing?.timer || existing?.inFlight) {
|
||||
return;
|
||||
}
|
||||
const attempts = (existing?.attempts ?? 0) + 1;
|
||||
if (attempts > MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS) {
|
||||
this.clearDecryptRetry(retryKey);
|
||||
LogService.debug(
|
||||
"MatrixClientLite",
|
||||
`Giving up decryption retry for ${params.eventId} in ${params.roomId} after ${attempts - 1} attempts`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const delayMs = Math.min(
|
||||
MATRIX_DECRYPT_RETRY_BASE_DELAY_MS * 2 ** (attempts - 1),
|
||||
MATRIX_DECRYPT_RETRY_MAX_DELAY_MS,
|
||||
);
|
||||
const next: MatrixDecryptRetryState = {
|
||||
event: params.event,
|
||||
roomId: params.roomId,
|
||||
eventId: params.eventId,
|
||||
attempts,
|
||||
inFlight: false,
|
||||
timer: null,
|
||||
};
|
||||
next.timer = setTimeout(() => {
|
||||
this.runDecryptRetry(retryKey).catch(noop);
|
||||
}, delayMs);
|
||||
this.decryptRetries.set(retryKey, next);
|
||||
}
|
||||
|
||||
private async runDecryptRetry(retryKey: string): Promise<void> {
|
||||
const state = this.decryptRetries.get(retryKey);
|
||||
if (!state || state.inFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.inFlight = true;
|
||||
state.timer = null;
|
||||
const canDecrypt = typeof this.deps.client.decryptEventIfNeeded === "function";
|
||||
if (!canDecrypt) {
|
||||
this.clearDecryptRetry(retryKey);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.deps.client.decryptEventIfNeeded?.(state.event, {
|
||||
isRetry: true,
|
||||
});
|
||||
} catch {
|
||||
// Retry with backoff until we hit the configured retry cap.
|
||||
} finally {
|
||||
state.inFlight = false;
|
||||
}
|
||||
|
||||
if (isDecryptionFailure(state.event)) {
|
||||
this.scheduleDecryptRetry(state);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearDecryptRetry(retryKey);
|
||||
}
|
||||
|
||||
private clearDecryptRetry(retryKey: string): void {
|
||||
const state = this.decryptRetries.get(retryKey);
|
||||
if (state?.timer) {
|
||||
clearTimeout(state.timer);
|
||||
}
|
||||
this.decryptRetries.delete(retryKey);
|
||||
this.failedDecryptionsNotified.delete(retryKey);
|
||||
}
|
||||
|
||||
private rememberDecryptedMessage(roomId: string, eventId: string): void {
|
||||
if (!eventId) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
this.pruneDecryptedMessageDedupe(now);
|
||||
this.decryptedMessageDedupe.set(`${roomId}|${eventId}`, now);
|
||||
}
|
||||
|
||||
private pruneDecryptedMessageDedupe(now: number): void {
|
||||
const ttlMs = 30_000;
|
||||
for (const [key, createdAt] of this.decryptedMessageDedupe) {
|
||||
if (now - createdAt > ttlMs) {
|
||||
this.decryptedMessageDedupe.delete(key);
|
||||
}
|
||||
}
|
||||
const maxEntries = 2048;
|
||||
while (this.decryptedMessageDedupe.size > maxEntries) {
|
||||
const oldest = this.decryptedMessageDedupe.keys().next().value;
|
||||
if (oldest === undefined) {
|
||||
break;
|
||||
}
|
||||
this.decryptedMessageDedupe.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
extensions/matrix/src/matrix/sdk/event-helpers.test.ts
Normal file
60
extensions/matrix/src/matrix/sdk/event-helpers.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { MatrixEvent } from "matrix-js-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHttpError, matrixEventToRaw, parseMxc } from "./event-helpers.js";
|
||||
|
||||
describe("event-helpers", () => {
|
||||
it("parses mxc URIs", () => {
|
||||
expect(parseMxc("mxc://server.example/media-id")).toEqual({
|
||||
server: "server.example",
|
||||
mediaId: "media-id",
|
||||
});
|
||||
expect(parseMxc("not-mxc")).toBeNull();
|
||||
});
|
||||
|
||||
it("builds HTTP errors from JSON and plain text payloads", () => {
|
||||
const fromJson = buildHttpError(403, JSON.stringify({ error: "forbidden" }));
|
||||
expect(fromJson.message).toBe("forbidden");
|
||||
expect(fromJson.statusCode).toBe(403);
|
||||
|
||||
const fromText = buildHttpError(500, "internal failure");
|
||||
expect(fromText.message).toBe("internal failure");
|
||||
expect(fromText.statusCode).toBe(500);
|
||||
});
|
||||
|
||||
it("serializes Matrix events and resolves state key from available sources", () => {
|
||||
const viaGetter = {
|
||||
getId: () => "$1",
|
||||
getSender: () => "@alice:example.org",
|
||||
getType: () => "m.room.member",
|
||||
getTs: () => 1000,
|
||||
getContent: () => ({ membership: "join" }),
|
||||
getUnsigned: () => ({ age: 1 }),
|
||||
getStateKey: () => "@alice:example.org",
|
||||
} as unknown as MatrixEvent;
|
||||
expect(matrixEventToRaw(viaGetter).state_key).toBe("@alice:example.org");
|
||||
|
||||
const viaWire = {
|
||||
getId: () => "$2",
|
||||
getSender: () => "@bob:example.org",
|
||||
getType: () => "m.room.member",
|
||||
getTs: () => 2000,
|
||||
getContent: () => ({ membership: "join" }),
|
||||
getUnsigned: () => ({}),
|
||||
getStateKey: () => undefined,
|
||||
getWireContent: () => ({ state_key: "@bob:example.org" }),
|
||||
} as unknown as MatrixEvent;
|
||||
expect(matrixEventToRaw(viaWire).state_key).toBe("@bob:example.org");
|
||||
|
||||
const viaRaw = {
|
||||
getId: () => "$3",
|
||||
getSender: () => "@carol:example.org",
|
||||
getType: () => "m.room.member",
|
||||
getTs: () => 3000,
|
||||
getContent: () => ({ membership: "join" }),
|
||||
getUnsigned: () => ({}),
|
||||
getStateKey: () => undefined,
|
||||
event: { state_key: "@carol:example.org" },
|
||||
} as unknown as MatrixEvent;
|
||||
expect(matrixEventToRaw(viaRaw).state_key).toBe("@carol:example.org");
|
||||
});
|
||||
});
|
||||
71
extensions/matrix/src/matrix/sdk/event-helpers.ts
Normal file
71
extensions/matrix/src/matrix/sdk/event-helpers.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { MatrixEvent } from "matrix-js-sdk";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
|
||||
export function matrixEventToRaw(event: MatrixEvent): MatrixRawEvent {
|
||||
const unsigned = (event.getUnsigned?.() ?? {}) as {
|
||||
age?: number;
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
const raw: MatrixRawEvent = {
|
||||
event_id: event.getId() ?? "",
|
||||
sender: event.getSender() ?? "",
|
||||
type: event.getType() ?? "",
|
||||
origin_server_ts: event.getTs() ?? 0,
|
||||
content: ((event.getContent?.() ?? {}) as Record<string, unknown>) || {},
|
||||
unsigned,
|
||||
};
|
||||
const stateKey = resolveMatrixStateKey(event);
|
||||
if (typeof stateKey === "string") {
|
||||
raw.state_key = stateKey;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function parseMxc(url: string): { server: string; mediaId: string } | null {
|
||||
const match = /^mxc:\/\/([^/]+)\/(.+)$/.exec(url.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
server: match[1],
|
||||
mediaId: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHttpError(
|
||||
statusCode: number,
|
||||
bodyText: string,
|
||||
): Error & { statusCode: number } {
|
||||
let message = `Matrix HTTP ${statusCode}`;
|
||||
if (bodyText.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(bodyText) as { error?: string };
|
||||
if (typeof parsed.error === "string" && parsed.error.trim()) {
|
||||
message = parsed.error.trim();
|
||||
} else {
|
||||
message = bodyText.slice(0, 500);
|
||||
}
|
||||
} catch {
|
||||
message = bodyText.slice(0, 500);
|
||||
}
|
||||
}
|
||||
return Object.assign(new Error(message), { statusCode });
|
||||
}
|
||||
|
||||
function resolveMatrixStateKey(event: MatrixEvent): string | undefined {
|
||||
const direct = event.getStateKey?.();
|
||||
if (typeof direct === "string") {
|
||||
return direct;
|
||||
}
|
||||
const wireContent = (
|
||||
event as { getWireContent?: () => { state_key?: unknown } }
|
||||
).getWireContent?.();
|
||||
if (wireContent && typeof wireContent.state_key === "string") {
|
||||
return wireContent.state_key;
|
||||
}
|
||||
const rawEvent = (event as { event?: { state_key?: unknown } }).event;
|
||||
if (rawEvent && typeof rawEvent.state_key === "string") {
|
||||
return rawEvent.state_key;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
106
extensions/matrix/src/matrix/sdk/http-client.test.ts
Normal file
106
extensions/matrix/src/matrix/sdk/http-client.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { performMatrixRequestMock } = vi.hoisted(() => ({
|
||||
performMatrixRequestMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./transport.js", () => ({
|
||||
performMatrixRequest: performMatrixRequestMock,
|
||||
}));
|
||||
|
||||
import { MatrixAuthedHttpClient } from "./http-client.js";
|
||||
|
||||
describe("MatrixAuthedHttpClient", () => {
|
||||
beforeEach(() => {
|
||||
performMatrixRequestMock.mockReset();
|
||||
});
|
||||
|
||||
it("parses JSON responses and forwards absolute-endpoint opt-in", async () => {
|
||||
performMatrixRequestMock.mockResolvedValue({
|
||||
response: new Response('{"ok":true}', {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
text: '{"ok":true}',
|
||||
buffer: Buffer.from('{"ok":true}', "utf8"),
|
||||
});
|
||||
|
||||
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
|
||||
const result = await client.requestJson({
|
||||
method: "GET",
|
||||
endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami",
|
||||
timeoutMs: 5000,
|
||||
allowAbsoluteEndpoint: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(performMatrixRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami",
|
||||
allowAbsoluteEndpoint: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns plain text when response is not JSON", async () => {
|
||||
performMatrixRequestMock.mockResolvedValue({
|
||||
response: new Response("pong", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain" },
|
||||
}),
|
||||
text: "pong",
|
||||
buffer: Buffer.from("pong", "utf8"),
|
||||
});
|
||||
|
||||
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
|
||||
const result = await client.requestJson({
|
||||
method: "GET",
|
||||
endpoint: "/_matrix/client/v3/ping",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(result).toBe("pong");
|
||||
});
|
||||
|
||||
it("returns raw buffers for media requests", async () => {
|
||||
const payload = Buffer.from([1, 2, 3, 4]);
|
||||
performMatrixRequestMock.mockResolvedValue({
|
||||
response: new Response(payload, { status: 200 }),
|
||||
text: payload.toString("utf8"),
|
||||
buffer: payload,
|
||||
});
|
||||
|
||||
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
|
||||
const result = await client.requestRaw({
|
||||
method: "GET",
|
||||
endpoint: "/_matrix/media/v3/download/example/id",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it("raises HTTP errors with status code metadata", async () => {
|
||||
performMatrixRequestMock.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: "forbidden" }), {
|
||||
status: 403,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
text: JSON.stringify({ error: "forbidden" }),
|
||||
buffer: Buffer.from(JSON.stringify({ error: "forbidden" }), "utf8"),
|
||||
});
|
||||
|
||||
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
|
||||
await expect(
|
||||
client.requestJson({
|
||||
method: "GET",
|
||||
endpoint: "/_matrix/client/v3/rooms",
|
||||
timeoutMs: 5000,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: "forbidden",
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
63
extensions/matrix/src/matrix/sdk/http-client.ts
Normal file
63
extensions/matrix/src/matrix/sdk/http-client.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { buildHttpError } from "./event-helpers.js";
|
||||
import { type HttpMethod, type QueryParams, performMatrixRequest } from "./transport.js";
|
||||
|
||||
export class MatrixAuthedHttpClient {
|
||||
constructor(
|
||||
private readonly homeserver: string,
|
||||
private readonly accessToken: string,
|
||||
) {}
|
||||
|
||||
async requestJson(params: {
|
||||
method: HttpMethod;
|
||||
endpoint: string;
|
||||
qs?: QueryParams;
|
||||
body?: unknown;
|
||||
timeoutMs: number;
|
||||
allowAbsoluteEndpoint?: boolean;
|
||||
}): Promise<unknown> {
|
||||
const { response, text } = await performMatrixRequest({
|
||||
homeserver: this.homeserver,
|
||||
accessToken: this.accessToken,
|
||||
method: params.method,
|
||||
endpoint: params.endpoint,
|
||||
qs: params.qs,
|
||||
body: params.body,
|
||||
timeoutMs: params.timeoutMs,
|
||||
allowAbsoluteEndpoint: params.allowAbsoluteEndpoint,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw buildHttpError(response.status, text);
|
||||
}
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
if (!text.trim()) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(text);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async requestRaw(params: {
|
||||
method: HttpMethod;
|
||||
endpoint: string;
|
||||
qs?: QueryParams;
|
||||
timeoutMs: number;
|
||||
allowAbsoluteEndpoint?: boolean;
|
||||
}): Promise<Buffer> {
|
||||
const { response, buffer } = await performMatrixRequest({
|
||||
homeserver: this.homeserver,
|
||||
accessToken: this.accessToken,
|
||||
method: params.method,
|
||||
endpoint: params.endpoint,
|
||||
qs: params.qs,
|
||||
timeoutMs: params.timeoutMs,
|
||||
raw: true,
|
||||
allowAbsoluteEndpoint: params.allowAbsoluteEndpoint,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw buildHttpError(response.status, buffer.toString("utf8"));
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
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);
|
||||
},
|
||||
};
|
||||
176
extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts
Normal file
176
extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixCryptoBootstrapApi } from "./types.js";
|
||||
import { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
|
||||
|
||||
function createTempRecoveryKeyPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-recovery-key-store-"));
|
||||
return path.join(dir, "recovery-key.json");
|
||||
}
|
||||
|
||||
describe("MatrixRecoveryKeyStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("loads a stored recovery key for requested secret-storage keys", async () => {
|
||||
const recoveryKeyPath = createTempRecoveryKeyPath();
|
||||
fs.writeFileSync(
|
||||
recoveryKeyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: "SSSS",
|
||||
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
||||
const callbacks = store.buildCryptoCallbacks();
|
||||
const resolved = await callbacks.getSecretStorageKey?.(
|
||||
{ keys: { SSSS: { name: "test" } } },
|
||||
"m.cross_signing.master",
|
||||
);
|
||||
|
||||
expect(resolved?.[0]).toBe("SSSS");
|
||||
expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("persists cached secret-storage keys with secure file permissions", () => {
|
||||
const recoveryKeyPath = createTempRecoveryKeyPath();
|
||||
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
||||
const callbacks = store.buildCryptoCallbacks();
|
||||
|
||||
callbacks.cacheSecretStorageKey?.(
|
||||
"KEY123",
|
||||
{
|
||||
name: "openclaw",
|
||||
},
|
||||
new Uint8Array([9, 8, 7]),
|
||||
);
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
|
||||
keyId?: string;
|
||||
privateKeyBase64?: string;
|
||||
};
|
||||
expect(saved.keyId).toBe("KEY123");
|
||||
expect(saved.privateKeyBase64).toBe(Buffer.from([9, 8, 7]).toString("base64"));
|
||||
|
||||
const mode = fs.statSync(recoveryKeyPath).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
|
||||
it("creates and persists a recovery key when secret storage is missing", async () => {
|
||||
const recoveryKeyPath = createTempRecoveryKeyPath();
|
||||
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
||||
const generated = {
|
||||
keyId: "GENERATED",
|
||||
keyInfo: { name: "generated" },
|
||||
privateKey: new Uint8Array([5, 6, 7, 8]),
|
||||
encodedPrivateKey: "encoded-generated-key",
|
||||
};
|
||||
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
|
||||
const bootstrapSecretStorage = vi.fn(
|
||||
async (opts?: { createSecretStorageKey?: () => Promise<unknown> }) => {
|
||||
await opts?.createSecretStorageKey?.();
|
||||
},
|
||||
);
|
||||
const crypto = {
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage,
|
||||
createRecoveryKeyFromPassphrase,
|
||||
getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: null })),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
} as unknown as MatrixCryptoBootstrapApi;
|
||||
|
||||
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
|
||||
|
||||
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
|
||||
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
setupNewSecretStorage: true,
|
||||
}),
|
||||
);
|
||||
expect(store.getRecoveryKeySummary()).toMatchObject({
|
||||
keyId: "GENERATED",
|
||||
encodedPrivateKey: "encoded-generated-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("rebinds stored recovery key to server default key id when it changes", async () => {
|
||||
const recoveryKeyPath = createTempRecoveryKeyPath();
|
||||
fs.writeFileSync(
|
||||
recoveryKeyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: "OLD",
|
||||
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
||||
|
||||
const bootstrapSecretStorage = vi.fn(async () => {});
|
||||
const createRecoveryKeyFromPassphrase = vi.fn(async () => {
|
||||
throw new Error("should not be called");
|
||||
});
|
||||
const crypto = {
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage,
|
||||
createRecoveryKeyFromPassphrase,
|
||||
getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "NEW" })),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
} as unknown as MatrixCryptoBootstrapApi;
|
||||
|
||||
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
|
||||
|
||||
expect(createRecoveryKeyFromPassphrase).not.toHaveBeenCalled();
|
||||
expect(store.getRecoveryKeySummary()).toMatchObject({
|
||||
keyId: "NEW",
|
||||
});
|
||||
});
|
||||
|
||||
it("recreates secret storage when default key exists but is not usable locally", async () => {
|
||||
const recoveryKeyPath = createTempRecoveryKeyPath();
|
||||
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
||||
const generated = {
|
||||
keyId: "RECOVERED",
|
||||
keyInfo: { name: "recovered" },
|
||||
privateKey: new Uint8Array([1, 1, 2, 3]),
|
||||
encodedPrivateKey: "encoded-recovered-key",
|
||||
};
|
||||
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
|
||||
const bootstrapSecretStorage = vi.fn(
|
||||
async (opts?: { createSecretStorageKey?: () => Promise<unknown> }) => {
|
||||
await opts?.createSecretStorageKey?.();
|
||||
},
|
||||
);
|
||||
const crypto = {
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage,
|
||||
createRecoveryKeyFromPassphrase,
|
||||
getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: "LEGACY" })),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
} as unknown as MatrixCryptoBootstrapApi;
|
||||
|
||||
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
|
||||
|
||||
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
|
||||
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
setupNewSecretStorage: true,
|
||||
}),
|
||||
);
|
||||
expect(store.getRecoveryKeySummary()).toMatchObject({
|
||||
keyId: "RECOVERED",
|
||||
encodedPrivateKey: "encoded-recovered-key",
|
||||
});
|
||||
});
|
||||
});
|
||||
253
extensions/matrix/src/matrix/sdk/recovery-key-store.ts
Normal file
253
extensions/matrix/src/matrix/sdk/recovery-key-store.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
MatrixCryptoBootstrapApi,
|
||||
MatrixCryptoCallbacks,
|
||||
MatrixGeneratedSecretStorageKey,
|
||||
MatrixSecretStorageStatus,
|
||||
MatrixStoredRecoveryKey,
|
||||
} from "./types.js";
|
||||
import { LogService } from "./logger.js";
|
||||
|
||||
export class MatrixRecoveryKeyStore {
|
||||
private readonly secretStorageKeyCache = new Map<
|
||||
string,
|
||||
{ key: Uint8Array; keyInfo?: MatrixStoredRecoveryKey["keyInfo"] }
|
||||
>();
|
||||
|
||||
constructor(private readonly recoveryKeyPath?: string) {}
|
||||
|
||||
buildCryptoCallbacks(): MatrixCryptoCallbacks {
|
||||
return {
|
||||
getSecretStorageKey: async ({ keys }) => {
|
||||
const requestedKeyIds = Object.keys(keys ?? {});
|
||||
if (requestedKeyIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const keyId of requestedKeyIds) {
|
||||
const cached = this.secretStorageKeyCache.get(keyId);
|
||||
if (cached) {
|
||||
return [keyId, new Uint8Array(cached.key)];
|
||||
}
|
||||
}
|
||||
|
||||
const stored = this.loadStoredRecoveryKey();
|
||||
if (!stored || !stored.privateKeyBase64) {
|
||||
return null;
|
||||
}
|
||||
const privateKey = new Uint8Array(Buffer.from(stored.privateKeyBase64, "base64"));
|
||||
if (privateKey.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stored.keyId && requestedKeyIds.includes(stored.keyId)) {
|
||||
this.rememberSecretStorageKey(stored.keyId, privateKey, stored.keyInfo);
|
||||
return [stored.keyId, privateKey];
|
||||
}
|
||||
|
||||
const firstRequestedKeyId = requestedKeyIds[0];
|
||||
if (!firstRequestedKeyId) {
|
||||
return null;
|
||||
}
|
||||
this.rememberSecretStorageKey(firstRequestedKeyId, privateKey, stored.keyInfo);
|
||||
return [firstRequestedKeyId, privateKey];
|
||||
},
|
||||
cacheSecretStorageKey: (keyId, keyInfo, key) => {
|
||||
const privateKey = new Uint8Array(key);
|
||||
const normalizedKeyInfo: MatrixStoredRecoveryKey["keyInfo"] = {
|
||||
passphrase: keyInfo?.passphrase,
|
||||
name: typeof keyInfo?.name === "string" ? keyInfo.name : undefined,
|
||||
};
|
||||
this.rememberSecretStorageKey(keyId, privateKey, normalizedKeyInfo);
|
||||
|
||||
const stored = this.loadStoredRecoveryKey();
|
||||
this.saveRecoveryKeyToDisk({
|
||||
keyId,
|
||||
keyInfo: normalizedKeyInfo,
|
||||
privateKey,
|
||||
encodedPrivateKey: stored?.encodedPrivateKey,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getRecoveryKeySummary(): {
|
||||
encodedPrivateKey?: string;
|
||||
keyId?: string | null;
|
||||
createdAt?: string;
|
||||
} | null {
|
||||
const stored = this.loadStoredRecoveryKey();
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
encodedPrivateKey: stored.encodedPrivateKey,
|
||||
keyId: stored.keyId,
|
||||
createdAt: stored.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async bootstrapSecretStorageWithRecoveryKey(crypto: MatrixCryptoBootstrapApi): Promise<void> {
|
||||
let status: MatrixSecretStorageStatus | null = null;
|
||||
if (typeof crypto.getSecretStorageStatus === "function") {
|
||||
try {
|
||||
status = await crypto.getSecretStorageStatus();
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to read secret storage status:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const hasDefaultSecretStorageKey = Boolean(status?.defaultKeyId);
|
||||
const hasKnownInvalidSecrets = Object.values(status?.secretStorageKeyValidityMap ?? {}).some(
|
||||
(valid) => valid === false,
|
||||
);
|
||||
let generatedRecoveryKey = false;
|
||||
const storedRecovery = this.loadStoredRecoveryKey();
|
||||
let recoveryKey = storedRecovery
|
||||
? {
|
||||
keyInfo: storedRecovery.keyInfo,
|
||||
privateKey: new Uint8Array(Buffer.from(storedRecovery.privateKeyBase64, "base64")),
|
||||
encodedPrivateKey: storedRecovery.encodedPrivateKey,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (recoveryKey && status?.defaultKeyId) {
|
||||
const defaultKeyId = status.defaultKeyId;
|
||||
this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo);
|
||||
if (storedRecovery?.keyId !== defaultKeyId) {
|
||||
this.saveRecoveryKeyToDisk({
|
||||
keyId: defaultKeyId,
|
||||
keyInfo: recoveryKey.keyInfo,
|
||||
privateKey: recoveryKey.privateKey,
|
||||
encodedPrivateKey: recoveryKey.encodedPrivateKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ensureRecoveryKey = async (): Promise<MatrixGeneratedSecretStorageKey> => {
|
||||
if (recoveryKey) {
|
||||
return recoveryKey;
|
||||
}
|
||||
if (typeof crypto.createRecoveryKeyFromPassphrase !== "function") {
|
||||
throw new Error(
|
||||
"Matrix crypto backend does not support recovery key generation (createRecoveryKeyFromPassphrase missing)",
|
||||
);
|
||||
}
|
||||
recoveryKey = await crypto.createRecoveryKeyFromPassphrase();
|
||||
this.saveRecoveryKeyToDisk(recoveryKey);
|
||||
generatedRecoveryKey = true;
|
||||
return recoveryKey;
|
||||
};
|
||||
|
||||
const shouldRecreateSecretStorage =
|
||||
!hasDefaultSecretStorageKey ||
|
||||
(!recoveryKey && status?.ready === false) ||
|
||||
hasKnownInvalidSecrets;
|
||||
|
||||
if (hasKnownInvalidSecrets) {
|
||||
// Existing secret storage keys can't decrypt required secrets. Generate a fresh recovery key.
|
||||
recoveryKey = null;
|
||||
}
|
||||
|
||||
const secretStorageOptions: {
|
||||
createSecretStorageKey?: () => Promise<MatrixGeneratedSecretStorageKey>;
|
||||
setupNewSecretStorage?: boolean;
|
||||
setupNewKeyBackup?: boolean;
|
||||
} = {
|
||||
setupNewKeyBackup: false,
|
||||
};
|
||||
|
||||
if (shouldRecreateSecretStorage) {
|
||||
secretStorageOptions.setupNewSecretStorage = true;
|
||||
secretStorageOptions.createSecretStorageKey = ensureRecoveryKey;
|
||||
}
|
||||
|
||||
await crypto.bootstrapSecretStorage(secretStorageOptions);
|
||||
|
||||
if (generatedRecoveryKey && this.recoveryKeyPath) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
`Generated Matrix recovery key and saved it to ${this.recoveryKeyPath}. Keep this file secure.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private rememberSecretStorageKey(
|
||||
keyId: string,
|
||||
key: Uint8Array,
|
||||
keyInfo?: MatrixStoredRecoveryKey["keyInfo"],
|
||||
): void {
|
||||
if (!keyId.trim()) {
|
||||
return;
|
||||
}
|
||||
this.secretStorageKeyCache.set(keyId, {
|
||||
key: new Uint8Array(key),
|
||||
keyInfo,
|
||||
});
|
||||
}
|
||||
|
||||
private loadStoredRecoveryKey(): MatrixStoredRecoveryKey | null {
|
||||
if (!this.recoveryKeyPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (!fs.existsSync(this.recoveryKeyPath)) {
|
||||
return null;
|
||||
}
|
||||
const raw = fs.readFileSync(this.recoveryKeyPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<MatrixStoredRecoveryKey>;
|
||||
if (
|
||||
parsed.version !== 1 ||
|
||||
typeof parsed.createdAt !== "string" ||
|
||||
typeof parsed.privateKeyBase64 !== "string" ||
|
||||
!parsed.privateKeyBase64.trim()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
createdAt: parsed.createdAt,
|
||||
keyId: typeof parsed.keyId === "string" ? parsed.keyId : null,
|
||||
encodedPrivateKey:
|
||||
typeof parsed.encodedPrivateKey === "string" ? parsed.encodedPrivateKey : undefined,
|
||||
privateKeyBase64: parsed.privateKeyBase64,
|
||||
keyInfo:
|
||||
parsed.keyInfo && typeof parsed.keyInfo === "object"
|
||||
? {
|
||||
passphrase: parsed.keyInfo.passphrase,
|
||||
name: typeof parsed.keyInfo.name === "string" ? parsed.keyInfo.name : undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private saveRecoveryKeyToDisk(params: MatrixGeneratedSecretStorageKey): void {
|
||||
if (!this.recoveryKeyPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload: MatrixStoredRecoveryKey = {
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: typeof params.keyId === "string" ? params.keyId : null,
|
||||
encodedPrivateKey: params.encodedPrivateKey,
|
||||
privateKeyBase64: Buffer.from(params.privateKey).toString("base64"),
|
||||
keyInfo: params.keyInfo
|
||||
? {
|
||||
passphrase: params.keyInfo.passphrase,
|
||||
name: params.keyInfo.name,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
fs.mkdirSync(path.dirname(this.recoveryKeyPath), { recursive: true });
|
||||
fs.writeFileSync(this.recoveryKeyPath, JSON.stringify(payload, null, 2), "utf8");
|
||||
fs.chmodSync(this.recoveryKeyPath, 0o600);
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to persist recovery key:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
171
extensions/matrix/src/matrix/sdk/transport.ts
Normal file
171
extensions/matrix/src/matrix/sdk/transport.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
type QueryValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| Array<string | number | boolean | null | undefined>;
|
||||
|
||||
export type QueryParams = Record<string, QueryValue> | null | undefined;
|
||||
|
||||
function normalizeEndpoint(endpoint: string): string {
|
||||
if (!endpoint) {
|
||||
return "/";
|
||||
}
|
||||
return endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||||
}
|
||||
|
||||
function applyQuery(url: URL, qs: QueryParams): void {
|
||||
if (!qs) {
|
||||
return;
|
||||
}
|
||||
for (const [key, rawValue] of Object.entries(qs)) {
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(rawValue)) {
|
||||
for (const item of rawValue) {
|
||||
if (item === undefined || item === null) {
|
||||
continue;
|
||||
}
|
||||
url.searchParams.append(key, String(item));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, String(rawValue));
|
||||
}
|
||||
}
|
||||
|
||||
function isRedirectStatus(statusCode: number): boolean {
|
||||
return statusCode >= 300 && statusCode < 400;
|
||||
}
|
||||
|
||||
async function fetchWithSafeRedirects(url: URL, init: RequestInit): Promise<Response> {
|
||||
let currentUrl = new URL(url.toString());
|
||||
let method = (init.method ?? "GET").toUpperCase();
|
||||
let body = init.body;
|
||||
let headers = new Headers(init.headers ?? {});
|
||||
const maxRedirects = 5;
|
||||
|
||||
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) {
|
||||
const response = await fetch(currentUrl, {
|
||||
...init,
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
if (!isRedirectStatus(response.status)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
throw new Error(`Matrix redirect missing location header (${currentUrl.toString()})`);
|
||||
}
|
||||
|
||||
const nextUrl = new URL(location, currentUrl);
|
||||
if (nextUrl.protocol !== currentUrl.protocol) {
|
||||
throw new Error(
|
||||
`Blocked cross-protocol redirect (${currentUrl.protocol} -> ${nextUrl.protocol})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (nextUrl.origin !== currentUrl.origin) {
|
||||
headers = new Headers(headers);
|
||||
headers.delete("authorization");
|
||||
}
|
||||
|
||||
if (
|
||||
response.status === 303 ||
|
||||
((response.status === 301 || response.status === 302) &&
|
||||
method !== "GET" &&
|
||||
method !== "HEAD")
|
||||
) {
|
||||
method = "GET";
|
||||
body = undefined;
|
||||
headers = new Headers(headers);
|
||||
headers.delete("content-type");
|
||||
headers.delete("content-length");
|
||||
}
|
||||
|
||||
currentUrl = nextUrl;
|
||||
}
|
||||
|
||||
throw new Error(`Too many redirects while requesting ${url.toString()}`);
|
||||
}
|
||||
|
||||
export async function performMatrixRequest(params: {
|
||||
homeserver: string;
|
||||
accessToken: string;
|
||||
method: HttpMethod;
|
||||
endpoint: string;
|
||||
qs?: QueryParams;
|
||||
body?: unknown;
|
||||
timeoutMs: number;
|
||||
raw?: boolean;
|
||||
allowAbsoluteEndpoint?: boolean;
|
||||
}): Promise<{ response: Response; text: string; buffer: Buffer }> {
|
||||
const isAbsoluteEndpoint =
|
||||
params.endpoint.startsWith("http://") || params.endpoint.startsWith("https://");
|
||||
if (isAbsoluteEndpoint && params.allowAbsoluteEndpoint !== true) {
|
||||
throw new Error(
|
||||
`Absolute Matrix endpoint is blocked by default: ${params.endpoint}. Set allowAbsoluteEndpoint=true to opt in.`,
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = isAbsoluteEndpoint
|
||||
? new URL(params.endpoint)
|
||||
: new URL(normalizeEndpoint(params.endpoint), params.homeserver);
|
||||
applyQuery(baseUrl, params.qs);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Accept", params.raw ? "*/*" : "application/json");
|
||||
if (params.accessToken) {
|
||||
headers.set("Authorization", `Bearer ${params.accessToken}`);
|
||||
}
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (params.body !== undefined) {
|
||||
if (
|
||||
params.body instanceof Uint8Array ||
|
||||
params.body instanceof ArrayBuffer ||
|
||||
typeof params.body === "string"
|
||||
) {
|
||||
body = params.body as BodyInit;
|
||||
} else {
|
||||
headers.set("Content-Type", "application/json");
|
||||
body = JSON.stringify(params.body);
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs);
|
||||
try {
|
||||
const response = await fetchWithSafeRedirects(baseUrl, {
|
||||
method: params.method,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (params.raw) {
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
return {
|
||||
response,
|
||||
text: bytes.toString("utf8"),
|
||||
buffer: bytes,
|
||||
};
|
||||
}
|
||||
const text = await response.text();
|
||||
return {
|
||||
response,
|
||||
text,
|
||||
buffer: Buffer.from(text, "utf8"),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
183
extensions/matrix/src/matrix/sdk/types.ts
Normal file
183
extensions/matrix/src/matrix/sdk/types.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { MatrixVerificationRequestLike } from "./verification-manager.js";
|
||||
|
||||
export type MatrixRawEvent = {
|
||||
event_id: string;
|
||||
sender: string;
|
||||
type: string;
|
||||
origin_server_ts: number;
|
||||
content: Record<string, unknown>;
|
||||
unsigned?: {
|
||||
age?: number;
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
state_key?: string;
|
||||
};
|
||||
|
||||
export type MatrixClientEventMap = {
|
||||
"room.event": [roomId: string, event: MatrixRawEvent];
|
||||
"room.message": [roomId: string, event: MatrixRawEvent];
|
||||
"room.encrypted_event": [roomId: string, event: MatrixRawEvent];
|
||||
"room.decrypted_event": [roomId: string, event: MatrixRawEvent];
|
||||
"room.failed_decryption": [roomId: string, event: MatrixRawEvent, error: Error];
|
||||
"room.invite": [roomId: string, event: MatrixRawEvent];
|
||||
"room.join": [roomId: string, event: MatrixRawEvent];
|
||||
};
|
||||
|
||||
export type EncryptedFile = {
|
||||
url: string;
|
||||
key: {
|
||||
kty: string;
|
||||
key_ops: string[];
|
||||
alg: string;
|
||||
k: string;
|
||||
ext: boolean;
|
||||
};
|
||||
iv: string;
|
||||
hashes: Record<string, string>;
|
||||
v: string;
|
||||
};
|
||||
|
||||
export type FileWithThumbnailInfo = {
|
||||
size?: number;
|
||||
mimetype?: string;
|
||||
thumbnail_url?: string;
|
||||
thumbnail_info?: {
|
||||
w?: number;
|
||||
h?: number;
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type DimensionalFileInfo = FileWithThumbnailInfo & {
|
||||
w?: number;
|
||||
h?: number;
|
||||
};
|
||||
|
||||
export type TimedFileInfo = FileWithThumbnailInfo & {
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export type VideoFileInfo = DimensionalFileInfo &
|
||||
TimedFileInfo & {
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export type MessageEventContent = {
|
||||
msgtype?: string;
|
||||
body?: string;
|
||||
format?: string;
|
||||
formatted_body?: string;
|
||||
filename?: string;
|
||||
url?: string;
|
||||
file?: EncryptedFile;
|
||||
info?: Record<string, unknown>;
|
||||
"m.relates_to"?: Record<string, unknown>;
|
||||
"m.new_content"?: unknown;
|
||||
"m.mentions"?: {
|
||||
user_ids?: string[];
|
||||
room?: boolean;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TextualMessageEventContent = MessageEventContent & {
|
||||
msgtype: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type LocationMessageEventContent = MessageEventContent & {
|
||||
msgtype?: string;
|
||||
geo_uri?: string;
|
||||
};
|
||||
|
||||
export type MatrixSecretStorageStatus = {
|
||||
ready: boolean;
|
||||
defaultKeyId: string | null;
|
||||
secretStorageKeyValidityMap?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type MatrixGeneratedSecretStorageKey = {
|
||||
keyId?: string | null;
|
||||
keyInfo?: {
|
||||
passphrase?: unknown;
|
||||
name?: string;
|
||||
};
|
||||
privateKey: Uint8Array;
|
||||
encodedPrivateKey?: string;
|
||||
};
|
||||
|
||||
export type MatrixDeviceVerificationStatusLike = {
|
||||
isVerified?: () => boolean;
|
||||
localVerified?: boolean;
|
||||
crossSigningVerified?: boolean;
|
||||
signedByOwner?: boolean;
|
||||
};
|
||||
|
||||
export type MatrixSecretStorageKeyDescription = {
|
||||
passphrase?: unknown;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixCryptoCallbacks = {
|
||||
getSecretStorageKey?: (
|
||||
params: { keys: Record<string, MatrixSecretStorageKeyDescription> },
|
||||
name: string,
|
||||
) => Promise<[string, Uint8Array] | null>;
|
||||
cacheSecretStorageKey?: (
|
||||
keyId: string,
|
||||
keyInfo: MatrixSecretStorageKeyDescription,
|
||||
key: Uint8Array,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type MatrixStoredRecoveryKey = {
|
||||
version: 1;
|
||||
createdAt: string;
|
||||
keyId?: string | null;
|
||||
encodedPrivateKey?: string;
|
||||
privateKeyBase64: string;
|
||||
keyInfo?: {
|
||||
passphrase?: unknown;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixAuthDict = Record<string, unknown>;
|
||||
|
||||
export type MatrixUiAuthCallback = <T>(
|
||||
makeRequest: (authData: MatrixAuthDict | null) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
|
||||
export type MatrixCryptoBootstrapApi = {
|
||||
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
|
||||
bootstrapCrossSigning: (opts: {
|
||||
setupNewCrossSigning?: boolean;
|
||||
authUploadDeviceSigningKeys?: MatrixUiAuthCallback;
|
||||
}) => Promise<void>;
|
||||
bootstrapSecretStorage: (opts?: {
|
||||
createSecretStorageKey?: () => Promise<MatrixGeneratedSecretStorageKey>;
|
||||
setupNewSecretStorage?: boolean;
|
||||
setupNewKeyBackup?: boolean;
|
||||
}) => Promise<void>;
|
||||
createRecoveryKeyFromPassphrase?: (password?: string) => Promise<MatrixGeneratedSecretStorageKey>;
|
||||
getSecretStorageStatus?: () => Promise<MatrixSecretStorageStatus>;
|
||||
requestOwnUserVerification: () => Promise<unknown | null>;
|
||||
requestDeviceVerification?: (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
) => Promise<MatrixVerificationRequestLike>;
|
||||
requestVerificationDM?: (
|
||||
userId: string,
|
||||
roomId: string,
|
||||
) => Promise<MatrixVerificationRequestLike>;
|
||||
getDeviceVerificationStatus?: (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
) => Promise<MatrixDeviceVerificationStatusLike | null>;
|
||||
setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise<void>;
|
||||
crossSignDevice?: (deviceId: string) => Promise<void>;
|
||||
isCrossSigningReady?: () => Promise<boolean>;
|
||||
userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise<boolean>;
|
||||
};
|
||||
170
extensions/matrix/src/matrix/sdk/verification-manager.test.ts
Normal file
170
extensions/matrix/src/matrix/sdk/verification-manager.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { VerificationPhase } from "matrix-js-sdk/lib/crypto-api/verification.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MatrixVerificationManager,
|
||||
type MatrixShowQrCodeCallbacks,
|
||||
type MatrixShowSasCallbacks,
|
||||
type MatrixVerificationRequestLike,
|
||||
type MatrixVerifierLike,
|
||||
} from "./verification-manager.js";
|
||||
|
||||
class MockVerifier extends EventEmitter implements MatrixVerifierLike {
|
||||
constructor(
|
||||
private readonly sasCallbacks: MatrixShowSasCallbacks | null,
|
||||
private readonly qrCallbacks: MatrixShowQrCodeCallbacks | null,
|
||||
private readonly verifyImpl: () => Promise<void> = async () => {},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
verify(): Promise<void> {
|
||||
return this.verifyImpl();
|
||||
}
|
||||
|
||||
cancel(_e: Error): void {
|
||||
void _e;
|
||||
}
|
||||
|
||||
getShowSasCallbacks(): MatrixShowSasCallbacks | null {
|
||||
return this.sasCallbacks;
|
||||
}
|
||||
|
||||
getReciprocateQrCodeCallbacks(): MatrixShowQrCodeCallbacks | null {
|
||||
return this.qrCallbacks;
|
||||
}
|
||||
}
|
||||
|
||||
class MockVerificationRequest extends EventEmitter implements MatrixVerificationRequestLike {
|
||||
transactionId?: string;
|
||||
roomId?: string;
|
||||
initiatedByMe = false;
|
||||
otherUserId = "@alice:example.org";
|
||||
otherDeviceId?: string;
|
||||
isSelfVerification = false;
|
||||
phase = VerificationPhase.Requested;
|
||||
pending = true;
|
||||
accepting = false;
|
||||
declining = false;
|
||||
methods: string[] = ["m.sas.v1"];
|
||||
chosenMethod?: string | null;
|
||||
cancellationCode?: string | null;
|
||||
verifier?: MatrixVerifierLike;
|
||||
|
||||
constructor(init?: Partial<MockVerificationRequest>) {
|
||||
super();
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
accept = vi.fn(async () => {
|
||||
this.phase = VerificationPhase.Ready;
|
||||
});
|
||||
|
||||
cancel = vi.fn(async () => {
|
||||
this.phase = VerificationPhase.Cancelled;
|
||||
});
|
||||
|
||||
startVerification = vi.fn(async (_method: string) => {
|
||||
if (!this.verifier) {
|
||||
throw new Error("verifier not configured");
|
||||
}
|
||||
this.phase = VerificationPhase.Started;
|
||||
return this.verifier;
|
||||
});
|
||||
|
||||
scanQRCode = vi.fn(async (_qrCodeData: Uint8ClampedArray) => {
|
||||
if (!this.verifier) {
|
||||
throw new Error("verifier not configured");
|
||||
}
|
||||
this.phase = VerificationPhase.Started;
|
||||
return this.verifier;
|
||||
});
|
||||
|
||||
generateQRCode = vi.fn(async () => new Uint8ClampedArray([1, 2, 3]));
|
||||
}
|
||||
|
||||
describe("MatrixVerificationManager", () => {
|
||||
it("reuses the same tracked id for repeated transaction IDs", () => {
|
||||
const manager = new MatrixVerificationManager();
|
||||
const first = new MockVerificationRequest({
|
||||
transactionId: "txn-1",
|
||||
phase: VerificationPhase.Requested,
|
||||
});
|
||||
const second = new MockVerificationRequest({
|
||||
transactionId: "txn-1",
|
||||
phase: VerificationPhase.Ready,
|
||||
pending: false,
|
||||
chosenMethod: "m.sas.v1",
|
||||
});
|
||||
|
||||
const firstSummary = manager.trackVerificationRequest(first);
|
||||
const secondSummary = manager.trackVerificationRequest(second);
|
||||
|
||||
expect(secondSummary.id).toBe(firstSummary.id);
|
||||
expect(secondSummary.phase).toBe(VerificationPhase.Ready);
|
||||
expect(secondSummary.pending).toBe(false);
|
||||
expect(secondSummary.chosenMethod).toBe("m.sas.v1");
|
||||
});
|
||||
|
||||
it("starts SAS verification and exposes SAS payload/callback flow", async () => {
|
||||
const confirm = vi.fn(async () => {});
|
||||
const mismatch = vi.fn();
|
||||
const verifier = new MockVerifier(
|
||||
{
|
||||
sas: {
|
||||
decimal: [111, 222, 333],
|
||||
emoji: [
|
||||
["cat", "cat"],
|
||||
["dog", "dog"],
|
||||
["fox", "fox"],
|
||||
],
|
||||
},
|
||||
confirm,
|
||||
mismatch,
|
||||
cancel: vi.fn(),
|
||||
},
|
||||
null,
|
||||
async () => {},
|
||||
);
|
||||
const request = new MockVerificationRequest({
|
||||
transactionId: "txn-2",
|
||||
verifier,
|
||||
});
|
||||
const manager = new MatrixVerificationManager();
|
||||
const tracked = manager.trackVerificationRequest(request);
|
||||
|
||||
const started = await manager.startVerification(tracked.id, "sas");
|
||||
expect(started.hasSas).toBe(true);
|
||||
|
||||
const sas = manager.getVerificationSas(tracked.id);
|
||||
expect(sas.decimal).toEqual([111, 222, 333]);
|
||||
expect(sas.emoji?.length).toBe(3);
|
||||
|
||||
await manager.confirmVerificationSas(tracked.id);
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.mismatchVerificationSas(tracked.id);
|
||||
expect(mismatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prunes stale terminal sessions during list operations", () => {
|
||||
const now = new Date("2026-02-08T15:00:00.000Z").getTime();
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
nowSpy.mockReturnValue(now);
|
||||
|
||||
const manager = new MatrixVerificationManager();
|
||||
manager.trackVerificationRequest(
|
||||
new MockVerificationRequest({
|
||||
transactionId: "txn-old-done",
|
||||
phase: VerificationPhase.Done,
|
||||
pending: false,
|
||||
}),
|
||||
);
|
||||
|
||||
nowSpy.mockReturnValue(now + 24 * 60 * 60 * 1000 + 1);
|
||||
const summaries = manager.listVerifications();
|
||||
|
||||
expect(summaries).toHaveLength(0);
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
464
extensions/matrix/src/matrix/sdk/verification-manager.ts
Normal file
464
extensions/matrix/src/matrix/sdk/verification-manager.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
import {
|
||||
VerificationPhase,
|
||||
VerificationRequestEvent,
|
||||
VerifierEvent,
|
||||
} from "matrix-js-sdk/lib/crypto-api/verification.js";
|
||||
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
|
||||
|
||||
export type MatrixVerificationMethod = "sas" | "show-qr" | "scan-qr";
|
||||
|
||||
export type MatrixVerificationSummary = {
|
||||
id: string;
|
||||
transactionId?: string;
|
||||
roomId?: string;
|
||||
otherUserId: string;
|
||||
otherDeviceId?: string;
|
||||
isSelfVerification: boolean;
|
||||
initiatedByMe: boolean;
|
||||
phase: number;
|
||||
phaseName: string;
|
||||
pending: boolean;
|
||||
methods: string[];
|
||||
chosenMethod?: string | null;
|
||||
canAccept: boolean;
|
||||
hasSas: boolean;
|
||||
hasReciprocateQr: boolean;
|
||||
completed: boolean;
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MatrixShowSasCallbacks = {
|
||||
sas: {
|
||||
decimal?: [number, number, number];
|
||||
emoji?: Array<[string, string]>;
|
||||
};
|
||||
confirm: () => Promise<void>;
|
||||
mismatch: () => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export type MatrixShowQrCodeCallbacks = {
|
||||
confirm: () => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export type MatrixVerifierLike = {
|
||||
verify: () => Promise<void>;
|
||||
cancel: (e: Error) => void;
|
||||
getShowSasCallbacks: () => MatrixShowSasCallbacks | null;
|
||||
getReciprocateQrCodeCallbacks: () => MatrixShowQrCodeCallbacks | null;
|
||||
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
|
||||
};
|
||||
|
||||
export type MatrixVerificationRequestLike = {
|
||||
transactionId?: string;
|
||||
roomId?: string;
|
||||
initiatedByMe: boolean;
|
||||
otherUserId: string;
|
||||
otherDeviceId?: string;
|
||||
isSelfVerification: boolean;
|
||||
phase: number;
|
||||
pending: boolean;
|
||||
accepting: boolean;
|
||||
declining: boolean;
|
||||
methods: string[];
|
||||
chosenMethod?: string | null;
|
||||
cancellationCode?: string | null;
|
||||
accept: () => Promise<void>;
|
||||
cancel: (params?: { reason?: string; code?: string }) => Promise<void>;
|
||||
startVerification: (method: string) => Promise<MatrixVerifierLike>;
|
||||
scanQRCode: (qrCodeData: Uint8ClampedArray) => Promise<MatrixVerifierLike>;
|
||||
generateQRCode: () => Promise<Uint8ClampedArray | undefined>;
|
||||
verifier?: MatrixVerifierLike;
|
||||
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
|
||||
};
|
||||
|
||||
export type MatrixVerificationCryptoApi = {
|
||||
requestOwnUserVerification: () => Promise<unknown | null>;
|
||||
requestDeviceVerification?: (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
) => Promise<MatrixVerificationRequestLike>;
|
||||
requestVerificationDM?: (
|
||||
userId: string,
|
||||
roomId: string,
|
||||
) => Promise<MatrixVerificationRequestLike>;
|
||||
};
|
||||
|
||||
type MatrixVerificationSession = {
|
||||
id: string;
|
||||
request: MatrixVerificationRequestLike;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
error?: string;
|
||||
activeVerifier?: MatrixVerifierLike;
|
||||
verifyPromise?: Promise<void>;
|
||||
verifyStarted: boolean;
|
||||
sasCallbacks?: MatrixShowSasCallbacks;
|
||||
reciprocateQrCallbacks?: MatrixShowQrCodeCallbacks;
|
||||
};
|
||||
|
||||
const MAX_TRACKED_VERIFICATION_SESSIONS = 256;
|
||||
const TERMINAL_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export class MatrixVerificationManager {
|
||||
private readonly verificationSessions = new Map<string, MatrixVerificationSession>();
|
||||
private verificationSessionCounter = 0;
|
||||
private readonly trackedVerificationRequests = new WeakSet<object>();
|
||||
private readonly trackedVerificationVerifiers = new WeakSet<object>();
|
||||
|
||||
private pruneVerificationSessions(nowMs: number): void {
|
||||
for (const [id, session] of this.verificationSessions) {
|
||||
const phase = session.request.phase;
|
||||
const isTerminal = phase === VerificationPhase.Done || phase === VerificationPhase.Cancelled;
|
||||
if (isTerminal && nowMs - session.updatedAtMs > TERMINAL_SESSION_RETENTION_MS) {
|
||||
this.verificationSessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.verificationSessions.size <= MAX_TRACKED_VERIFICATION_SESSIONS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedByAge = Array.from(this.verificationSessions.entries()).sort(
|
||||
(a, b) => a[1].updatedAtMs - b[1].updatedAtMs,
|
||||
);
|
||||
const overflow = this.verificationSessions.size - MAX_TRACKED_VERIFICATION_SESSIONS;
|
||||
for (let i = 0; i < overflow; i += 1) {
|
||||
const entry = sortedByAge[i];
|
||||
if (entry) {
|
||||
this.verificationSessions.delete(entry[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getVerificationPhaseName(phase: number): string {
|
||||
switch (phase) {
|
||||
case VerificationPhase.Unsent:
|
||||
return "unsent";
|
||||
case VerificationPhase.Requested:
|
||||
return "requested";
|
||||
case VerificationPhase.Ready:
|
||||
return "ready";
|
||||
case VerificationPhase.Started:
|
||||
return "started";
|
||||
case VerificationPhase.Cancelled:
|
||||
return "cancelled";
|
||||
case VerificationPhase.Done:
|
||||
return "done";
|
||||
default:
|
||||
return `unknown(${phase})`;
|
||||
}
|
||||
}
|
||||
|
||||
private touchVerificationSession(session: MatrixVerificationSession): void {
|
||||
session.updatedAtMs = Date.now();
|
||||
}
|
||||
|
||||
private buildVerificationSummary(session: MatrixVerificationSession): MatrixVerificationSummary {
|
||||
const request = session.request;
|
||||
const phase = request.phase;
|
||||
const canAccept = phase < VerificationPhase.Ready && !request.accepting && !request.declining;
|
||||
return {
|
||||
id: session.id,
|
||||
transactionId: request.transactionId,
|
||||
roomId: request.roomId,
|
||||
otherUserId: request.otherUserId,
|
||||
otherDeviceId: request.otherDeviceId,
|
||||
isSelfVerification: request.isSelfVerification,
|
||||
initiatedByMe: request.initiatedByMe,
|
||||
phase,
|
||||
phaseName: this.getVerificationPhaseName(phase),
|
||||
pending: request.pending,
|
||||
methods: Array.isArray(request.methods) ? request.methods : [],
|
||||
chosenMethod: request.chosenMethod ?? null,
|
||||
canAccept,
|
||||
hasSas: Boolean(session.sasCallbacks),
|
||||
hasReciprocateQr: Boolean(session.reciprocateQrCallbacks),
|
||||
completed: phase === VerificationPhase.Done,
|
||||
error: session.error,
|
||||
createdAt: new Date(session.createdAtMs).toISOString(),
|
||||
updatedAt: new Date(session.updatedAtMs).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private findVerificationSession(id: string): MatrixVerificationSession {
|
||||
const direct = this.verificationSessions.get(id);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
for (const session of this.verificationSessions.values()) {
|
||||
if (session.request.transactionId === id) {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
throw new Error(`Matrix verification request not found: ${id}`);
|
||||
}
|
||||
|
||||
private ensureVerificationRequestTracked(session: MatrixVerificationSession): void {
|
||||
const requestObj = session.request as unknown as object;
|
||||
if (this.trackedVerificationRequests.has(requestObj)) {
|
||||
return;
|
||||
}
|
||||
this.trackedVerificationRequests.add(requestObj);
|
||||
session.request.on(VerificationRequestEvent.Change, () => {
|
||||
this.touchVerificationSession(session);
|
||||
if (session.request.verifier) {
|
||||
this.attachVerifierToVerificationSession(session, session.request.verifier);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private attachVerifierToVerificationSession(
|
||||
session: MatrixVerificationSession,
|
||||
verifier: MatrixVerifierLike,
|
||||
): void {
|
||||
session.activeVerifier = verifier;
|
||||
this.touchVerificationSession(session);
|
||||
|
||||
const maybeSas = verifier.getShowSasCallbacks();
|
||||
if (maybeSas) {
|
||||
session.sasCallbacks = maybeSas;
|
||||
}
|
||||
const maybeReciprocateQr = verifier.getReciprocateQrCodeCallbacks();
|
||||
if (maybeReciprocateQr) {
|
||||
session.reciprocateQrCallbacks = maybeReciprocateQr;
|
||||
}
|
||||
|
||||
const verifierObj = verifier as unknown as object;
|
||||
if (this.trackedVerificationVerifiers.has(verifierObj)) {
|
||||
return;
|
||||
}
|
||||
this.trackedVerificationVerifiers.add(verifierObj);
|
||||
|
||||
verifier.on(VerifierEvent.ShowSas, (sas) => {
|
||||
session.sasCallbacks = sas as MatrixShowSasCallbacks;
|
||||
this.touchVerificationSession(session);
|
||||
});
|
||||
verifier.on(VerifierEvent.ShowReciprocateQr, (qr) => {
|
||||
session.reciprocateQrCallbacks = qr as MatrixShowQrCodeCallbacks;
|
||||
this.touchVerificationSession(session);
|
||||
});
|
||||
verifier.on(VerifierEvent.Cancel, (err) => {
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
this.touchVerificationSession(session);
|
||||
});
|
||||
}
|
||||
|
||||
private ensureVerificationStarted(session: MatrixVerificationSession): void {
|
||||
if (!session.activeVerifier || session.verifyStarted) {
|
||||
return;
|
||||
}
|
||||
session.verifyStarted = true;
|
||||
const verifier = session.activeVerifier;
|
||||
session.verifyPromise = verifier
|
||||
.verify()
|
||||
.then(() => {
|
||||
this.touchVerificationSession(session);
|
||||
})
|
||||
.catch((err) => {
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
this.touchVerificationSession(session);
|
||||
});
|
||||
}
|
||||
|
||||
trackVerificationRequest(request: MatrixVerificationRequestLike): MatrixVerificationSummary {
|
||||
this.pruneVerificationSessions(Date.now());
|
||||
const txId = request.transactionId?.trim();
|
||||
if (txId) {
|
||||
for (const existing of this.verificationSessions.values()) {
|
||||
if (existing.request.transactionId === txId) {
|
||||
existing.request = request;
|
||||
this.ensureVerificationRequestTracked(existing);
|
||||
if (request.verifier) {
|
||||
this.attachVerifierToVerificationSession(existing, request.verifier);
|
||||
}
|
||||
this.touchVerificationSession(existing);
|
||||
return this.buildVerificationSummary(existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const id = `verification-${++this.verificationSessionCounter}`;
|
||||
const session: MatrixVerificationSession = {
|
||||
id,
|
||||
request,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
verifyStarted: false,
|
||||
};
|
||||
this.verificationSessions.set(session.id, session);
|
||||
this.ensureVerificationRequestTracked(session);
|
||||
if (request.verifier) {
|
||||
this.attachVerifierToVerificationSession(session, request.verifier);
|
||||
}
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
async requestOwnUserVerification(
|
||||
crypto: MatrixVerificationCryptoApi | undefined,
|
||||
): Promise<MatrixVerificationSummary | null> {
|
||||
if (!crypto) {
|
||||
return null;
|
||||
}
|
||||
const request =
|
||||
(await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null;
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
return this.trackVerificationRequest(request);
|
||||
}
|
||||
|
||||
listVerifications(): MatrixVerificationSummary[] {
|
||||
this.pruneVerificationSessions(Date.now());
|
||||
const summaries = Array.from(this.verificationSessions.values()).map((session) =>
|
||||
this.buildVerificationSummary(session),
|
||||
);
|
||||
return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
async requestVerification(
|
||||
crypto: MatrixVerificationCryptoApi | undefined,
|
||||
params: {
|
||||
ownUser?: boolean;
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
roomId?: string;
|
||||
},
|
||||
): Promise<MatrixVerificationSummary> {
|
||||
if (!crypto) {
|
||||
throw new Error("Matrix crypto is not available");
|
||||
}
|
||||
let request: MatrixVerificationRequestLike | null = null;
|
||||
if (params.ownUser) {
|
||||
request = (await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null;
|
||||
} else if (params.userId && params.deviceId && crypto.requestDeviceVerification) {
|
||||
request = await crypto.requestDeviceVerification(params.userId, params.deviceId);
|
||||
} else if (params.userId && params.roomId && crypto.requestVerificationDM) {
|
||||
request = await crypto.requestVerificationDM(params.userId, params.roomId);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Matrix verification request requires one of: ownUser, userId+deviceId, or userId+roomId",
|
||||
);
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
throw new Error("Matrix verification request could not be created");
|
||||
}
|
||||
return this.trackVerificationRequest(request);
|
||||
}
|
||||
|
||||
async acceptVerification(id: string): Promise<MatrixVerificationSummary> {
|
||||
const session = this.findVerificationSession(id);
|
||||
await session.request.accept();
|
||||
this.touchVerificationSession(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
async cancelVerification(
|
||||
id: string,
|
||||
params?: { reason?: string; code?: string },
|
||||
): Promise<MatrixVerificationSummary> {
|
||||
const session = this.findVerificationSession(id);
|
||||
await session.request.cancel(params);
|
||||
this.touchVerificationSession(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
async startVerification(
|
||||
id: string,
|
||||
method: MatrixVerificationMethod = "sas",
|
||||
): Promise<MatrixVerificationSummary> {
|
||||
const session = this.findVerificationSession(id);
|
||||
if (method !== "sas") {
|
||||
throw new Error("Matrix startVerification currently supports only SAS directly");
|
||||
}
|
||||
const verifier = await session.request.startVerification(VerificationMethod.Sas);
|
||||
this.attachVerifierToVerificationSession(session, verifier);
|
||||
this.ensureVerificationStarted(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
async generateVerificationQr(id: string): Promise<{ qrDataBase64: string }> {
|
||||
const session = this.findVerificationSession(id);
|
||||
const qr = await session.request.generateQRCode();
|
||||
if (!qr) {
|
||||
throw new Error("Matrix verification QR data is not available yet");
|
||||
}
|
||||
return { qrDataBase64: Buffer.from(qr).toString("base64") };
|
||||
}
|
||||
|
||||
async scanVerificationQr(id: string, qrDataBase64: string): Promise<MatrixVerificationSummary> {
|
||||
const session = this.findVerificationSession(id);
|
||||
const trimmed = qrDataBase64.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Matrix verification QR payload is required");
|
||||
}
|
||||
const qrBytes = Buffer.from(trimmed, "base64");
|
||||
if (qrBytes.length === 0) {
|
||||
throw new Error("Matrix verification QR payload is invalid base64");
|
||||
}
|
||||
const verifier = await session.request.scanQRCode(new Uint8ClampedArray(qrBytes));
|
||||
this.attachVerifierToVerificationSession(session, verifier);
|
||||
this.ensureVerificationStarted(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
async confirmVerificationSas(id: string): Promise<MatrixVerificationSummary> {
|
||||
const session = this.findVerificationSession(id);
|
||||
const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks();
|
||||
if (!callbacks) {
|
||||
throw new Error("Matrix SAS confirmation is not available for this verification request");
|
||||
}
|
||||
session.sasCallbacks = callbacks;
|
||||
await callbacks.confirm();
|
||||
this.touchVerificationSession(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
mismatchVerificationSas(id: string): MatrixVerificationSummary {
|
||||
const session = this.findVerificationSession(id);
|
||||
const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks();
|
||||
if (!callbacks) {
|
||||
throw new Error("Matrix SAS mismatch is not available for this verification request");
|
||||
}
|
||||
session.sasCallbacks = callbacks;
|
||||
callbacks.mismatch();
|
||||
this.touchVerificationSession(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
confirmVerificationReciprocateQr(id: string): MatrixVerificationSummary {
|
||||
const session = this.findVerificationSession(id);
|
||||
const callbacks =
|
||||
session.reciprocateQrCallbacks ?? session.activeVerifier?.getReciprocateQrCodeCallbacks();
|
||||
if (!callbacks) {
|
||||
throw new Error(
|
||||
"Matrix reciprocate-QR confirmation is not available for this verification request",
|
||||
);
|
||||
}
|
||||
session.reciprocateQrCallbacks = callbacks;
|
||||
callbacks.confirm();
|
||||
this.touchVerificationSession(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
getVerificationSas(id: string): {
|
||||
decimal?: [number, number, number];
|
||||
emoji?: Array<[string, string]>;
|
||||
} {
|
||||
const session = this.findVerificationSession(id);
|
||||
const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks();
|
||||
if (!callbacks) {
|
||||
throw new Error("Matrix SAS data is not available for this verification request");
|
||||
}
|
||||
session.sasCallbacks = callbacks;
|
||||
return {
|
||||
decimal: callbacks.sas.decimal,
|
||||
emoji: callbacks.sas.emoji,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,6 @@ import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setMatrixRuntime } from "../runtime.js";
|
||||
|
||||
vi.mock("@vector-im/matrix-bot-sdk", () => ({
|
||||
ConsoleLogger: class {
|
||||
trace = vi.fn();
|
||||
debug = vi.fn();
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
LogService: {
|
||||
setLogger: vi.fn(),
|
||||
},
|
||||
MatrixClient: vi.fn(),
|
||||
SimpleFsStorageProvider: vi.fn(),
|
||||
RustSdkCryptoStorageProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
const loadWebMediaMock = vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("media"),
|
||||
fileName: "photo.png",
|
||||
@@ -59,7 +43,7 @@ const makeClient = () => {
|
||||
sendMessage,
|
||||
uploadContent,
|
||||
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
|
||||
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
|
||||
} as unknown as import("./sdk.js").MatrixClient;
|
||||
return { client, sendMessage, uploadContent };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { PollInput } from "openclaw/plugin-sdk";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
|
||||
import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js";
|
||||
@@ -71,7 +71,6 @@ export async function sendMessageMatrix(
|
||||
? buildThreadRelation(threadId, opts.replyToId)
|
||||
: buildReplyRelation(opts.replyToId);
|
||||
const sendContent = async (content: MatrixOutboundContent) => {
|
||||
// @vector-im/matrix-bot-sdk uses sendMessage differently
|
||||
const eventId = await client.sendMessage(roomId, content);
|
||||
return eventId;
|
||||
};
|
||||
@@ -175,7 +174,6 @@ export async function sendPollMatrix(
|
||||
const pollPayload = threadId
|
||||
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
|
||||
: pollContent;
|
||||
// @vector-im/matrix-bot-sdk sendEvent returns eventId string directly
|
||||
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { getActiveMatrixClient } from "../active-client.js";
|
||||
@@ -49,6 +49,8 @@ export async function resolveMatrixClient(opts: {
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
password: auth.password,
|
||||
deviceId: auth.deviceId,
|
||||
encryption: auth.encryption,
|
||||
localTimeoutMs: opts.timeoutMs,
|
||||
});
|
||||
@@ -60,7 +62,6 @@ export async function resolveMatrixClient(opts: {
|
||||
// Ignore crypto prep failures for one-off sends; normal sync will retry.
|
||||
}
|
||||
}
|
||||
// @vector-im/matrix-bot-sdk uses start() instead of startClient()
|
||||
await client.start();
|
||||
return { client, stopOnDone: true };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseBuffer, type IFileInfo } from "music-metadata";
|
||||
import type {
|
||||
DimensionalFileInfo,
|
||||
EncryptedFile,
|
||||
@@ -5,8 +6,7 @@ import type {
|
||||
MatrixClient,
|
||||
TimedFileInfo,
|
||||
VideoFileInfo,
|
||||
} from "@vector-im/matrix-bot-sdk";
|
||||
import { parseBuffer, type IFileInfo } from "music-metadata";
|
||||
} from "../sdk.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { applyMatrixFormatting } from "./formatting.js";
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { EventType } from "./types.js";
|
||||
|
||||
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { EventType, type MatrixDirectAccountData } from "./types.js";
|
||||
|
||||
function normalizeTarget(raw: string): string {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
TextualMessageEventContent,
|
||||
TimedFileInfo,
|
||||
VideoFileInfo,
|
||||
} from "@vector-im/matrix-bot-sdk";
|
||||
} from "../sdk.js";
|
||||
|
||||
// Message types
|
||||
export const MsgType = {
|
||||
@@ -85,7 +85,7 @@ export type MatrixSendResult = {
|
||||
};
|
||||
|
||||
export type MatrixSendOpts = {
|
||||
client?: import("@vector-im/matrix-bot-sdk").MatrixClient;
|
||||
client?: import("../sdk.js").MatrixClient;
|
||||
mediaUrl?: string;
|
||||
accountId?: string;
|
||||
replyToId?: string;
|
||||
|
||||
@@ -37,8 +37,9 @@ async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"Matrix requires a homeserver URL.",
|
||||
"Use an access token (recommended) or a password (logs in and stores a token).",
|
||||
"Use an access token (recommended), password login, or account registration.",
|
||||
"With access token: user ID is fetched automatically.",
|
||||
"Password + register mode can create an account on homeservers with open registration.",
|
||||
"Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.",
|
||||
`Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`,
|
||||
].join("\n"),
|
||||
@@ -192,11 +193,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
statusLines: [
|
||||
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
|
||||
],
|
||||
selectionHint: !sdkReady
|
||||
? "install @vector-im/matrix-bot-sdk"
|
||||
: configured
|
||||
? "configured"
|
||||
: "needs auth",
|
||||
selectionHint: !sdkReady ? "install matrix-js-sdk" : configured ? "configured" : "needs auth",
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, runtime, prompter, forceAllowFrom }) => {
|
||||
@@ -270,6 +267,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
let accessToken = existing.accessToken ?? "";
|
||||
let password = existing.password ?? "";
|
||||
let userId = existing.userId ?? "";
|
||||
let register = existing.register === true;
|
||||
|
||||
if (accessToken || password) {
|
||||
const keep = await prompter.confirm({
|
||||
@@ -280,6 +278,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
accessToken = "";
|
||||
password = "";
|
||||
userId = "";
|
||||
register = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +289,10 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
options: [
|
||||
{ value: "token", label: "Access token (user ID fetched automatically)" },
|
||||
{ value: "password", label: "Password (requires user ID)" },
|
||||
{
|
||||
value: "register",
|
||||
label: "Register account (open homeserver registration required)",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -303,8 +306,9 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
// With access token, we can fetch the userId automatically - don't prompt for it
|
||||
// The client.ts will use whoami() to get it
|
||||
userId = "";
|
||||
register = false;
|
||||
} else {
|
||||
// Password auth requires user ID upfront
|
||||
// Password auth and registration mode require user ID upfront
|
||||
userId = String(
|
||||
await prompter.text({
|
||||
message: "Matrix user ID",
|
||||
@@ -330,6 +334,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
register = authMode === "register";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +362,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
userId: userId || undefined,
|
||||
accessToken: accessToken || undefined,
|
||||
password: password || undefined,
|
||||
register,
|
||||
deviceName: deviceName || undefined,
|
||||
encryption: enableEncryption || undefined,
|
||||
},
|
||||
|
||||
@@ -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,9 +52,13 @@ export type MatrixConfig = {
|
||||
accessToken?: string;
|
||||
/** Matrix password (used only to fetch access token). */
|
||||
password?: string;
|
||||
/** Auto-register account when password login fails (open registration homeservers). */
|
||||
register?: boolean;
|
||||
/** Optional Matrix device id (recommended when using access tokens + E2EE). */
|
||||
deviceId?: string;
|
||||
/** Optional device name when logging in via password. */
|
||||
deviceName?: string;
|
||||
/** Initial sync limit for startup (default: @vector-im/matrix-bot-sdk default). */
|
||||
/** Initial sync limit for startup (defaults to matrix-js-sdk behavior). */
|
||||
initialSyncLimit?: number;
|
||||
/** Enable end-to-end encryption (E2EE). Default: false. */
|
||||
encryption?: boolean;
|
||||
|
||||
1000
pnpm-lock.yaml
generated
1000
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user