diff --git a/docs/channels/matrix-js.md b/docs/channels/matrix-js.md index 39734a56f36..6f3d640e1e9 100644 --- a/docs/channels/matrix-js.md +++ b/docs/channels/matrix-js.md @@ -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. diff --git a/extensions/matrix-js/src/config-migration.ts b/extensions/matrix-js/src/config-migration.ts index a5989024e83..ae1a358cdc7 100644 --- a/extensions/matrix-js/src/config-migration.ts +++ b/extensions/matrix-js/src/config-migration.ts @@ -19,6 +19,8 @@ type LegacyAccountField = | "textChunkLimit" | "chunkMode" | "responsePrefix" + | "startupVerification" + | "startupVerificationCooldownHours" | "mediaMaxMb" | "autoJoin" | "autoJoinAllowlist" @@ -45,6 +47,8 @@ const LEGACY_ACCOUNT_FIELDS: ReadonlyArray = [ "textChunkLimit", "chunkMode", "responsePrefix", + "startupVerification", + "startupVerificationCooldownHours", "mediaMaxMb", "autoJoin", "autoJoinAllowlist", diff --git a/extensions/matrix-js/src/config-schema.ts b/extensions/matrix-js/src/config-schema.ts index d4482e95698..3080c99e14a 100644 --- a/extensions/matrix-js/src/config-schema.ts +++ b/extensions/matrix-js/src/config-schema.ts @@ -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(), diff --git a/extensions/matrix-js/src/matrix/monitor/index.ts b/extensions/matrix-js/src/matrix/monitor/index.ts index a9b1372f9c3..81ab6865e03 100644 --- a/extensions/matrix-js/src/matrix/monitor/index.ts +++ b/extensions/matrix-js/src/matrix/monitor/index.ts @@ -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 ' 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)", { diff --git a/extensions/matrix-js/src/matrix/monitor/startup-verification.test.ts b/extensions/matrix-js/src/matrix/monitor/startup-verification.test.ts new file mode 100644 index 00000000000..a77792a5948 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/startup-verification.test.ts @@ -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; +}) { + 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); + }); +}); diff --git a/extensions/matrix-js/src/matrix/monitor/startup-verification.ts b/extensions/matrix-js/src/matrix/monitor/startup-verification.ts new file mode 100644 index 00000000000..913649670e3 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/startup-verification.ts @@ -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 { + const { value } = await readJsonFileWithFallback( + filePath, + null, + ); + return value && typeof value === "object" ? value : null; +} + +async function clearStartupVerificationState(filePath: string): Promise { + 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; + auth: MatrixAuth; + accountConfig: Pick; + accountId?: string | null; + env?: NodeJS.ProcessEnv; + nowMs?: number; + stateFilePath?: string; +}): Promise { + 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, + }; + } +} diff --git a/extensions/matrix-js/src/types.ts b/extensions/matrix-js/src/types.ts index 59b6c07883f..93507570608 100644 --- a/extensions/matrix-js/src/types.ts +++ b/extensions/matrix-js/src/types.ts @@ -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. */ diff --git a/src/plugin-sdk/matrix-js.ts b/src/plugin-sdk/matrix-js.ts index 5ea8df32aca..ecc9e3951c9 100644 --- a/src/plugin-sdk/matrix-js.ts +++ b/src/plugin-sdk/matrix-js.ts @@ -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";