Matrix: harden E2EE flows and split SDK modules

This commit is contained in:
gustavo
2026-02-08 16:09:07 -05:00
parent bba2de35dc
commit cc47efd430
15 changed files with 926 additions and 318 deletions

View File

@@ -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

View File

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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");

View File

@@ -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);
}
}

View 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);
});
});

View 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);
}
}
}
}

View 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" });
});
});

View 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);
},
};
}

View 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");
});
});

View 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,
});
});
});

View File

@@ -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"));

View 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",
});
});
});

View File

@@ -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();