mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Matrix: repair explicit secret storage bootstrap
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user