fix(matrix): harden self-verification trust

This commit is contained in:
Gustavo Madeira Santana
2026-04-23 16:51:29 -04:00
parent 349b708a7e
commit 38e9d9084e
15 changed files with 181 additions and 208 deletions

View File

@@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Google Meet: let realtime Meet sessions consult the full OpenClaw agent for deeper answers while staying in the live voice loop.
- Gateway/VoiceClaw: add a realtime brain WebSocket endpoint backed by Gemini Live, with owner-auth gating and async OpenClaw tool handoff. (#70938) Thanks @yagudaev.
- Providers/DeepSeek: add DeepSeek V4 Flash and V4 Pro to the bundled catalog and make V4 Flash the onboarding default.
- Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras.
### Fixes
@@ -219,7 +220,6 @@ Docs: https://docs.openclaw.ai
- QA/Telegram: record per-scenario reply RTT in the live Telegram QA report and summary, starting with the canary response. (#70550) Thanks @obviyus.
- Status: add an explicit `Runner:` field to `/status` so sessions now report whether they are running on embedded Pi, a CLI-backed provider, or an ACP harness agent/backend such as `codex (acp/acpx)` or `gemini (acp/acpx)`. (#70595)
- Gateway/diagnostics: enable payload-free stability recording by default and add a support-ready diagnostics export with sanitized logs, status, health, config, and stability snapshots for bug reports. (#70324) Thanks @gumadeiras.
- Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras.
### Fixes

View File

@@ -378,13 +378,13 @@ describe("matrix CLI verification commands", () => {
"- Accept the verification request in another Matrix client for this account.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Then run openclaw matrix verify start txn-1 --account ops to start SAS verification.",
"- Then run openclaw matrix verify start --account ops -- txn-1 to start SAS verification.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify sas txn-1 --account ops to display the SAS emoji or decimals.",
"- Run openclaw matrix verify sas --account ops -- txn-1 to display the SAS emoji or decimals.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- When the SAS matches, run openclaw matrix verify confirm-sas txn-1 --account ops.",
"- When the SAS matches, run openclaw matrix verify confirm-sas --account ops -- txn-1.",
);
});
@@ -425,13 +425,39 @@ describe("matrix CLI verification commands", () => {
});
expect(consoleLogMock).toHaveBeenCalledWith("Room id: !room-'$(x):example.org");
expect(consoleLogMock).toHaveBeenCalledWith(
"- Then run openclaw matrix verify start txn-dm --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' to start SAS verification.",
"- Then run openclaw matrix verify start --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm to start SAS verification.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify sas txn-dm --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' to display the SAS emoji or decimals.",
"- Run openclaw matrix verify sas --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm to display the SAS emoji or decimals.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- When the SAS matches, run openclaw matrix verify confirm-sas txn-dm --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org'.",
"- When the SAS matches, run openclaw matrix verify confirm-sas --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm.",
);
});
it("terminates options before remote Matrix verification ids in follow-up commands", async () => {
requestMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
id: "local-id",
transactionId: "--account=evil",
hasSas: false,
sas: undefined,
}),
);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "request", "--own-user", "--account", "ops"], {
from: "user",
});
expect(consoleLogMock).toHaveBeenCalledWith(
"- Then run openclaw matrix verify start --account ops -- --account=evil to start SAS verification.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify sas --account ops -- --account=evil to display the SAS emoji or decimals.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- When the SAS matches, run openclaw matrix verify confirm-sas --account ops -- --account=evil.",
);
});
@@ -545,13 +571,13 @@ describe("matrix CLI verification commands", () => {
});
expect(consoleLogMock).toHaveBeenCalledWith(
"- Then run openclaw matrix verify start 'txn-'\\''$(touch /tmp/pwn)' to start SAS verification.",
"- Then run openclaw matrix verify start -- 'txn-'\\''$(touch /tmp/pwn)' to start SAS verification.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify sas 'txn-'\\''$(touch /tmp/pwn)' to display the SAS emoji or decimals.",
"- Run openclaw matrix verify sas -- 'txn-'\\''$(touch /tmp/pwn)' to display the SAS emoji or decimals.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- When the SAS matches, run openclaw matrix verify confirm-sas 'txn-'\\''$(touch /tmp/pwn)'.",
"- When the SAS matches, run openclaw matrix verify confirm-sas -- 'txn-'\\''$(touch /tmp/pwn)'.",
);
});
@@ -569,10 +595,10 @@ describe("matrix CLI verification commands", () => {
});
expect(consoleLogMock).toHaveBeenCalledWith("SAS decimals: 1234 5678 9012");
expect(consoleLogMock).toHaveBeenCalledWith(
"- If they match, run openclaw matrix verify confirm-sas self-1.",
"- If they match, run openclaw matrix verify confirm-sas -- self-1.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- If they do not match, run openclaw matrix verify mismatch-sas self-1.",
"- If they do not match, run openclaw matrix verify mismatch-sas -- self-1.",
);
});
@@ -611,7 +637,7 @@ describe("matrix CLI verification commands", () => {
verificationDmRoomId: "!dm:example.org",
});
expect(consoleLogMock).toHaveBeenCalledWith(
"- If they match, run openclaw matrix verify confirm-sas txn-dm --user-id @alice:example.org --room-id '!dm:example.org' --account ops.",
"- If they match, run openclaw matrix verify confirm-sas --user-id @alice:example.org --room-id '!dm:example.org' --account ops -- txn-dm.",
);
});
@@ -627,7 +653,7 @@ describe("matrix CLI verification commands", () => {
await program.parseAsync(["matrix", "verify", "accept", "verification-1"], { from: "user" });
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify start txn-stable to start SAS verification.",
"- Run openclaw matrix verify start -- txn-stable to start SAS verification.",
);
});

View File

@@ -127,7 +127,12 @@ function formatMatrixCliCommandParts(parts: string[], accountId?: string): strin
const normalizedAccountId = normalizeAccountId(accountId);
const command = ["openclaw", "matrix", ...parts];
if (normalizedAccountId !== "default") {
command.push("--account", normalizedAccountId);
const optionTerminatorIndex = command.indexOf("--");
if (optionTerminatorIndex >= 0) {
command.splice(optionTerminatorIndex, 0, "--account", normalizedAccountId);
} else {
command.push("--account", normalizedAccountId);
}
}
return command.map(formatMatrixCliShellArg).join(" ");
}
@@ -910,7 +915,7 @@ function formatMatrixVerificationFollowupCommand(params: {
dmParts?: string[];
}): string {
return formatMatrixCliCommandParts(
["verify", params.action, params.requestId, ...(params.dmParts ?? [])],
["verify", params.action, ...(params.dmParts ?? []), "--", params.requestId],
params.accountId,
);
}

View File

@@ -422,10 +422,11 @@ describe("matrix verification actions", () => {
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const getOwnDeviceVerificationStatus = vi
const getOwnDeviceIdentityVerificationStatus = vi
.fn()
.mockResolvedValueOnce(mockUnverifiedOwnerStatus())
.mockResolvedValueOnce(mockVerifiedOwnerStatus());
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
mockCrossSigningPublicationStatus(),
);
@@ -440,6 +441,7 @@ describe("matrix verification actions", () => {
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus,
getOwnDeviceIdentityVerificationStatus,
getOwnDeviceVerificationStatus,
trustOwnIdentityAfterSelfVerification,
});
@@ -455,7 +457,8 @@ describe("matrix verification actions", () => {
},
});
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2);
expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2);
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
expect(trustOwnIdentityAfterSelfVerification).toHaveBeenCalledTimes(1);
});
@@ -487,6 +490,7 @@ describe("matrix verification actions", () => {
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const getOwnDeviceIdentityVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnCrossSigningPublicationStatus = vi
.fn()
@@ -503,6 +507,7 @@ describe("matrix verification actions", () => {
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus,
getOwnDeviceIdentityVerificationStatus,
getOwnDeviceVerificationStatus,
trustOwnIdentityAfterSelfVerification,
});
@@ -518,7 +523,8 @@ describe("matrix verification actions", () => {
},
});
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2);
expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2);
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
expect(trustOwnIdentityAfterSelfVerification).not.toHaveBeenCalled();
});
@@ -749,6 +755,7 @@ describe("matrix verification actions", () => {
getOwnCrossSigningPublicationStatus: vi.fn(async () =>
mockCrossSigningPublicationStatus(false),
),
getOwnDeviceIdentityVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
getOwnDeviceVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
});
});

View File

@@ -3,7 +3,7 @@ import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { CoreConfig } from "../../types.js";
import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js";
import type { MatrixOwnDeviceVerificationStatus } from "../sdk.js";
import type { MatrixDeviceVerificationStatus, MatrixOwnDeviceVerificationStatus } from "../sdk.js";
import type { MatrixVerificationSummary } from "../sdk/verification-manager.js";
import { withResolvedActionClient, withStartedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
@@ -158,7 +158,7 @@ async function waitForMatrixVerificationSummary(params: {
}
function formatMatrixOwnerVerificationDiagnostics(
status: MatrixOwnDeviceVerificationStatus | undefined,
status: MatrixDeviceVerificationStatus | MatrixOwnDeviceVerificationStatus | undefined,
): string {
if (!status) {
return "Matrix identity trust status was unavailable";
@@ -173,17 +173,17 @@ async function waitForMatrixSelfVerificationTrustStatus(params: {
timeoutMs: number;
}): Promise<MatrixOwnDeviceVerificationStatus> {
const startedAt = Date.now();
let last: MatrixOwnDeviceVerificationStatus | undefined;
let last: MatrixDeviceVerificationStatus | undefined;
let crossSigningPublished = false;
while (Date.now() - startedAt < params.timeoutMs) {
const [status, crossSigning] = await Promise.all([
params.client.getOwnDeviceVerificationStatus(),
params.client.getOwnDeviceIdentityVerificationStatus(),
params.client.getOwnCrossSigningPublicationStatus(),
]);
last = status;
crossSigningPublished = crossSigning.published;
if (last.verified && crossSigningPublished) {
return last;
return await params.client.getOwnDeviceVerificationStatus();
}
await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
}

View File

@@ -1,44 +0,0 @@
import { logger as matrixJsSdkLogger } from "matrix-js-sdk/lib/logger.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { LogService } from "../sdk/logger.js";
import {
createMatrixJsSdkClientLogger,
ensureMatrixSdkLoggingConfigured,
setMatrixSdkConsoleLogging,
setMatrixSdkLogMode,
} from "./logging.js";
describe("Matrix SDK logging", () => {
afterEach(() => {
setMatrixSdkLogMode("default");
setMatrixSdkConsoleLogging(false);
vi.restoreAllMocks();
delete process.env.MATRIX_SDK_DEBUG;
delete process.env.OPENCLAW_MATRIX_SDK_DEBUG;
});
it("suppresses Matrix SDK client logs in quiet mode", () => {
setMatrixSdkConsoleLogging(true);
setMatrixSdkLogMode("quiet");
ensureMatrixSdkLoggingConfigured();
const info = vi.spyOn(console, "info").mockImplementation(() => {});
createMatrixJsSdkClientLogger("MatrixClient").info("should be quiet");
matrixJsSdkLogger.info("global logger should be quiet");
LogService.info("MatrixClient", "should also be quiet");
expect(info).not.toHaveBeenCalled();
});
it("does not force Matrix JS SDK debug logs by default", () => {
const loglevelLogger = matrixJsSdkLogger as unknown as {
levels: { INFO: number };
setLevel: (level: number | string, persist?: boolean) => void;
};
const setLevel = vi.spyOn(loglevelLogger, "setLevel").mockImplementation(() => undefined);
ensureMatrixSdkLoggingConfigured();
expect(setLevel).toHaveBeenLastCalledWith(loglevelLogger.levels.INFO, false);
});
});

View File

@@ -1,12 +1,9 @@
import { logger as matrixJsSdkLogger } from "matrix-js-sdk/lib/logger.js";
import { ConsoleLogger, LogService, setMatrixConsoleLogging } from "../sdk/logger.js";
let matrixSdkLoggingConfigured = false;
let matrixSdkLogMode: "default" | "quiet" = "default";
const matrixSdkBaseLogger = new ConsoleLogger();
type MatrixLogMethod = "trace" | "debug" | "info" | "warn" | "error";
type MatrixJsSdkLogger = {
trace: (...messageOrObject: unknown[]) => void;
debug: (...messageOrObject: unknown[]) => void;
@@ -16,24 +13,17 @@ type MatrixJsSdkLogger = {
getChild: (namespace: string) => MatrixJsSdkLogger;
};
type MatrixJsSdkLoglevelLogger = MatrixJsSdkLogger & {
levels?: { DEBUG?: number; ERROR?: number; INFO?: number };
methodFactory?: (
methodName: string,
logLevel: number,
loggerName: string | symbol,
) => (...args: unknown[]) => void;
rebuild?: () => void;
setLevel?: (level: number | string, persist?: boolean) => void;
};
const quietMatrixSdkLogger = {
trace: () => {},
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
if (!module.includes("MatrixHttpClient")) {
return false;
}
return messageOrObject.some((entry) => {
if (!entry || typeof entry !== "object") {
return false;
}
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
});
}
export function ensureMatrixSdkLoggingConfigured(): void {
if (!matrixSdkLoggingConfigured) {
@@ -58,97 +48,41 @@ export function createMatrixJsSdkClientLogger(prefix = "matrix"): MatrixJsSdkLog
return createMatrixJsSdkLoggerInstance(prefix);
}
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
if (!module.includes("MatrixHttpClient")) {
return false;
}
return messageOrObject.some((entry) => {
if (!entry || typeof entry !== "object") {
return false;
}
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
});
}
function writeMatrixSdkLog(
method: MatrixLogMethod,
module: string,
messageOrObject: unknown[],
): void {
matrixSdkBaseLogger[method](module, ...messageOrObject);
}
function applyMatrixSdkLogger(): void {
if (matrixSdkLogMode === "quiet") {
LogService.setLogger(quietMatrixSdkLogger);
applyMatrixJsSdkLogger();
LogService.setLogger({
trace: () => {},
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
});
return;
}
LogService.setLogger({
trace: (module, ...messageOrObject) => writeMatrixSdkLog("trace", module, messageOrObject),
debug: (module, ...messageOrObject) => writeMatrixSdkLog("debug", module, messageOrObject),
info: (module, ...messageOrObject) => writeMatrixSdkLog("info", module, messageOrObject),
warn: (module, ...messageOrObject) => writeMatrixSdkLog("warn", module, messageOrObject),
trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject),
debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject),
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
error: (module, ...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
return;
}
writeMatrixSdkLog("error", module, messageOrObject);
matrixSdkBaseLogger.error(module, ...messageOrObject);
},
});
applyMatrixJsSdkLogger();
}
function normalizeMatrixJsSdkLogMethod(methodName: string): MatrixLogMethod {
if (methodName === "trace" || methodName === "debug" || methodName === "info") {
return methodName;
}
if (methodName === "warn" || methodName === "error") {
return methodName;
}
return "debug";
}
function formatMatrixJsSdkLoggerName(loggerName: string | symbol): string {
return typeof loggerName === "symbol" ? loggerName.toString() : loggerName;
}
function applyMatrixJsSdkLogger(): void {
const logger = matrixJsSdkLogger as MatrixJsSdkLoglevelLogger;
logger.methodFactory = (methodName, _logLevel, loggerName) => {
const method = normalizeMatrixJsSdkLogMethod(methodName);
const module = formatMatrixJsSdkLoggerName(loggerName);
return (...messageOrObject) => {
if (matrixSdkLogMode === "quiet") {
return;
}
if (method === "error" && shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
return;
}
writeMatrixSdkLog(method, module, messageOrObject);
};
};
logger.setLevel?.(resolveMatrixJsSdkLogLevel(logger), false);
logger.rebuild?.();
}
function resolveMatrixJsSdkLogLevel(logger: MatrixJsSdkLoglevelLogger): number | string {
if (matrixSdkLogMode === "quiet") {
return logger.levels?.ERROR ?? "error";
}
if (process.env.OPENCLAW_MATRIX_SDK_DEBUG === "1" || process.env.MATRIX_SDK_DEBUG === "1") {
return logger.levels?.DEBUG ?? "debug";
}
return logger.levels?.INFO ?? "info";
}
function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger {
const log = (method: MatrixLogMethod, ...messageOrObject: unknown[]): void => {
const log = (method: keyof ConsoleLogger, ...messageOrObject: unknown[]): void => {
if (matrixSdkLogMode === "quiet") {
return;
}
writeMatrixSdkLog(method, prefix, messageOrObject);
(matrixSdkBaseLogger[method] as (module: string, ...args: unknown[]) => void)(
prefix,
...messageOrObject,
);
};
return {

View File

@@ -33,17 +33,18 @@ function stubRuntimeFetch(fetchImpl: typeof fetch): void {
};
}
async function consumeMatrixSecretStorageKey(keyId = "SSSSKEY"): Promise<void> {
async function consumeMatrixSecretStorageKey(keyId = "SSSSKEY"): Promise<boolean> {
const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as {
getSecretStorageKey?: (
params: { keys: Record<string, unknown> },
name: string,
) => Promise<[string, Uint8Array] | null>;
} | null;
await callbacks?.getSecretStorageKey?.(
const result = await callbacks?.getSecretStorageKey?.(
{ keys: { [keyId]: { algorithm: "m.secret_storage.v1.aes-hmac-sha2" } } },
"m.cross_signing.master",
);
return Boolean(result);
}
class FakeMatrixEvent extends EventEmitter {
@@ -1245,6 +1246,25 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(freeOwnIdentity).toHaveBeenCalledTimes(1);
});
it("does not fail self-verification cleanup when own identity verify is unavailable", async () => {
const freeOwnIdentity = vi.fn();
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
getOwnIdentity: vi.fn(async () => ({
free: freeOwnIdentity,
isVerified: () => false,
})),
requestOwnUserVerification: vi.fn(async () => null),
}));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
});
await expect(client.trustOwnIdentityAfterSelfVerification()).resolves.toBeUndefined();
expect(freeOwnIdentity).toHaveBeenCalledTimes(1);
});
it("retries bootstrap with forced reset when initial publish/verification is incomplete", async () => {
matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() }));
const client = new MatrixClient("https://matrix.example.org", "token", {
@@ -1640,10 +1660,12 @@ describe("MatrixClient crypto bootstrapping", () => {
});
it("accepts a staged recovery key when it establishes identity trust and backup usability", async () => {
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
const privateKey = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1));
const encoded = encodeRecoveryKey(privateKey);
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
let backupKeyLoaded = false;
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
@@ -1661,8 +1683,11 @@ describe("MatrixClient crypto bootstrapping", () => {
signedByOwner: true,
})),
checkKeyBackupAndEnable: vi.fn(async () => {}),
getActiveSessionBackupVersion: vi.fn(async () => "11"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
loadSessionBackupPrivateKeyFromSecretStorage: vi.fn(async () => {
backupKeyLoaded = await consumeMatrixSecretStorageKey();
}),
getActiveSessionBackupVersion: vi.fn(async () => (backupKeyLoaded ? "11" : null)),
getSessionBackupPrivateKey: vi.fn(async () => (backupKeyLoaded ? privateKey : null)),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
@@ -1731,10 +1756,12 @@ describe("MatrixClient crypto bootstrapping", () => {
});
it("keeps a usable recovery key distinct from owner device verification", async () => {
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
const privateKey = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1));
const encoded = encodeRecoveryKey(privateKey);
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
let backupKeyLoaded = false;
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
@@ -1752,8 +1779,11 @@ describe("MatrixClient crypto bootstrapping", () => {
signedByOwner: false,
})),
checkKeyBackupAndEnable: vi.fn(async () => {}),
getActiveSessionBackupVersion: vi.fn(async () => "11"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
loadSessionBackupPrivateKeyFromSecretStorage: vi.fn(async () => {
backupKeyLoaded = await consumeMatrixSecretStorageKey();
}),
getActiveSessionBackupVersion: vi.fn(async () => (backupKeyLoaded ? "11" : null)),
getSessionBackupPrivateKey: vi.fn(async () => (backupKeyLoaded ? privateKey : null)),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},

View File

@@ -1136,6 +1136,16 @@ export class MatrixClient {
};
}
async getOwnDeviceIdentityVerificationStatus(): Promise<MatrixDeviceVerificationStatus> {
const userId = this.client.getUserId() ?? this.selfUserId ?? null;
const deviceId = this.client.getDeviceId()?.trim() || null;
const deviceVerification = await this.getDeviceVerificationStatus(userId, deviceId);
return {
...deviceVerification,
verified: deviceVerification.crossSigningVerified,
};
}
async trustOwnIdentityAfterSelfVerification(): Promise<void> {
if (!this.encryptionEnabled) {
return;
@@ -1157,7 +1167,7 @@ export class MatrixClient {
return;
}
if (typeof ownIdentity.verify !== "function") {
throw new Error("Matrix crypto backend does not support trusting own identity");
return;
}
await ownIdentity.verify();
} finally {
@@ -1199,6 +1209,10 @@ export class MatrixClient {
return await fail("Matrix crypto is not available (start client with encryption enabled)");
}
const backupUsableBeforeStagedRecovery =
resolveMatrixRoomKeyBackupReadinessError(await this.getRoomKeyBackupStatus(), {
requireServerBackup: true,
}) === null;
const trimmedRecoveryKey = rawRecoveryKey.trim();
if (!trimmedRecoveryKey) {
return await fail("Matrix recovery key is required");
@@ -1240,9 +1254,18 @@ export class MatrixClient {
const stagedRecoveryKeyConfirmedBySecretStorage =
Boolean(stagedKeyId) &&
secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId ?? ""] === true;
const stagedRecoveryKeyRejectedBySecretStorage =
Boolean(stagedKeyId) &&
secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId ?? ""] === false;
const stagedRecoveryKeyUnlockedBackup =
stagedRecoveryKeyUsed &&
!stagedRecoveryKeyRejectedBySecretStorage &&
!stagedRecoveryKeyConfirmedBySecretStorage &&
!backupUsableBeforeStagedRecovery &&
backupUsable;
const stagedRecoveryKeyValidated =
stagedRecoveryKeyUsed &&
(stagedRecoveryKeyConfirmedBySecretStorage || (status.verified && backupUsable));
(stagedRecoveryKeyConfirmedBySecretStorage || stagedRecoveryKeyUnlockedBackup);
const recoveryKeyAccepted = stagedRecoveryKeyValidated && (status.verified || backupUsable);
if (!status.verified) {
if (backupUsable && stagedRecoveryKeyValidated) {

View File

@@ -196,7 +196,8 @@ describe("MatrixCryptoBootstrapper", () => {
userHasCrossSigningKeys: vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true),
.mockResolvedValueOnce(true)
.mockResolvedValue(true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),

View File

@@ -1,3 +1,4 @@
import { setTimeout as sleep } from "node:timers/promises";
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
import type { MatrixDecryptBridge } from "./decrypt-bridge.js";
import { LogService } from "./logger.js";
@@ -37,6 +38,8 @@ export type MatrixCryptoBootstrapResult = {
ownDeviceVerified: boolean | null;
};
const CROSS_SIGNING_PUBLICATION_WAIT_MS = 5_000;
export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
private verificationHandlerRegistered = false;
@@ -167,7 +170,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
const finalize = async (): Promise<{ ready: boolean; published: boolean }> => {
const ready = await isCrossSigningReady();
const published = await hasPublishedCrossSigningKeys();
const published = ready
? await waitForPublishedCrossSigningKeys()
: await hasPublishedCrossSigningKeys();
if (ready && published) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return { ready, published };
@@ -180,6 +185,17 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
return { ready, published };
};
const waitForPublishedCrossSigningKeys = async (): Promise<boolean> => {
const startedAt = Date.now();
do {
if (await hasPublishedCrossSigningKeys()) {
return true;
}
await sleep(250);
} while (Date.now() - startedAt < CROSS_SIGNING_PUBLICATION_WAIT_MS);
return false;
};
if (options.forceResetCrossSigning) {
const resetCrossSigning = async (): Promise<void> => {
await crypto.bootstrapCrossSigning({

View File

@@ -520,7 +520,7 @@ describe("MatrixVerificationManager", () => {
}
});
it("cross-signs the other own device after auto-confirmed self-verification SAS", async () => {
it("does not cross-sign the other own device after auto-confirmed self-verification SAS", async () => {
vi.useFakeTimers();
const { confirm, verifier } = createSasVerifierFixture({
decimal: [6158, 1986, 3513],
@@ -541,7 +541,7 @@ describe("MatrixVerificationManager", () => {
await vi.advanceTimersByTimeAsync(30_100);
expect(confirm).toHaveBeenCalledTimes(1);
expect(trustOwnDeviceAfterSas).toHaveBeenCalledWith("OTHERDEVICE");
expect(trustOwnDeviceAfterSas).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}

View File

@@ -504,7 +504,7 @@ export class MatrixVerificationManager {
return;
}
session.sasAutoConfirmStarted = true;
void this.confirmSasForSession(session, callbacks)
void this.confirmSasForSession(session, callbacks, { trustOwnDevice: false })
.then(() => {
this.touchVerificationSession(session);
})
@@ -518,9 +518,12 @@ export class MatrixVerificationManager {
private async confirmSasForSession(
session: MatrixVerificationSession,
callbacks: MatrixShowSasCallbacks,
opts: { trustOwnDevice: boolean } = { trustOwnDevice: true },
): Promise<void> {
await callbacks.confirm();
await this.trustOwnDeviceAfterConfirmedSas(session);
if (opts.trustOwnDevice) {
await this.trustOwnDeviceAfterConfirmedSas(session);
}
}
private ensureVerificationStarted(session: MatrixVerificationSession): void {

View File

@@ -18,21 +18,6 @@ describe("redactSensitiveText", () => {
expect(output).toBe("OPENAI_API_KEY=sk-123…cdef");
});
it("masks env assignments after punctuation delimiters", () => {
expect(
redactSensitiveText("(OPENAI_API_KEY=sk-1234567890abcdef)", {
mode: "tools",
patterns: defaults,
}),
).toBe("(OPENAI_API_KEY=sk-123…cdef)");
expect(
redactSensitiveText("[MATRIX_ACCESS_TOKEN=abcdef1234567890ghij]", {
mode: "tools",
patterns: defaults,
}),
).toBe("[MATRIX_ACCESS_TOKEN=abcdef…ghij]");
});
it("masks CLI flags", () => {
const input = "curl --token abcdef1234567890ghij https://api.test";
const output = redactSensitiveText(input, {
@@ -60,17 +45,6 @@ describe("redactSensitiveText", () => {
expect(output).toBe('{"token":"abcdef…ghij"}');
});
it("masks Matrix access token fields and query parameters", () => {
const json = '{"access_token":"abcdef1234567890ghij"}';
const url = "https://matrix.example/_matrix/client/v3/sync?access_token=zyxwv9876543210token";
expect(redactSensitiveText(json, { mode: "tools", patterns: defaults })).toBe(
'{"access_token":"abcdef…ghij"}',
);
expect(redactSensitiveText(url, { mode: "tools", patterns: defaults })).toBe(
"https://matrix.example/_matrix/client/v3/sync?access_token=zyxwv9…oken",
);
});
it("masks bearer tokens", () => {
const input = "Authorization: Bearer abcdef1234567890ghij";
const output = redactSensitiveText(input, {

View File

@@ -15,11 +15,9 @@ const DEFAULT_REDACT_KEEP_END = 4;
const DEFAULT_REDACT_PATTERNS: string[] = [
// ENV-style assignments.
String.raw`(?:^|[\s([{,;])\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)\b\s*[=:]\s*(["']?)([^\s"'\\)\]\},;]+)\1`,
String.raw`\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1`,
// JSON fields.
String.raw`"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken|access_token|refresh_token)"\s*:\s*"([^"]+)"`,
// URL query parameters.
String.raw`([?&](?:access_token|refresh_token)=)([^&#\s]+)`,
String.raw`"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken)"\s*:\s*"([^"]+)"`,
// CLI flags.
String.raw`--(?:api[-_]?key|hook[-_]?token|token|secret|password|passwd)\s+(["']?)([^\s"']+)\1`,
// Authorization headers.