matrix-js: add startup verification policy

This commit is contained in:
Gustavo Madeira Santana
2026-03-08 17:51:43 -04:00
parent 31b17771e8
commit ce971c9406
8 changed files with 616 additions and 5 deletions

View File

@@ -211,6 +211,13 @@ openclaw matrix-js verify backup restore --verbose
All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`.
Use `--json` for full machine-readable output when scripting.
When `encryption: true`, Matrix-js defaults `startupVerification` to `"if-unverified"`.
On startup, if this device is still unverified, Matrix-js will request self-verification in another Matrix client,
skip duplicate requests while one is already pending, and apply a local cooldown before retrying after restarts.
Failed request attempts retry sooner than successful request creation by default.
Set `startupVerification: "off"` to disable automatic startup requests, or tune `startupVerificationCooldownHours`
if you want a shorter or longer retry window.
## Automatic verification notices
Matrix-js now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages.
@@ -338,6 +345,8 @@ See [Groups](/channels/groups) for mention-gating and allowlist behavior.
- `groupAllowFrom`: allowlist of user IDs for room traffic.
- `replyToMode`: `off`, `first`, or `all`.
- `threadReplies`: `off`, `inbound`, or `always`.
- `startupVerification`: automatic self-verification request mode on startup (`if-unverified`, `off`).
- `startupVerificationCooldownHours`: cooldown before retrying automatic startup verification requests.
- `textChunkLimit`: outbound message chunk size.
- `chunkMode`: `length` or `newline`.
- `responsePrefix`: optional message prefix for outbound replies.

View File

@@ -19,6 +19,8 @@ type LegacyAccountField =
| "textChunkLimit"
| "chunkMode"
| "responsePrefix"
| "startupVerification"
| "startupVerificationCooldownHours"
| "mediaMaxMb"
| "autoJoin"
| "autoJoinAllowlist"
@@ -45,6 +47,8 @@ const LEGACY_ACCOUNT_FIELDS: ReadonlyArray<LegacyAccountField> = [
"textChunkLimit",
"chunkMode",
"responsePrefix",
"startupVerification",
"startupVerificationCooldownHours",
"mediaMaxMb",
"autoJoin",
"autoJoinAllowlist",

View File

@@ -60,6 +60,8 @@ export const MatrixConfigSchema = z.object({
.enum(["group-mentions", "group-all", "direct", "all", "none", "off"])
.optional(),
reactionNotifications: z.enum(["off", "own"]).optional(),
startupVerification: z.enum(["off", "if-unverified"]).optional(),
startupVerificationCooldownHours: z.number().optional(),
mediaMaxMb: z.number().optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: z.array(allowFromEntry).optional(),

View File

@@ -27,6 +27,7 @@ import { createDirectRoomTracker } from "./direct.js";
import { registerMatrixMonitorEvents } from "./events.js";
import { createMatrixRoomMessageHandler } from "./handler.js";
import { createMatrixRoomInfoResolver } from "./room-info.js";
import { ensureMatrixStartupVerification } from "./startup-verification.js";
export type MonitorMatrixOpts = {
runtime?: RuntimeEnv;
@@ -363,16 +364,45 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
logger.warn("matrix: failed to sync profile from config", { error: String(err) });
}
// If E2EE is enabled, report device verification status and guidance.
// If E2EE is enabled, report device verification status and request self-verification
// when configured and the device is still unverified.
if (auth.encryption && client.crypto) {
try {
const status = await client.getOwnDeviceVerificationStatus();
if (status.verified) {
const startupVerification = await ensureMatrixStartupVerification({
client,
auth,
accountConfig,
accountId: account.accountId,
env: process.env,
});
if (startupVerification.kind === "verified") {
logger.info("matrix: device is verified and ready for encrypted rooms");
} else {
} else if (
startupVerification.kind === "disabled" ||
startupVerification.kind === "cooldown" ||
startupVerification.kind === "pending" ||
startupVerification.kind === "request-failed"
) {
logger.info(
"matrix: device not verified — run 'openclaw matrix-js verify device <key>' to enable E2EE",
);
if (startupVerification.kind === "pending") {
logger.info(
"matrix: startup verification request is already pending; finish it in another Matrix client",
);
} else if (startupVerification.kind === "cooldown") {
logVerboseMessage(
`matrix: skipped startup verification request due to cooldown (retryAfterMs=${startupVerification.retryAfterMs ?? 0})`,
);
} else if (startupVerification.kind === "request-failed") {
logger.debug?.("Matrix startup verification request failed (non-fatal)", {
error: startupVerification.error ?? "unknown",
});
}
} else if (startupVerification.kind === "requested") {
logger.info(
"matrix: device not verified — requested verification in another Matrix client",
);
}
} catch (err) {
logger.debug?.("Failed to resolve matrix-js verification status (non-fatal)", {

View File

@@ -0,0 +1,323 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ensureMatrixStartupVerification } from "./startup-verification.js";
function createTempStateDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "matrix-js-startup-verify-"));
}
function createStateFilePath(rootDir: string): string {
return path.join(rootDir, "startup-verification.json");
}
type VerificationSummaryLike = {
id: string;
transactionId?: string;
isSelfVerification: boolean;
completed: boolean;
pending: boolean;
};
function createHarness(params?: {
verified?: boolean;
requestVerification?: () => Promise<{ id: string; transactionId?: string }>;
listVerifications?: () => Promise<VerificationSummaryLike[]>;
}) {
const requestVerification =
params?.requestVerification ??
(async () => ({
id: "verification-1",
transactionId: "txn-1",
}));
const listVerifications = params?.listVerifications ?? (async () => []);
const getOwnDeviceVerificationStatus = vi.fn(async () => ({
encryptionEnabled: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
verified: params?.verified === true,
localVerified: params?.verified === true,
crossSigningVerified: params?.verified === true,
signedByOwner: params?.verified === true,
recoveryKeyStored: false,
recoveryKeyCreatedAt: null,
recoveryKeyId: null,
backupVersion: null,
backup: {
serverVersion: null,
activeVersion: null,
trusted: null,
matchesDecryptionKey: null,
decryptionKeyCached: null,
keyLoadAttempted: false,
keyLoadError: null,
},
}));
return {
client: {
getOwnDeviceVerificationStatus,
crypto: {
listVerifications: vi.fn(listVerifications),
requestVerification: vi.fn(requestVerification),
},
},
getOwnDeviceVerificationStatus,
};
}
afterEach(() => {
vi.useRealTimers();
});
describe("ensureMatrixStartupVerification", () => {
it("skips automatic requests when the device is already verified", async () => {
const tempHome = createTempStateDir();
const harness = createHarness({ verified: true });
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
});
expect(result.kind).toBe("verified");
expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled();
});
it("skips automatic requests when a self verification is already pending", async () => {
const tempHome = createTempStateDir();
const harness = createHarness({
listVerifications: async () => [
{
id: "verification-1",
transactionId: "txn-1",
isSelfVerification: true,
completed: false,
pending: true,
},
],
});
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
});
expect(result.kind).toBe("pending");
expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled();
});
it("respects the startup verification cooldown", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z"));
const tempHome = createTempStateDir();
const harness = createHarness();
await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
nowMs: Date.now(),
});
expect(harness.client.crypto.requestVerification).toHaveBeenCalledTimes(1);
const second = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
nowMs: Date.now() + 60_000,
});
expect(second.kind).toBe("cooldown");
expect(harness.client.crypto.requestVerification).toHaveBeenCalledTimes(1);
});
it("supports disabling startup verification requests", async () => {
const tempHome = createTempStateDir();
const harness = createHarness();
const stateFilePath = createStateFilePath(tempHome);
fs.writeFileSync(stateFilePath, JSON.stringify({ attemptedAt: "2026-03-08T12:00:00.000Z" }));
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
accountConfig: {
startupVerification: "off",
},
stateFilePath,
});
expect(result.kind).toBe("disabled");
expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled();
expect(fs.existsSync(stateFilePath)).toBe(false);
});
it("persists a successful startup verification request", async () => {
const tempHome = createTempStateDir();
const harness = createHarness();
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
nowMs: Date.parse("2026-03-08T12:00:00.000Z"),
});
expect(result.kind).toBe("requested");
expect(harness.client.crypto.requestVerification).toHaveBeenCalledWith({ ownUser: true });
expect(fs.existsSync(createStateFilePath(tempHome))).toBe(true);
});
it("keeps startup verification failures non-fatal", async () => {
const tempHome = createTempStateDir();
const harness = createHarness({
requestVerification: async () => {
throw new Error("no other verified session");
},
});
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
});
expect(result.kind).toBe("request-failed");
expect(result.error).toContain("no other verified session");
const cooledDown = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
nowMs: Date.now() + 60_000,
});
expect(cooledDown.kind).toBe("cooldown");
});
it("retries failed startup verification requests sooner than successful ones", async () => {
const tempHome = createTempStateDir();
const stateFilePath = createStateFilePath(tempHome);
const failingHarness = createHarness({
requestVerification: async () => {
throw new Error("no other verified session");
},
});
await ensureMatrixStartupVerification({
client: failingHarness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
accountConfig: {},
stateFilePath,
nowMs: Date.parse("2026-03-08T12:00:00.000Z"),
});
const retryingHarness = createHarness();
const result = await ensureMatrixStartupVerification({
client: retryingHarness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
accountConfig: {},
stateFilePath,
nowMs: Date.parse("2026-03-08T13:30:00.000Z"),
});
expect(result.kind).toBe("requested");
expect(retryingHarness.client.crypto.requestVerification).toHaveBeenCalledTimes(1);
});
it("clears the persisted startup state after verification succeeds", async () => {
const tempHome = createTempStateDir();
const stateFilePath = createStateFilePath(tempHome);
const unverified = createHarness();
await ensureMatrixStartupVerification({
client: unverified.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
accountConfig: {},
stateFilePath,
nowMs: Date.parse("2026-03-08T12:00:00.000Z"),
});
expect(fs.existsSync(stateFilePath)).toBe(true);
const verified = createHarness({ verified: true });
const result = await ensureMatrixStartupVerification({
client: verified.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
accountConfig: {},
stateFilePath,
});
expect(result.kind).toBe("verified");
expect(fs.existsSync(stateFilePath)).toBe(false);
});
});

View File

@@ -0,0 +1,239 @@
import fs from "node:fs/promises";
import path from "node:path";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix-js";
import type { MatrixConfig } from "../../types.js";
import { resolveMatrixStoragePaths } from "../client/storage.js";
import type { MatrixAuth } from "../client/types.js";
import type { MatrixClient, MatrixOwnDeviceVerificationStatus } from "../sdk.js";
const STARTUP_VERIFICATION_STATE_FILENAME = "startup-verification.json";
const DEFAULT_STARTUP_VERIFICATION_MODE = "if-unverified" as const;
const DEFAULT_STARTUP_VERIFICATION_COOLDOWN_HOURS = 24;
const DEFAULT_STARTUP_VERIFICATION_FAILURE_COOLDOWN_MS = 60 * 60 * 1000;
type MatrixStartupVerificationState = {
userId?: string | null;
deviceId?: string | null;
attemptedAt?: string;
outcome?: "requested" | "failed";
requestId?: string;
transactionId?: string;
error?: string;
};
export type MatrixStartupVerificationOutcome =
| {
kind: "disabled" | "verified" | "cooldown" | "pending" | "requested" | "request-failed";
verification: MatrixOwnDeviceVerificationStatus;
requestId?: string;
transactionId?: string;
error?: string;
retryAfterMs?: number;
}
| {
kind: "unsupported";
verification?: undefined;
};
function normalizeCooldownHours(value: number | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return DEFAULT_STARTUP_VERIFICATION_COOLDOWN_HOURS;
}
return Math.max(0, value);
}
function resolveStartupVerificationStatePath(params: {
auth: MatrixAuth;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): string {
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.accountId,
env: params.env,
});
return path.join(storagePaths.rootDir, STARTUP_VERIFICATION_STATE_FILENAME);
}
async function readStartupVerificationState(
filePath: string,
): Promise<MatrixStartupVerificationState | null> {
const { value } = await readJsonFileWithFallback<MatrixStartupVerificationState | null>(
filePath,
null,
);
return value && typeof value === "object" ? value : null;
}
async function clearStartupVerificationState(filePath: string): Promise<void> {
await fs.rm(filePath, { force: true }).catch(() => {});
}
function resolveStateCooldownMs(
state: MatrixStartupVerificationState | null,
cooldownMs: number,
): number {
if (state?.outcome === "failed") {
return Math.min(cooldownMs, DEFAULT_STARTUP_VERIFICATION_FAILURE_COOLDOWN_MS);
}
return cooldownMs;
}
function resolveRetryAfterMs(params: {
attemptedAt?: string;
cooldownMs: number;
nowMs: number;
}): number | undefined {
const attemptedAtMs = Date.parse(params.attemptedAt ?? "");
if (!Number.isFinite(attemptedAtMs)) {
return undefined;
}
const remaining = attemptedAtMs + params.cooldownMs - params.nowMs;
return remaining > 0 ? remaining : undefined;
}
function shouldHonorCooldown(params: {
state: MatrixStartupVerificationState | null;
verification: MatrixOwnDeviceVerificationStatus;
stateCooldownMs: number;
nowMs: number;
}): boolean {
if (!params.state || params.stateCooldownMs <= 0) {
return false;
}
if (
params.state.userId &&
params.verification.userId &&
params.state.userId !== params.verification.userId
) {
return false;
}
if (
params.state.deviceId &&
params.verification.deviceId &&
params.state.deviceId !== params.verification.deviceId
) {
return false;
}
return (
resolveRetryAfterMs({
attemptedAt: params.state.attemptedAt,
cooldownMs: params.stateCooldownMs,
nowMs: params.nowMs,
}) !== undefined
);
}
function hasPendingSelfVerification(
verifications: Array<{
isSelfVerification: boolean;
completed: boolean;
pending: boolean;
}>,
): boolean {
return verifications.some(
(entry) =>
entry.isSelfVerification === true && entry.completed !== true && entry.pending !== false,
);
}
export async function ensureMatrixStartupVerification(params: {
client: Pick<MatrixClient, "crypto" | "getOwnDeviceVerificationStatus">;
auth: MatrixAuth;
accountConfig: Pick<MatrixConfig, "startupVerification" | "startupVerificationCooldownHours">;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
nowMs?: number;
stateFilePath?: string;
}): Promise<MatrixStartupVerificationOutcome> {
if (params.auth.encryption !== true || !params.client.crypto) {
return { kind: "unsupported" };
}
const verification = await params.client.getOwnDeviceVerificationStatus();
const statePath =
params.stateFilePath ??
resolveStartupVerificationStatePath({
auth: params.auth,
accountId: params.accountId,
env: params.env,
});
if (verification.verified) {
await clearStartupVerificationState(statePath);
return {
kind: "verified",
verification,
};
}
const mode = params.accountConfig.startupVerification ?? DEFAULT_STARTUP_VERIFICATION_MODE;
if (mode === "off") {
await clearStartupVerificationState(statePath);
return {
kind: "disabled",
verification,
};
}
const verifications = await params.client.crypto.listVerifications().catch(() => []);
if (hasPendingSelfVerification(verifications)) {
return {
kind: "pending",
verification,
};
}
const cooldownHours = normalizeCooldownHours(
params.accountConfig.startupVerificationCooldownHours,
);
const cooldownMs = cooldownHours * 60 * 60 * 1000;
const nowMs = params.nowMs ?? Date.now();
const state = await readStartupVerificationState(statePath);
const stateCooldownMs = resolveStateCooldownMs(state, cooldownMs);
if (shouldHonorCooldown({ state, verification, stateCooldownMs, nowMs })) {
return {
kind: "cooldown",
verification,
retryAfterMs: resolveRetryAfterMs({
attemptedAt: state?.attemptedAt,
cooldownMs: stateCooldownMs,
nowMs,
}),
};
}
try {
const request = await params.client.crypto.requestVerification({ ownUser: true });
await writeJsonFileAtomically(statePath, {
userId: verification.userId,
deviceId: verification.deviceId,
attemptedAt: new Date(nowMs).toISOString(),
outcome: "requested",
requestId: request.id,
transactionId: request.transactionId,
} satisfies MatrixStartupVerificationState);
return {
kind: "requested",
verification,
requestId: request.id,
transactionId: request.transactionId ?? undefined,
};
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
await writeJsonFileAtomically(statePath, {
userId: verification.userId,
deviceId: verification.deviceId,
attemptedAt: new Date(nowMs).toISOString(),
outcome: "failed",
error,
} satisfies MatrixStartupVerificationState).catch(() => {});
return {
kind: "request-failed",
verification,
error,
};
}
}

View File

@@ -90,6 +90,10 @@ export type MatrixConfig = {
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off";
/** Inbound reaction notifications for bot-authored Matrix messages. */
reactionNotifications?: "off" | "own";
/** Whether Matrix-js should auto-request self verification on startup when unverified. */
startupVerification?: "off" | "if-unverified";
/** Cooldown window for automatic startup verification requests. Default: 24 hours. */
startupVerificationCooldownHours?: number;
/** Max outbound media size in MB. */
mediaMaxMb?: number;
/** Auto-join invites (always|allowlist|off). Default: always. */

View File

@@ -96,7 +96,7 @@ export {
export { formatDocsLink } from "../terminal/links.js";
export type { WizardPrompter } from "../wizard/prompts.js";
export { createScopedPairingAccess } from "./pairing-access.js";
export { writeJsonFileAtomically } from "./json-store.js";
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
export { runPluginCommandWithTimeout } from "./run-command.js";
export { createLoggerBackedRuntime } from "./runtime.js";