Matrix: scope encryption guidance by account

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 05:11:14 -04:00
parent 125a3018db
commit c5e575c2af
6 changed files with 163 additions and 16 deletions

View File

@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const withResolvedActionClientMock = vi.fn();
const loadConfigMock = vi.fn(() => ({
channels: {
matrix: {},
},
}));
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => ({
config: {
loadConfig: loadConfigMock,
},
}),
}));
vi.mock("./client.js", () => ({
withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args),
}));
let listMatrixVerifications: typeof import("./verification.js").listMatrixVerifications;
describe("matrix verification actions", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
loadConfigMock.mockReturnValue({
channels: {
matrix: {},
},
});
({ listMatrixVerifications } = await import("./verification.js"));
});
it("points encryption guidance at the selected Matrix account", async () => {
loadConfigMock.mockReturnValue({
channels: {
matrix: {
accounts: {
ops: {
encryption: false,
},
},
},
},
});
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto: null });
});
await expect(listMatrixVerifications({ accountId: "ops" })).rejects.toThrow(
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
);
});
it("uses the resolved default Matrix account when accountId is omitted", async () => {
loadConfigMock.mockReturnValue({
channels: {
matrix: {
defaultAccount: "ops",
accounts: {
ops: {
encryption: false,
},
},
},
},
});
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto: null });
});
await expect(listMatrixVerifications()).rejects.toThrow(
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
);
});
});

View File

@@ -1,11 +1,16 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js";
import { withResolvedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
function requireCrypto(
client: import("../sdk.js").MatrixClient,
opts: MatrixActionClientOpts,
): NonNullable<import("../sdk.js").MatrixClient["crypto"]> {
if (!client.crypto) {
throw new Error("Matrix encryption is not available (enable channels.matrix.encryption=true)");
const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig;
throw new Error(formatMatrixEncryptionUnavailableError(cfg, opts.accountId));
}
return client.crypto;
}
@@ -22,7 +27,7 @@ export async function listMatrixVerifications(opts: MatrixActionClientOpts = {})
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const crypto = requireCrypto(client, opts);
return await crypto.listVerifications();
},
"persist",
@@ -40,7 +45,7 @@ export async function requestMatrixVerification(
return await withResolvedActionClient(
{ ...params, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const crypto = requireCrypto(client, params);
const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId);
return await crypto.requestVerification({
ownUser,
@@ -60,7 +65,7 @@ export async function acceptMatrixVerification(
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const crypto = requireCrypto(client, opts);
return await crypto.acceptVerification(resolveVerificationId(requestId));
},
"persist",
@@ -74,7 +79,7 @@ export async function cancelMatrixVerification(
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const crypto = requireCrypto(client, opts);
return await crypto.cancelVerification(resolveVerificationId(requestId), {
reason: opts.reason?.trim() || undefined,
code: opts.code?.trim() || undefined,
@@ -91,7 +96,7 @@ export async function startMatrixVerification(
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const crypto = requireCrypto(client, opts);
return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas");
},
"persist",
@@ -105,7 +110,7 @@ export async function generateMatrixVerificationQr(
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const crypto = requireCrypto(client, opts);
return await crypto.generateVerificationQr(resolveVerificationId(requestId));
},
"persist",
@@ -120,7 +125,7 @@ export async function scanMatrixVerificationQr(
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const crypto = requireCrypto(client, opts);
const payload = qrDataBase64.trim();
if (!payload) {
throw new Error("Matrix QR data is required");
@@ -138,7 +143,7 @@ export async function getMatrixVerificationSas(
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const crypto = requireCrypto(client, opts);
return await crypto.getVerificationSas(resolveVerificationId(requestId));
},
"persist",
@@ -152,7 +157,7 @@ export async function confirmMatrixVerificationSas(
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const crypto = requireCrypto(client, opts);
return await crypto.confirmVerificationSas(resolveVerificationId(requestId));
},
"persist",
@@ -166,7 +171,7 @@ export async function mismatchMatrixVerificationSas(
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const crypto = requireCrypto(client, opts);
return await crypto.mismatchVerificationSas(resolveVerificationId(requestId));
},
"persist",
@@ -180,7 +185,7 @@ export async function confirmMatrixVerificationReciprocateQr(
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const crypto = requireCrypto(client, opts);
return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId));
},
"persist",
@@ -193,7 +198,7 @@ export async function getMatrixEncryptionStatus(
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const crypto = requireCrypto(client, opts);
const recoveryKey = await crypto.getRecoveryKey();
return {
encryptionEnabled: true,

View File

@@ -0,0 +1,27 @@
import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig } from "../types.js";
import { resolveDefaultMatrixAccountId } from "./accounts.js";
import { resolveMatrixConfigPath } from "./config-update.js";
export function resolveMatrixEncryptionConfigPath(
cfg: CoreConfig,
accountId?: string | null,
): string {
const effectiveAccountId =
normalizeOptionalAccountId(accountId) ?? resolveDefaultMatrixAccountId(cfg);
return `${resolveMatrixConfigPath(cfg, effectiveAccountId)}.encryption`;
}
export function formatMatrixEncryptionUnavailableError(
cfg: CoreConfig,
accountId?: string | null,
): string {
return `Matrix encryption is not available (enable ${resolveMatrixEncryptionConfigPath(cfg, accountId)}=true)`;
}
export function formatMatrixEncryptedEventDisabledWarning(
cfg: CoreConfig,
accountId?: string | null,
): string {
return `matrix: encrypted event received without encryption enabled; set ${resolveMatrixEncryptionConfigPath(cfg, accountId)}=true and verify the device to decrypt`;
}

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../../types.js";
import type { MatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js";
import { registerMatrixMonitorEvents } from "./events.js";
@@ -14,6 +15,8 @@ function getSentNoticeBody(sendMessage: ReturnType<typeof vi.fn>, index = 0): st
}
function createHarness(params?: {
cfg?: CoreConfig;
accountId?: string;
authEncryption?: boolean;
cryptoAvailable?: boolean;
verifications?: Array<{
@@ -50,9 +53,10 @@ function createHarness(params?: {
} as unknown as MatrixClient;
registerMatrixMonitorEvents({
cfg: params?.cfg ?? { channels: { matrix: {} } },
client,
auth: {
accountId: "default",
accountId: params?.accountId ?? "default",
encryption: params?.authEncryption ?? true,
} as MatrixAuth,
logVerboseMessage: vi.fn(),
@@ -268,6 +272,35 @@ describe("registerMatrixMonitorEvents verification routing", () => {
);
});
it("uses the active Matrix account path in encrypted-event warnings", () => {
const { logger, roomEventListener } = createHarness({
accountId: "ops",
authEncryption: false,
cfg: {
channels: {
matrix: {
accounts: {
ops: {},
},
},
},
},
});
roomEventListener("!room:example.org", {
event_id: "$enc1",
sender: "@alice:example.org",
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now(),
content: {},
});
expect(logger.warn).toHaveBeenCalledWith(
"matrix: encrypted event received without encryption enabled; set channels.matrix.accounts.ops.encryption=true and verify the device to decrypt",
{ roomId: "!room:example.org" },
);
});
it("warns once when crypto bindings are unavailable for encrypted rooms", () => {
const { formatNativeDependencyHint, logger, roomEventListener } = createHarness({
authEncryption: true,

View File

@@ -1,11 +1,14 @@
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix";
import type { CoreConfig } from "../../types.js";
import type { MatrixAuth } from "../client.js";
import { formatMatrixEncryptedEventDisabledWarning } from "../encryption-guidance.js";
import type { MatrixClient } from "../sdk.js";
import type { MatrixRawEvent } from "./types.js";
import { EventType } from "./types.js";
import { createMatrixVerificationEventRouter } from "./verification-events.js";
export function registerMatrixMonitorEvents(params: {
cfg: CoreConfig;
client: MatrixClient;
auth: MatrixAuth;
logVerboseMessage: (message: string) => void;
@@ -16,6 +19,7 @@ export function registerMatrixMonitorEvents(params: {
onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise<void>;
}): void {
const {
cfg,
client,
auth,
logVerboseMessage,
@@ -85,8 +89,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";
const warning = formatMatrixEncryptedEventDisabledWarning(cfg, auth.accountId);
logger.warn(warning, { roomId });
}
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {

View File

@@ -208,6 +208,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
);
registerMatrixMonitorEvents({
cfg,
client,
auth,
logVerboseMessage,