mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
Matrix: harden E2EE flows and split SDK modules
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -123,6 +123,10 @@ Enable with `channels.matrix.encryption: true`:
|
||||
- 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.
|
||||
@@ -251,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).
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EncryptedFile, MessageEventContent } from "../sdk.js";
|
||||
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;
|
||||
|
||||
@@ -164,7 +164,23 @@ describe("MatrixClient request hardening", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("blocks cross-protocol redirects", async () => {
|
||||
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,
|
||||
@@ -177,9 +193,11 @@ describe("MatrixClient request hardening", () => {
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
|
||||
await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow(
|
||||
"Blocked cross-protocol redirect",
|
||||
);
|
||||
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 () => {
|
||||
@@ -203,7 +221,9 @@ describe("MatrixClient request hardening", () => {
|
||||
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");
|
||||
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");
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
// Polyfill IndexedDB for WASM crypto in Node.js
|
||||
import "fake-indexeddb/auto";
|
||||
import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs";
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient as createMatrixJsClient,
|
||||
type MatrixClient as MatrixJsClient,
|
||||
type MatrixEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
|
||||
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type {
|
||||
EncryptedFile,
|
||||
LocationMessageEventContent,
|
||||
MatrixClientEventMap,
|
||||
MatrixCryptoBootstrapApi,
|
||||
MatrixDeviceVerificationStatusLike,
|
||||
MatrixRawEvent,
|
||||
MessageEventContent,
|
||||
TextualMessageEventContent,
|
||||
} 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";
|
||||
@@ -27,13 +23,7 @@ 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 {
|
||||
type MatrixVerificationCryptoApi,
|
||||
MatrixVerificationManager,
|
||||
type MatrixVerificationMethod,
|
||||
type MatrixVerificationRequestLike,
|
||||
type MatrixVerificationSummary,
|
||||
} from "./sdk/verification-manager.js";
|
||||
import { MatrixVerificationManager } from "./sdk/verification-manager.js";
|
||||
|
||||
export { ConsoleLogger, LogService };
|
||||
export type {
|
||||
@@ -49,50 +39,6 @@ export type {
|
||||
TextualMessageEventContent,
|
||||
} from "./sdk/types.js";
|
||||
|
||||
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 class MatrixClient {
|
||||
private readonly client: MatrixJsClient;
|
||||
private readonly emitter = new EventEmitter();
|
||||
@@ -110,6 +56,7 @@ export class MatrixClient {
|
||||
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> => {
|
||||
@@ -174,9 +121,23 @@ export class MatrixClient {
|
||||
this.emitter.emit("room.failed_decryption", roomId, event, error);
|
||||
},
|
||||
});
|
||||
this.cryptoBootstrapper = new MatrixCryptoBootstrapper<MatrixRawEvent>({
|
||||
getUserId: () => this.getUserId(),
|
||||
getDeviceId: () => this.client.getDeviceId(),
|
||||
verificationManager: this.verificationManager,
|
||||
recoveryKeyStore: this.recoveryKeyStore,
|
||||
decryptBridge: this.decryptBridge,
|
||||
});
|
||||
|
||||
if (this.encryptionEnabled) {
|
||||
this.crypto = this.createCryptoFacade();
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,8 +209,7 @@ export class MatrixClient {
|
||||
|
||||
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
|
||||
if (crypto) {
|
||||
await this.bootstrapCryptoIdentity(crypto);
|
||||
this.registerVerificationRequestHandler(crypto);
|
||||
await this.cryptoBootstrapper.bootstrap(crypto);
|
||||
}
|
||||
|
||||
// Persist the crypto store after successful init (captures fresh keys on first run).
|
||||
@@ -270,102 +230,6 @@ export class MatrixClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async bootstrapCryptoIdentity(crypto: MatrixCryptoBootstrapApi): Promise<void> {
|
||||
try {
|
||||
await crypto.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", err);
|
||||
}
|
||||
try {
|
||||
await this.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto);
|
||||
LogService.info("MatrixClientLite", "Secret storage bootstrap complete");
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err);
|
||||
}
|
||||
try {
|
||||
await this.ensureOwnDeviceTrust(crypto);
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to verify own Matrix device:", 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.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.decryptBridge.bindCryptoRetrySignals(crypto);
|
||||
LogService.info("MatrixClientLite", "Verification request handler registered");
|
||||
}
|
||||
|
||||
private async ensureOwnDeviceTrust(crypto: MatrixCryptoBootstrapApi): Promise<void> {
|
||||
const deviceId = this.client.getDeviceId()?.trim();
|
||||
if (!deviceId) {
|
||||
return;
|
||||
}
|
||||
const userId = await this.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getUserId(): Promise<string> {
|
||||
const fromClient = this.client.getUserId();
|
||||
if (fromClient) {
|
||||
@@ -478,13 +342,15 @@ export class MatrixClient {
|
||||
endpoint: string,
|
||||
qs?: QueryParams,
|
||||
body?: unknown,
|
||||
opts?: { allowAbsoluteEndpoint?: boolean },
|
||||
): Promise<unknown> {
|
||||
return await this.requestJson({
|
||||
return await this.httpClient.requestJson({
|
||||
method,
|
||||
endpoint,
|
||||
qs,
|
||||
body,
|
||||
timeoutMs: this.localTimeoutMs,
|
||||
allowAbsoluteEndpoint: opts?.allowAbsoluteEndpoint,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -506,7 +372,7 @@ export class MatrixClient {
|
||||
throw new Error(`Invalid Matrix content URI: ${mxcUrl}`);
|
||||
}
|
||||
const endpoint = `/_matrix/media/v3/download/${encodeURIComponent(parsed.server)}/${encodeURIComponent(parsed.mediaId)}`;
|
||||
const response = await this.requestRaw({
|
||||
const response = await this.httpClient.requestRaw({
|
||||
method: "GET",
|
||||
endpoint,
|
||||
qs: { allow_remote: allowRemote },
|
||||
@@ -533,7 +399,7 @@ export class MatrixClient {
|
||||
}
|
||||
|
||||
async sendReadReceipt(roomId: string, eventId: string): Promise<void> {
|
||||
await this.requestJson({
|
||||
await this.httpClient.requestJson({
|
||||
method: "POST",
|
||||
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/receipt/m.read/${encodeURIComponent(
|
||||
eventId,
|
||||
@@ -586,111 +452,6 @@ export class MatrixClient {
|
||||
});
|
||||
}
|
||||
|
||||
private createCryptoFacade(): 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 = this.client.getRoom(roomId);
|
||||
if (room?.hasEncryptionStateEvent()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const event = await this.getRoomStateEvent(roomId, "m.room.encryption", "");
|
||||
return typeof event.algorithm === "string" && event.algorithm.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
requestOwnUserVerification: async (): Promise<unknown | null> => {
|
||||
const crypto = this.client.getCrypto() as MatrixVerificationCryptoApi | undefined;
|
||||
return await this.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 this.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 this.recoveryKeyStore.getRecoveryKeySummary();
|
||||
},
|
||||
listVerifications: async () => {
|
||||
return this.verificationManager.listVerifications();
|
||||
},
|
||||
requestVerification: async (params) => {
|
||||
const crypto = this.client.getCrypto() as MatrixVerificationCryptoApi | undefined;
|
||||
return await this.verificationManager.requestVerification(crypto, params);
|
||||
},
|
||||
acceptVerification: async (id) => {
|
||||
return await this.verificationManager.acceptVerification(id);
|
||||
},
|
||||
cancelVerification: async (id, params) => {
|
||||
return await this.verificationManager.cancelVerification(id, params);
|
||||
},
|
||||
startVerification: async (id, method = "sas") => {
|
||||
return await this.verificationManager.startVerification(id, method);
|
||||
},
|
||||
generateVerificationQr: async (id) => {
|
||||
return await this.verificationManager.generateVerificationQr(id);
|
||||
},
|
||||
scanVerificationQr: async (id, qrDataBase64) => {
|
||||
return await this.verificationManager.scanVerificationQr(id, qrDataBase64);
|
||||
},
|
||||
confirmVerificationSas: async (id) => {
|
||||
return await this.verificationManager.confirmVerificationSas(id);
|
||||
},
|
||||
mismatchVerificationSas: async (id) => {
|
||||
return this.verificationManager.mismatchVerificationSas(id);
|
||||
},
|
||||
confirmVerificationReciprocateQr: async (id) => {
|
||||
return this.verificationManager.confirmVerificationReciprocateQr(id);
|
||||
},
|
||||
getVerificationSas: async (id) => {
|
||||
return this.verificationManager.getVerificationSas(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async refreshDmCache(): Promise<void> {
|
||||
const direct = await this.getAccountData("m.direct");
|
||||
this.dmRoomIds.clear();
|
||||
@@ -708,23 +469,4 @@ export class MatrixClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async requestJson(params: {
|
||||
method: HttpMethod;
|
||||
endpoint: string;
|
||||
qs?: QueryParams;
|
||||
body?: unknown;
|
||||
timeoutMs: number;
|
||||
}): Promise<unknown> {
|
||||
return await this.httpClient.requestJson(params);
|
||||
}
|
||||
|
||||
private async requestRaw(params: {
|
||||
method: HttpMethod;
|
||||
endpoint: string;
|
||||
qs?: QueryParams;
|
||||
timeoutMs: number;
|
||||
}): Promise<Buffer> {
|
||||
return await this.httpClient.requestRaw(params);
|
||||
}
|
||||
}
|
||||
|
||||
117
extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts
Normal file
117
extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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"),
|
||||
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({
|
||||
setupNewCrossSigning: true,
|
||||
});
|
||||
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
|
||||
crypto,
|
||||
);
|
||||
expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledWith(crypto);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
122
extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts
Normal file
122
extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
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 { MatrixCryptoBootstrapApi, MatrixRawEvent } 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>;
|
||||
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.bootstrapCrossSigning(crypto);
|
||||
await this.bootstrapSecretStorage(crypto);
|
||||
await this.ensureOwnDeviceTrust(crypto);
|
||||
this.registerVerificationRequestHandler(crypto);
|
||||
}
|
||||
|
||||
private async bootstrapCrossSigning(crypto: MatrixCryptoBootstrapApi): Promise<void> {
|
||||
try {
|
||||
await crypto.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", err);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
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");
|
||||
});
|
||||
});
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export class MatrixAuthedHttpClient {
|
||||
qs?: QueryParams;
|
||||
body?: unknown;
|
||||
timeoutMs: number;
|
||||
allowAbsoluteEndpoint?: boolean;
|
||||
}): Promise<unknown> {
|
||||
const { response, text } = await performMatrixRequest({
|
||||
homeserver: this.homeserver,
|
||||
@@ -22,6 +23,7 @@ export class MatrixAuthedHttpClient {
|
||||
qs: params.qs,
|
||||
body: params.body,
|
||||
timeoutMs: params.timeoutMs,
|
||||
allowAbsoluteEndpoint: params.allowAbsoluteEndpoint,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw buildHttpError(response.status, text);
|
||||
@@ -41,6 +43,7 @@ export class MatrixAuthedHttpClient {
|
||||
endpoint: string;
|
||||
qs?: QueryParams;
|
||||
timeoutMs: number;
|
||||
allowAbsoluteEndpoint?: boolean;
|
||||
}): Promise<Buffer> {
|
||||
const { response, buffer } = await performMatrixRequest({
|
||||
homeserver: this.homeserver,
|
||||
@@ -50,6 +53,7 @@ export class MatrixAuthedHttpClient {
|
||||
qs: params.qs,
|
||||
timeoutMs: params.timeoutMs,
|
||||
raw: true,
|
||||
allowAbsoluteEndpoint: params.allowAbsoluteEndpoint,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw buildHttpError(response.status, buffer.toString("utf8"));
|
||||
|
||||
138
extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts
Normal file
138
extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -107,11 +107,19 @@ export async function performMatrixRequest(params: {
|
||||
body?: unknown;
|
||||
timeoutMs: number;
|
||||
raw?: boolean;
|
||||
allowAbsoluteEndpoint?: boolean;
|
||||
}): Promise<{ response: Response; text: string; buffer: Buffer }> {
|
||||
const baseUrl =
|
||||
params.endpoint.startsWith("http://") || params.endpoint.startsWith("https://")
|
||||
? new URL(params.endpoint)
|
||||
: new URL(normalizeEndpoint(params.endpoint), params.homeserver);
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user