Matrix: repair explicit secret storage bootstrap

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 02:14:42 -04:00
parent 4e364cb8e9
commit 5b98d8e5aa
7 changed files with 279 additions and 7 deletions

View File

@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const bootstrapMatrixVerificationMock = vi.fn();
const getMatrixRoomKeyBackupStatusMock = vi.fn();
const getMatrixVerificationStatusMock = vi.fn();
const resolveMatrixAccountConfigMock = vi.fn();
const resolveMatrixAccountMock = vi.fn();
const matrixSetupApplyAccountConfigMock = vi.fn();
const matrixSetupValidateInputMock = vi.fn();
const matrixRuntimeLoadConfigMock = vi.fn();
@@ -30,6 +32,11 @@ vi.mock("./matrix/actions/profile.js", () => ({
updateMatrixOwnProfile: (...args: unknown[]) => updateMatrixOwnProfileMock(...args),
}));
vi.mock("./matrix/accounts.js", () => ({
resolveMatrixAccount: (...args: unknown[]) => resolveMatrixAccountMock(...args),
resolveMatrixAccountConfig: (...args: unknown[]) => resolveMatrixAccountConfigMock(...args),
}));
vi.mock("./channel.js", () => ({
matrixPlugin: {
setup: {
@@ -72,6 +79,22 @@ describe("matrix CLI verification commands", () => {
matrixSetupApplyAccountConfigMock.mockImplementation(({ cfg }: { cfg: unknown }) => cfg);
matrixRuntimeLoadConfigMock.mockReturnValue({});
matrixRuntimeWriteConfigFileMock.mockResolvedValue(undefined);
resolveMatrixAccountMock.mockReturnValue({
configured: false,
});
resolveMatrixAccountConfigMock.mockReturnValue({
encryption: false,
});
bootstrapMatrixVerificationMock.mockResolvedValue({
success: true,
verification: {
recoveryKeyCreatedAt: null,
backupVersion: null,
},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: {},
});
updateMatrixOwnProfileMock.mockResolvedValue({
skipped: false,
displayNameUpdated: true,
@@ -206,6 +229,76 @@ describe("matrix CLI verification commands", () => {
);
});
it("bootstraps verification for newly added encrypted accounts", async () => {
resolveMatrixAccountConfigMock.mockReturnValue({
encryption: true,
});
bootstrapMatrixVerificationMock.mockResolvedValue({
success: true,
verification: {
recoveryKeyCreatedAt: "2026-03-09T06:00:00.000Z",
backupVersion: "7",
},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: {},
});
const program = buildProgram();
await program.parseAsync(
[
"matrix",
"account",
"add",
"--account",
"ops",
"--homeserver",
"https://matrix.example.org",
"--user-id",
"@ops:example.org",
"--password",
"secret",
],
{ from: "user" },
);
expect(bootstrapMatrixVerificationMock).toHaveBeenCalledWith({ accountId: "ops" });
expect(console.log).toHaveBeenCalledWith("Matrix verification bootstrap: complete");
expect(console.log).toHaveBeenCalledWith(
`Recovery key created at: ${formatExpectedLocalTimestamp("2026-03-09T06:00:00.000Z")}`,
);
expect(console.log).toHaveBeenCalledWith("Backup version: 7");
});
it("does not bootstrap verification when updating an already configured account", async () => {
resolveMatrixAccountMock.mockReturnValue({
configured: true,
});
resolveMatrixAccountConfigMock.mockReturnValue({
encryption: true,
});
const program = buildProgram();
await program.parseAsync(
[
"matrix",
"account",
"add",
"--account",
"ops",
"--homeserver",
"https://matrix.example.org",
"--user-id",
"@ops:example.org",
"--password",
"secret",
],
{ from: "user" },
);
expect(bootstrapMatrixVerificationMock).not.toHaveBeenCalled();
});
it("uses --name as fallback account id and prints account-scoped config path", async () => {
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
const program = buildProgram();

View File

@@ -5,6 +5,7 @@ import {
type ChannelSetupInput,
} from "openclaw/plugin-sdk/matrix";
import { matrixPlugin } from "./channel.js";
import { resolveMatrixAccount, resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { updateMatrixOwnProfile } from "./matrix/actions/profile.js";
import {
bootstrapMatrixVerification,
@@ -86,6 +87,13 @@ type MatrixCliAccountAddResult = {
accountId: string;
configPath: string;
useEnv: boolean;
verificationBootstrap: {
attempted: boolean;
success: boolean;
recoveryKeyCreatedAt: string | null;
backupVersion: string | null;
error?: string;
};
profile: {
attempted: boolean;
displayNameUpdated: boolean;
@@ -132,6 +140,7 @@ async function addMatrixAccount(params: {
accountId: params.account,
input,
}) ?? normalizeAccountId(params.account?.trim() || params.name?.trim());
const existingAccount = resolveMatrixAccount({ cfg, accountId });
const validationError = setup.validateInput?.({
cfg,
@@ -148,6 +157,36 @@ async function addMatrixAccount(params: {
input,
}) as CoreConfig;
await runtime.config.writeConfigFile(updated as never);
const accountConfig = resolveMatrixAccountConfig({ cfg: updated, accountId });
let verificationBootstrap: MatrixCliAccountAddResult["verificationBootstrap"] = {
attempted: false,
success: false,
recoveryKeyCreatedAt: null,
backupVersion: null,
};
if (existingAccount.configured !== true && accountConfig.encryption === true) {
try {
const bootstrap = await bootstrapMatrixVerification({ accountId });
verificationBootstrap = {
attempted: true,
success: bootstrap.success === true,
recoveryKeyCreatedAt: bootstrap.verification.recoveryKeyCreatedAt,
backupVersion: bootstrap.verification.backupVersion,
...(bootstrap.success
? {}
: { error: bootstrap.error ?? "Matrix verification bootstrap failed" }),
};
} catch (err) {
verificationBootstrap = {
attempted: true,
success: false,
recoveryKeyCreatedAt: null,
backupVersion: null,
error: toErrorMessage(err),
};
}
}
const desiredDisplayName = input.name?.trim();
const desiredAvatarUrl = input.avatarUrl?.trim();
@@ -197,6 +236,7 @@ async function addMatrixAccount(params: {
accountId,
configPath: resolveMatrixConfigPath(updated, accountId),
useEnv: input.useEnv === true,
verificationBootstrap,
profile,
};
}
@@ -581,6 +621,22 @@ export function registerMatrixCli(params: { program: Command }): void {
console.log(
`Credentials source: ${result.useEnv ? "MATRIX_* / MATRIX_<ACCOUNT_ID>_* env vars" : "inline config"}`,
);
if (result.verificationBootstrap.attempted) {
if (result.verificationBootstrap.success) {
console.log("Matrix verification bootstrap: complete");
printTimestamp(
"Recovery key created at",
result.verificationBootstrap.recoveryKeyCreatedAt,
);
if (result.verificationBootstrap.backupVersion) {
console.log(`Backup version: ${result.verificationBootstrap.backupVersion}`);
}
} else {
console.error(
`Matrix verification bootstrap warning: ${result.verificationBootstrap.error}`,
);
}
}
if (result.profile.attempted) {
if (result.profile.error) {
console.error(`Profile sync warning: ${result.profile.error}`);

View File

@@ -946,6 +946,7 @@ export class MatrixClient {
bootstrapSummary = await this.cryptoBootstrapper.bootstrap(crypto, {
forceResetCrossSigning: params?.forceResetCrossSigning === true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
strict: true,
});
await this.ensureRoomKeyBackupEnabled(crypto);

View File

@@ -55,6 +55,9 @@ describe("MatrixCryptoBootstrapper", () => {
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
crypto,
{
allowSecretStorageRecreateWithoutRecoveryKey: false,
},
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledTimes(2);
expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledWith(crypto);
@@ -129,6 +132,33 @@ describe("MatrixCryptoBootstrapper", () => {
);
});
it("passes explicit secret-storage repair allowance only when requested", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto, {
strict: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
});
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
crypto,
{
allowSecretStorageRecreateWithoutRecoveryKey: true,
},
);
});
it("fails in strict mode when cross-signing keys are still unpublished", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({

View File

@@ -27,6 +27,7 @@ export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
export type MatrixCryptoBootstrapOptions = {
forceResetCrossSigning?: boolean;
allowAutomaticCrossSigningReset?: boolean;
allowSecretStorageRecreateWithoutRecoveryKey?: boolean;
strict?: boolean;
};
@@ -49,13 +50,21 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
// Register verification listeners before expensive bootstrap work so incoming requests
// are not missed during startup.
this.registerVerificationRequestHandler(crypto);
await this.bootstrapSecretStorage(crypto, strict);
await this.bootstrapSecretStorage(crypto, {
strict,
allowSecretStorageRecreateWithoutRecoveryKey:
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
});
const crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: options.forceResetCrossSigning === true,
allowAutomaticCrossSigningReset: options.allowAutomaticCrossSigningReset !== false,
strict,
});
await this.bootstrapSecretStorage(crypto, strict);
await this.bootstrapSecretStorage(crypto, {
strict,
allowSecretStorageRecreateWithoutRecoveryKey:
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
});
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict);
return {
crossSigningReady: crossSigning.ready,
@@ -219,14 +228,20 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
private async bootstrapSecretStorage(
crypto: MatrixCryptoBootstrapApi,
strict = false,
options: {
strict: boolean;
allowSecretStorageRecreateWithoutRecoveryKey: boolean;
},
): Promise<void> {
try {
await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto);
await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, {
allowSecretStorageRecreateWithoutRecoveryKey:
options.allowSecretStorageRecreateWithoutRecoveryKey,
});
LogService.info("MatrixClientLite", "Secret storage bootstrap complete");
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err);
if (strict) {
if (options.strict) {
throw err instanceof Error ? err : new Error(String(err));
}
}

View File

@@ -175,6 +175,58 @@ describe("MatrixRecoveryKeyStore", () => {
});
});
it("recreates secret storage during explicit bootstrap when the server key exists but no local recovery key is available", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const generated = {
keyId: "REPAIRED",
keyInfo: { name: "repaired" },
privateKey: new Uint8Array([7, 7, 8, 9]),
encodedPrivateKey: "encoded-repaired-key",
};
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
const bootstrapSecretStorage = vi.fn(
async (opts?: {
setupNewSecretStorage?: boolean;
createSecretStorageKey?: () => Promise<unknown>;
}) => {
if (opts?.setupNewSecretStorage) {
await opts.createSecretStorageKey?.();
return;
}
throw new Error("getSecretStorageKey callback returned falsey");
},
);
const crypto = {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage,
createRecoveryKeyFromPassphrase,
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "LEGACY",
secretStorageKeyValidityMap: { LEGACY: true },
})),
requestOwnUserVerification: vi.fn(async () => null),
} as unknown as MatrixCryptoBootstrapApi;
await store.bootstrapSecretStorageWithRecoveryKey(crypto, {
allowSecretStorageRecreateWithoutRecoveryKey: true,
});
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
expect(bootstrapSecretStorage).toHaveBeenCalledTimes(2);
expect(bootstrapSecretStorage).toHaveBeenLastCalledWith(
expect.objectContaining({
setupNewSecretStorage: true,
}),
);
expect(store.getRecoveryKeySummary()).toMatchObject({
keyId: "REPAIRED",
encodedPrivateKey: "encoded-repaired-key",
});
});
it("stores an encoded recovery key and decodes its private key material", () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);

View File

@@ -128,7 +128,10 @@ export class MatrixRecoveryKeyStore {
async bootstrapSecretStorageWithRecoveryKey(
crypto: MatrixCryptoBootstrapApi,
options: { setupNewKeyBackup?: boolean } = {},
options: {
setupNewKeyBackup?: boolean;
allowSecretStorageRecreateWithoutRecoveryKey?: boolean;
} = {},
): Promise<void> {
let status: MatrixSecretStorageStatus | null = null;
if (typeof crypto.getSecretStorageStatus === "function") {
@@ -204,7 +207,29 @@ export class MatrixRecoveryKeyStore {
secretStorageOptions.createSecretStorageKey = ensureRecoveryKey;
}
await crypto.bootstrapSecretStorage(secretStorageOptions);
try {
await crypto.bootstrapSecretStorage(secretStorageOptions);
} catch (err) {
const shouldRecreateWithoutRecoveryKey =
options.allowSecretStorageRecreateWithoutRecoveryKey === true &&
!recoveryKey &&
hasDefaultSecretStorageKey &&
err instanceof Error &&
err.message.includes("getSecretStorageKey callback returned falsey");
if (!shouldRecreateWithoutRecoveryKey) {
throw err;
}
LogService.warn(
"MatrixClientLite",
"Secret storage exists on the server but no local recovery key is available; recreating secret storage and generating a new recovery key during explicit bootstrap.",
);
await crypto.bootstrapSecretStorage({
setupNewSecretStorage: true,
setupNewKeyBackup: options.setupNewKeyBackup === true,
createSecretStorageKey: ensureRecoveryKey,
});
}
if (generatedRecoveryKey && this.recoveryKeyPath) {
LogService.warn(