Matrix-js: add E2EE verification runtime and CLI

This commit is contained in:
Gustavo Madeira Santana
2026-02-23 00:44:50 -05:00
parent 877069783b
commit 999fa0f50f
17 changed files with 735 additions and 892 deletions

View File

@@ -1,6 +1,12 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { GatewayRequestHandlerOptions, OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { matrixPlugin } from "./src/channel.js";
import { registerMatrixJsCli } from "./src/cli.js";
import {
bootstrapMatrixVerification,
getMatrixVerificationStatus,
verifyMatrixRecoveryKey,
} from "./src/matrix/actions/verification.js";
import { setMatrixRuntime } from "./src/runtime.js";
const plugin = {
@@ -11,6 +17,78 @@ const plugin = {
register(api: OpenClawPluginApi) {
setMatrixRuntime(api.runtime);
api.registerChannel({ plugin: matrixPlugin });
const sendError = (respond: (ok: boolean, payload?: unknown) => void, err: unknown) => {
respond(false, { error: err instanceof Error ? err.message : String(err) });
};
api.registerGatewayMethod(
"matrix-js.verify.recoveryKey",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const key = typeof params?.key === "string" ? params.key : "";
if (!key.trim()) {
respond(false, { error: "key required" });
return;
}
const accountId =
typeof params?.accountId === "string"
? params.accountId.trim() || undefined
: undefined;
const result = await verifyMatrixRecoveryKey(key, { accountId });
respond(result.success, result);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"matrix-js.verify.bootstrap",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const accountId =
typeof params?.accountId === "string"
? params.accountId.trim() || undefined
: undefined;
const recoveryKey =
typeof params?.recoveryKey === "string" ? params.recoveryKey : undefined;
const forceResetCrossSigning = params?.forceResetCrossSigning === true;
const result = await bootstrapMatrixVerification({
accountId,
recoveryKey,
forceResetCrossSigning,
});
respond(result.success, result);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"matrix-js.verify.status",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const accountId =
typeof params?.accountId === "string"
? params.accountId.trim() || undefined
: undefined;
const includeRecoveryKey = params?.includeRecoveryKey === true;
const status = await getMatrixVerificationStatus({ accountId, includeRecoveryKey });
respond(true, status);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerCli(
({ program }) => {
registerMatrixJsCli({ program });
},
{ commands: ["matrix-js"] },
);
},
};

View File

@@ -0,0 +1,154 @@
import type { Command } from "commander";
import {
bootstrapMatrixVerification,
getMatrixVerificationStatus,
verifyMatrixRecoveryKey,
} from "./matrix/actions/verification.js";
function printVerificationStatus(status: {
verified: boolean;
userId: string | null;
deviceId: string | null;
backupVersion: string | null;
recoveryKeyStored: boolean;
recoveryKeyCreatedAt: string | null;
pendingVerifications: number;
}): void {
if (status.verified) {
console.log("Verified: yes");
console.log(`User: ${status.userId ?? "unknown"}`);
console.log(`Device: ${status.deviceId ?? "unknown"}`);
if (status.backupVersion) {
console.log(`Backup version: ${status.backupVersion}`);
}
} else {
console.log("Verified: no");
console.log(`User: ${status.userId ?? "unknown"}`);
console.log(`Device: ${status.deviceId ?? "unknown"}`);
console.log("Run 'openclaw matrix-js verify recovery-key <key>' to verify this device.");
}
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
if (status.recoveryKeyCreatedAt) {
console.log(`Recovery key created at: ${status.recoveryKeyCreatedAt}`);
}
console.log(`Pending verifications: ${status.pendingVerifications}`);
}
export function registerMatrixJsCli(params: { program: Command }): void {
const root = params.program
.command("matrix-js")
.description("Matrix-js channel utilities")
.addHelpText("after", () => "\nDocs: https://docs.openclaw.ai/channels/matrix\n");
const verify = root.command("verify").description("Device verification for Matrix E2EE");
verify
.command("status")
.description("Check Matrix-js device verification status")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--include-recovery-key", "Include stored recovery key in output")
.option("--json", "Output as JSON")
.action(async (options: { account?: string; includeRecoveryKey?: boolean; json?: boolean }) => {
try {
const status = await getMatrixVerificationStatus({
accountId: options.account,
includeRecoveryKey: options.includeRecoveryKey === true,
});
if (options.json) {
console.log(JSON.stringify(status, null, 2));
return;
}
printVerificationStatus(status);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (options.json) {
console.log(JSON.stringify({ error: message }, null, 2));
} else {
console.error(`Error: ${message}`);
}
process.exitCode = 1;
}
});
verify
.command("bootstrap")
.description("Bootstrap Matrix-js cross-signing and device verification state")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--recovery-key <key>", "Recovery key to apply before bootstrap")
.option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
recoveryKey?: string;
forceResetCrossSigning?: boolean;
json?: boolean;
}) => {
try {
const result = await bootstrapMatrixVerification({
accountId: options.account,
recoveryKey: options.recoveryKey,
forceResetCrossSigning: options.forceResetCrossSigning === true,
});
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
console.log(`Bootstrap success: ${result.success ? "yes" : "no"}`);
if (result.error) {
console.log(`Error: ${result.error}`);
}
console.log(`Verified: ${result.verification.verified ? "yes" : "no"}`);
console.log(`User: ${result.verification.userId ?? "unknown"}`);
console.log(`Device: ${result.verification.deviceId ?? "unknown"}`);
console.log(
`Cross-signing published: ${result.crossSigning.published ? "yes" : "no"} (master=${result.crossSigning.masterKeyPublished ? "yes" : "no"}, self=${result.crossSigning.selfSigningKeyPublished ? "yes" : "no"}, user=${result.crossSigning.userSigningKeyPublished ? "yes" : "no"})`,
);
console.log(`Pending verifications: ${result.pendingVerifications}`);
if (!result.success) {
process.exitCode = 1;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (options.json) {
console.log(JSON.stringify({ success: false, error: message }, null, 2));
} else {
console.error(`Verification bootstrap failed: ${message}`);
}
process.exitCode = 1;
}
},
);
verify
.command("recovery-key <key>")
.description("Verify device using a Matrix recovery key")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--json", "Output as JSON")
.action(async (key: string, options: { account?: string; json?: boolean }) => {
try {
const result = await verifyMatrixRecoveryKey(key, { accountId: options.account });
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else if (result.success) {
console.log("Device verification completed successfully.");
console.log(`User: ${result.userId ?? "unknown"}`);
console.log(`Device: ${result.deviceId ?? "unknown"}`);
if (result.backupVersion) {
console.log(`Backup version: ${result.backupVersion}`);
}
} else {
console.error(`Verification failed: ${result.error ?? "unknown error"}`);
process.exitCode = 1;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (options.json) {
console.log(JSON.stringify({ success: false, error: message }, null, 2));
} else {
console.error(`Verification failed: ${message}`);
}
process.exitCode = 1;
}
});
}

View File

@@ -13,17 +13,20 @@ export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js";
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js";
export {
bootstrapMatrixVerification,
acceptMatrixVerification,
cancelMatrixVerification,
confirmMatrixVerificationReciprocateQr,
confirmMatrixVerificationSas,
generateMatrixVerificationQr,
getMatrixEncryptionStatus,
getMatrixVerificationStatus,
getMatrixVerificationSas,
listMatrixVerifications,
mismatchMatrixVerificationSas,
requestMatrixVerification,
scanMatrixVerificationQr,
startMatrixVerification,
verifyMatrixRecoveryKey,
} from "./actions/verification.js";
export { reactMatrixMessage } from "./send.js";

View File

@@ -5,7 +5,9 @@ function requireCrypto(
client: import("../sdk.js").MatrixClient,
): NonNullable<import("../sdk.js").MatrixClient["crypto"]> {
if (!client.crypto) {
throw new Error("Matrix encryption is not available (enable channels.matrix.encryption=true)");
throw new Error(
"Matrix encryption is not available (enable channels.matrix-js.encryption=true)",
);
}
return client.crypto;
}
@@ -218,3 +220,61 @@ export async function getMatrixEncryptionStatus(
}
}
}
export async function getMatrixVerificationStatus(
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const status = await client.getOwnDeviceVerificationStatus();
const payload = {
...status,
pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0,
};
if (!opts.includeRecoveryKey) {
return payload;
}
const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null;
return {
...payload,
recoveryKey: recoveryKey?.encodedPrivateKey ?? null,
};
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function verifyMatrixRecoveryKey(
recoveryKey: string,
opts: MatrixActionClientOpts = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
return await client.verifyWithRecoveryKey(recoveryKey);
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function bootstrapMatrixVerification(
opts: MatrixActionClientOpts & {
recoveryKey?: string;
forceResetCrossSigning?: boolean;
} = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
return await client.bootstrapOwnDeviceVerification({
recoveryKey: opts.recoveryKey?.trim() || undefined,
forceResetCrossSigning: opts.forceResetCrossSigning === true,
});
} finally {
if (stopOnDone) {
client.stop();
}
}
}

View File

@@ -41,7 +41,7 @@ describe("registerMatrixAutoJoin", () => {
const { client, getInviteHandler, joinRoom } = createClientStub();
const cfg: CoreConfig = {
channels: {
matrix: {
"matrix-js": {
autoJoin: "always",
},
},
@@ -71,7 +71,7 @@ describe("registerMatrixAutoJoin", () => {
});
const cfg: CoreConfig = {
channels: {
matrix: {
"matrix-js": {
autoJoin: "allowlist",
autoJoinAllowlist: ["#allowed:example.org"],
},
@@ -102,7 +102,7 @@ describe("registerMatrixAutoJoin", () => {
});
const cfg: CoreConfig = {
channels: {
matrix: {
"matrix-js": {
autoJoin: "allowlist",
autoJoinAllowlist: [" #allowed:example.org "],
},

View File

@@ -16,9 +16,9 @@ export function registerMatrixAutoJoin(params: {
}
runtime.log?.(message);
};
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
const autoJoin = cfg.channels?.["matrix-js"]?.autoJoin ?? "always";
const autoJoinAllowlist = new Set(
(cfg.channels?.matrix?.autoJoinAllowlist ?? [])
(cfg.channels?.["matrix-js"]?.autoJoinAllowlist ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean),
);

View File

@@ -75,7 +75,7 @@ export function registerMatrixMonitorEvents(params: {
if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) {
warnedEncryptedRooms.add(roomId);
const warning =
"matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt";
"matrix: encrypted event received without encryption enabled; set channels.matrix-js.encryption=true and verify the device to decrypt";
logger.warn({ roomId }, warning);
}
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {

View File

@@ -234,10 +234,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const senderName = await getMemberDisplayName(roomId, senderId);
const storeAllowFrom = await core.channel.pairing
.readAllowFromStore("matrix")
.readAllowFromStore("matrix-js")
.catch(() => []);
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
const groupAllowFrom = cfg.channels?.["matrix-js"]?.groupAllowFrom ?? [];
const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
@@ -254,7 +254,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
if (!allowMatch.allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "matrix",
channel: "matrix-js",
id: senderId,
meta: { name: senderName },
});
@@ -271,7 +271,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"openclaw pairing approve matrix <code>",
"openclaw pairing approve matrix-js <code>",
].join("\n"),
{ client },
);
@@ -375,7 +375,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
});
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "matrix",
surface: "matrix-js",
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = resolveMatrixAllowListMatches({
@@ -410,7 +410,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
if (isRoom && commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "matrix",
channel: "matrix-js",
reason: "control command (unauthorized)",
target: senderId,
});
@@ -451,7 +451,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "matrix",
channel: "matrix-js",
peer: {
kind: isDirectMessage ? "dm" : "channel",
id: isDirectMessage ? senderId : roomId,
@@ -493,8 +493,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined,
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
Provider: "matrix" as const,
Surface: "matrix" as const,
Provider: "matrix-js" as const,
Surface: "matrix-js" as const,
WasMentioned: isRoom ? wasMentioned : undefined,
MessageSid: messageId,
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
@@ -506,7 +506,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
...locationPayload?.context,
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
OriginatingChannel: "matrix" as const,
OriginatingChannel: "matrix-js" as const,
OriginatingTo: `room:${roomId}`,
});
@@ -517,7 +517,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
updateLastRoute: isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "matrix",
channel: "matrix-js",
to: `room:${roomId}`,
accountId: route.accountId,
}
@@ -576,13 +576,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
let didSendReply = false;
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",
channel: "matrix-js",
accountId: route.accountId,
});
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
channel: "matrix",
channel: "matrix-js",
accountId: route.accountId,
});
const typingCallbacks = createTypingCallbacks({
@@ -591,7 +591,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
onStartError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix",
channel: "matrix-js",
action: "start",
target: roomId,
error: err,
@@ -600,7 +600,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
onStopError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix",
channel: "matrix-js",
action: "stop",
target: roomId,
error: err,

View File

@@ -43,7 +43,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}
const core = getMatrixRuntime();
let cfg = core.config.loadConfig() as CoreConfig;
if (cfg.channels?.matrix?.enabled === false) {
if (cfg.channels?.["matrix-js"]?.enabled === false) {
return;
}
@@ -220,10 +220,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
"matrix-js": {
...cfg.channels?.["matrix-js"],
dm: {
...cfg.channels?.matrix?.dm,
...cfg.channels?.["matrix-js"]?.dm,
allowFrom,
},
groupAllowFrom,
@@ -253,13 +253,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.matrix !== undefined,
providerConfigPresent: cfg.channels?.["matrix-js"] !== undefined,
groupPolicy: accountConfig.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "matrix",
providerKey: "matrix-js",
accountId: account.accountId,
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
log: (message) => logVerboseMessage(message),
@@ -271,7 +271,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix");
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix-js");
const mediaMaxMb = opts.mediaMaxMb ?? accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const startupMs = Date.now();
@@ -329,18 +329,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
// Shared client is already started via resolveSharedMatrixClient.
logger.info(`matrix: logged in as ${auth.userId}`);
// If E2EE is enabled, trigger device verification
// If E2EE is enabled, report device verification status and guidance.
if (auth.encryption && client.crypto) {
try {
// Request verification from other sessions
const verificationRequest = await (
client.crypto as { requestOwnUserVerification?: () => Promise<unknown> }
).requestOwnUserVerification?.();
if (verificationRequest) {
logger.info("matrix: device verification requested - please verify in another client");
const status = await client.getOwnDeviceVerificationStatus();
if (status.verified) {
logger.info("matrix: device is verified and ready for encrypted rooms");
} else {
logger.info(
"matrix: device not verified — run 'openclaw matrix-js verify recovery-key <key>' to enable E2EE",
);
}
} catch (err) {
logger.debug?.("Device verification request failed (may already be verified)", {
logger.debug?.("Failed to resolve matrix-js verification status (non-fatal)", {
error: String(err),
});
}

View File

@@ -20,7 +20,7 @@ export async function deliverMatrixReplies(params: {
params.tableMode ??
core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",
channel: "matrix-js",
accountId: params.accountId,
});
const logVerbose = (message: string) => {
@@ -29,7 +29,7 @@ export async function deliverMatrixReplies(params: {
}
};
const chunkLimit = Math.min(params.textLimit, 4000);
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId);
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix-js", params.accountId);
let hasReplied = false;
for (const reply of params.replies) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;

View File

@@ -98,6 +98,25 @@ describe("MatrixCryptoBootstrapper", () => {
);
});
it("fails in strict mode when cross-signing keys are still unpublished", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({
bootstrapCrossSigning: vi.fn(async () => {}),
isCrossSigningReady: vi.fn(async () => false),
userHasCrossSigningKeys: vi.fn(async () => false),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await expect(bootstrapper.bootstrap(crypto, { strict: true })).rejects.toThrow(
"Cross-signing bootstrap finished but server keys are still not published",
);
});
it("uses password UIA fallback when null and dummy auth fail", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi.fn(async () => {});

View File

@@ -22,15 +22,38 @@ export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
decryptBridge: Pick<MatrixDecryptBridge<TRawEvent>, "bindCryptoRetrySignals">;
};
export type MatrixCryptoBootstrapOptions = {
forceResetCrossSigning?: boolean;
strict?: boolean;
};
export type MatrixCryptoBootstrapResult = {
crossSigningReady: boolean;
crossSigningPublished: boolean;
ownDeviceVerified: boolean | null;
};
export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
constructor(private readonly deps: MatrixCryptoBootstrapperDeps<TRawEvent>) {}
async bootstrap(crypto: MatrixCryptoBootstrapApi): Promise<void> {
await this.bootstrapSecretStorage(crypto);
await this.bootstrapCrossSigning(crypto);
await this.bootstrapSecretStorage(crypto);
await this.ensureOwnDeviceTrust(crypto);
async bootstrap(
crypto: MatrixCryptoBootstrapApi,
options: MatrixCryptoBootstrapOptions = {},
): Promise<MatrixCryptoBootstrapResult> {
const strict = options.strict === true;
await this.bootstrapSecretStorage(crypto, strict);
const crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: options.forceResetCrossSigning === true,
strict,
});
await this.bootstrapSecretStorage(crypto, strict);
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict);
this.registerVerificationRequestHandler(crypto);
return {
crossSigningReady: crossSigning.ready,
crossSigningPublished: crossSigning.published,
ownDeviceVerified,
};
}
private createSigningKeysUiAuthCallback(params: {
@@ -47,7 +70,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
} catch {
if (!params.password?.trim()) {
throw new Error(
"Matrix cross-signing key upload requires UIA; provide matrix.password for m.login.password fallback",
"Matrix cross-signing key upload requires UIA; provide matrix-js.password for m.login.password fallback",
);
}
return await makeRequest({
@@ -60,7 +83,10 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
};
}
private async bootstrapCrossSigning(crypto: MatrixCryptoBootstrapApi): Promise<void> {
private async bootstrapCrossSigning(
crypto: MatrixCryptoBootstrapApi,
options: { forceResetCrossSigning: boolean; strict: boolean },
): Promise<{ ready: boolean; published: boolean }> {
const userId = await this.deps.getUserId();
const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({
userId,
@@ -87,6 +113,37 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
}
};
const finalize = async (): Promise<{ ready: boolean; published: boolean }> => {
const ready = await isCrossSigningReady();
const published = await hasPublishedCrossSigningKeys();
if (ready && published) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return { ready, published };
}
const message = "Cross-signing bootstrap finished but server keys are still not published";
LogService.warn("MatrixClientLite", message);
if (options.strict) {
throw new Error(message);
}
return { ready, published };
};
if (options.forceResetCrossSigning) {
try {
await crypto.bootstrapCrossSigning({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys,
});
} catch (err) {
LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", err);
if (options.strict) {
throw err instanceof Error ? err : new Error(String(err));
}
return { ready: false, published: false };
}
return await finalize();
}
// First pass: preserve existing cross-signing identity and ensure public keys are uploaded.
try {
await crypto.bootstrapCrossSigning({
@@ -105,7 +162,10 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
});
} catch (resetErr) {
LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", resetErr);
return;
if (options.strict) {
throw resetErr instanceof Error ? resetErr : new Error(String(resetErr));
}
return { ready: false, published: false };
}
}
@@ -113,7 +173,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
const firstPassPublished = await hasPublishedCrossSigningKeys();
if (firstPassReady && firstPassPublished) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return;
return { ready: true, published: true };
}
// Fallback: recover from broken local/server state by creating a fresh identity.
@@ -124,27 +184,27 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
});
} catch (err) {
LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err);
return;
if (options.strict) {
throw err instanceof Error ? err : new Error(String(err));
}
return { ready: false, published: false };
}
const finalReady = await isCrossSigningReady();
const finalPublished = await hasPublishedCrossSigningKeys();
if (finalReady && finalPublished) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return;
}
LogService.warn(
"MatrixClientLite",
"Cross-signing bootstrap finished but server keys are still not published",
);
return await finalize();
}
private async bootstrapSecretStorage(crypto: MatrixCryptoBootstrapApi): Promise<void> {
private async bootstrapSecretStorage(
crypto: MatrixCryptoBootstrapApi,
strict = false,
): 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);
if (strict) {
throw err instanceof Error ? err : new Error(String(err));
}
}
}
@@ -188,10 +248,13 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
LogService.info("MatrixClientLite", "Verification request handler registered");
}
private async ensureOwnDeviceTrust(crypto: MatrixCryptoBootstrapApi): Promise<void> {
private async ensureOwnDeviceTrust(
crypto: MatrixCryptoBootstrapApi,
strict = false,
): Promise<boolean | null> {
const deviceId = this.deps.getDeviceId()?.trim();
if (!deviceId) {
return;
return null;
}
const userId = await this.deps.getUserId();
@@ -206,7 +269,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
deviceStatus?.signedByOwner === true;
if (alreadyVerified) {
return;
return true;
}
if (typeof crypto.setDeviceVerified === "function") {
@@ -222,5 +285,19 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
await crypto.crossSignDevice(deviceId);
}
}
const refreshedStatus =
typeof crypto.getDeviceVerificationStatus === "function"
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
: null;
const verified =
refreshedStatus?.isVerified?.() === true ||
refreshedStatus?.localVerified === true ||
refreshedStatus?.crossSigningVerified === true ||
refreshedStatus?.signedByOwner === true;
if (!verified && strict) {
throw new Error(`Matrix own device ${deviceId} is not verified after bootstrap`);
}
return verified;
}
}

View File

@@ -124,7 +124,7 @@ function resolveDefaultIdbSnapshotPath(): string {
process.env.OPENCLAW_STATE_DIR ||
process.env.MOLTBOT_STATE_DIR ||
path.join(process.env.HOME || "/tmp", ".openclaw");
return path.join(stateDir, "credentials", "matrix", "crypto-idb-snapshot.json");
return path.join(stateDir, "credentials", "matrix-js", "crypto-idb-snapshot.json");
}
export async function restoreIdbFromDisk(snapshotPath?: string): Promise<boolean> {

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { encodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type { MatrixCryptoBootstrapApi } from "./types.js";
@@ -173,4 +174,29 @@ describe("MatrixRecoveryKeyStore", () => {
encodedPrivateKey: "encoded-recovered-key",
});
});
it("stores an encoded recovery key and decodes its private key material", () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
expect(encoded).toBeTypeOf("string");
const summary = store.storeEncodedRecoveryKey({
encodedPrivateKey: encoded as string,
keyId: "SSSSKEY",
});
expect(summary.keyId).toBe("SSSSKEY");
expect(summary.encodedPrivateKey).toBe(encoded);
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
privateKeyBase64?: string;
keyId?: string;
};
expect(persisted.keyId).toBe("SSSSKEY");
expect(
Buffer.from(persisted.privateKeyBase64 ?? "", "base64").equals(
Buffer.from(Array.from({ length: 32 }, (_, i) => i + 1)),
),
).toBe(true);
});
});

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
import { LogService } from "./logger.js";
import type {
MatrixCryptoBootstrapApi,
@@ -88,6 +89,43 @@ export class MatrixRecoveryKeyStore {
};
}
storeEncodedRecoveryKey(params: {
encodedPrivateKey: string;
keyId?: string | null;
keyInfo?: MatrixStoredRecoveryKey["keyInfo"];
}): {
encodedPrivateKey?: string;
keyId?: string | null;
createdAt?: string;
} {
const encodedPrivateKey = params.encodedPrivateKey.trim();
if (!encodedPrivateKey) {
throw new Error("Matrix recovery key is required");
}
let privateKey: Uint8Array;
try {
privateKey = decodeRecoveryKey(encodedPrivateKey);
} catch (err) {
throw new Error(
`Invalid Matrix recovery key: ${err instanceof Error ? err.message : String(err)}`,
);
}
const normalizedKeyId =
typeof params.keyId === "string" && params.keyId.trim() ? params.keyId.trim() : null;
const keyInfo = params.keyInfo ?? this.loadStoredRecoveryKey()?.keyInfo;
this.saveRecoveryKeyToDisk({
keyId: normalizedKeyId,
keyInfo,
privateKey,
encodedPrivateKey,
});
if (normalizedKeyId) {
this.rememberSecretStorageKey(normalizedKeyId, privateKey, keyInfo);
}
return this.getRecoveryKeySummary() ?? {};
}
async bootstrapSecretStorageWithRecoveryKey(crypto: MatrixCryptoBootstrapApi): Promise<void> {
let status: MatrixSecretStorageStatus | null = null;
if (typeof crypto.getSecretStorageStatus === "function") {

View File

@@ -7,6 +7,7 @@ import {
readStringParam,
} from "openclaw/plugin-sdk";
import {
bootstrapMatrixVerification,
acceptMatrixVerification,
cancelMatrixVerification,
confirmMatrixVerificationReciprocateQr,
@@ -15,6 +16,7 @@ import {
editMatrixMessage,
generateMatrixVerificationQr,
getMatrixEncryptionStatus,
getMatrixVerificationStatus,
getMatrixMemberInfo,
getMatrixRoomInfo,
getMatrixVerificationSas,
@@ -30,6 +32,7 @@ import {
sendMatrixMessage,
startMatrixVerification,
unpinMatrixMessage,
verifyMatrixRecoveryKey,
} from "./matrix/actions.js";
import { reactMatrixMessage } from "./matrix/send.js";
import type { CoreConfig } from "./types.js";
@@ -50,6 +53,9 @@ const verificationActions = new Set([
"verificationConfirm",
"verificationMismatch",
"verificationConfirmQr",
"verificationStatus",
"verificationBootstrap",
"verificationRecoveryKey",
]);
function readRoomId(params: Record<string, unknown>, required = true): string {
@@ -68,7 +74,8 @@ export async function handleMatrixAction(
cfg: CoreConfig,
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const isActionEnabled = createActionGate(cfg.channels?.matrix?.actions);
const accountId = readStringParam(params, "accountId") ?? undefined;
const isActionEnabled = createActionGate(cfg.channels?.["matrix-js"]?.actions);
if (reactionActions.has(action)) {
if (!isActionEnabled("reactions")) {
@@ -198,11 +205,37 @@ export async function handleMatrixAction(
if (action === "encryptionStatus") {
const includeRecoveryKey = params.includeRecoveryKey === true;
const status = await getMatrixEncryptionStatus({ includeRecoveryKey });
const status = await getMatrixEncryptionStatus({ includeRecoveryKey, accountId });
return jsonResult({ ok: true, status });
}
if (action === "verificationStatus") {
const includeRecoveryKey = params.includeRecoveryKey === true;
const status = await getMatrixVerificationStatus({ includeRecoveryKey, accountId });
return jsonResult({ ok: true, status });
}
if (action === "verificationBootstrap") {
const recoveryKey =
readStringParam(params, "recoveryKey", { trim: false }) ??
readStringParam(params, "key", { trim: false });
const result = await bootstrapMatrixVerification({
recoveryKey: recoveryKey ?? undefined,
forceResetCrossSigning: params.forceResetCrossSigning === true,
accountId,
});
return jsonResult({ ok: result.success, result });
}
if (action === "verificationRecoveryKey") {
const recoveryKey =
readStringParam(params, "recoveryKey", { trim: false }) ??
readStringParam(params, "key", { trim: false });
const result = await verifyMatrixRecoveryKey(
readStringParam({ recoveryKey }, "recoveryKey", { required: true, trim: false }),
{ accountId },
);
return jsonResult({ ok: result.success, result });
}
if (action === "verificationList") {
const verifications = await listMatrixVerifications();
const verifications = await listMatrixVerifications({ accountId });
return jsonResult({ ok: true, verifications });
}
if (action === "verificationRequest") {
@@ -215,12 +248,14 @@ export async function handleMatrixAction(
userId: userId ?? undefined,
deviceId: deviceId ?? undefined,
roomId: roomId ?? undefined,
accountId,
});
return jsonResult({ ok: true, verification });
}
if (action === "verificationAccept") {
const verification = await acceptMatrixVerification(
readStringParam({ requestId }, "requestId", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, verification });
}
@@ -229,7 +264,7 @@ export async function handleMatrixAction(
const code = readStringParam(params, "code");
const verification = await cancelMatrixVerification(
readStringParam({ requestId }, "requestId", { required: true }),
{ reason: reason ?? undefined, code: code ?? undefined },
{ reason: reason ?? undefined, code: code ?? undefined, accountId },
);
return jsonResult({ ok: true, verification });
}
@@ -243,13 +278,14 @@ export async function handleMatrixAction(
}
const verification = await startMatrixVerification(
readStringParam({ requestId }, "requestId", { required: true }),
{ method: "sas" },
{ method: "sas", accountId },
);
return jsonResult({ ok: true, verification });
}
if (action === "verificationGenerateQr") {
const qr = await generateMatrixVerificationQr(
readStringParam({ requestId }, "requestId", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, ...qr });
}
@@ -261,30 +297,35 @@ export async function handleMatrixAction(
const verification = await scanMatrixVerificationQr(
readStringParam({ requestId }, "requestId", { required: true }),
readStringParam({ qrDataBase64 }, "qrDataBase64", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, verification });
}
if (action === "verificationSas") {
const sas = await getMatrixVerificationSas(
readStringParam({ requestId }, "requestId", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, sas });
}
if (action === "verificationConfirm") {
const verification = await confirmMatrixVerificationSas(
readStringParam({ requestId }, "requestId", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, verification });
}
if (action === "verificationMismatch") {
const verification = await mismatchMatrixVerificationSas(
readStringParam({ requestId }, "requestId", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, verification });
}
if (action === "verificationConfirmQr") {
const verification = await confirmMatrixVerificationReciprocateQr(
readStringParam({ requestId }, "requestId", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, verification });
}

990
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff