Matrix: add backup reset and fix migration targeting

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 10:06:26 -04:00
parent 790f02dcc1
commit e3102fcae7
13 changed files with 560 additions and 52 deletions

View File

@@ -14,6 +14,7 @@ const matrixSetupApplyAccountConfigMock = vi.fn();
const matrixSetupValidateInputMock = vi.fn();
const matrixRuntimeLoadConfigMock = vi.fn();
const matrixRuntimeWriteConfigFileMock = vi.fn();
const resetMatrixRoomKeyBackupMock = vi.fn();
const restoreMatrixRoomKeyBackupMock = vi.fn();
const setMatrixSdkConsoleLoggingMock = vi.fn();
const setMatrixSdkLogModeMock = vi.fn();
@@ -24,6 +25,7 @@ vi.mock("./matrix/actions/verification.js", () => ({
bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args),
getMatrixRoomKeyBackupStatus: (...args: unknown[]) => getMatrixRoomKeyBackupStatusMock(...args),
getMatrixVerificationStatus: (...args: unknown[]) => getMatrixVerificationStatusMock(...args),
resetMatrixRoomKeyBackup: (...args: unknown[]) => resetMatrixRoomKeyBackupMock(...args),
restoreMatrixRoomKeyBackup: (...args: unknown[]) => restoreMatrixRoomKeyBackupMock(...args),
verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args),
}));
@@ -118,6 +120,21 @@ describe("matrix CLI verification commands", () => {
pendingVerifications: 0,
cryptoBootstrap: {},
});
resetMatrixRoomKeyBackupMock.mockResolvedValue({
success: true,
previousVersion: "1",
deletedVersion: "1",
createdVersion: "2",
backup: {
serverVersion: "2",
activeVersion: "2",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
keyLoadAttempted: false,
keyLoadError: null,
},
});
updateMatrixOwnProfileMock.mockResolvedValue({
skipped: false,
displayNameUpdated: true,
@@ -195,6 +212,32 @@ describe("matrix CLI verification commands", () => {
expect(process.exitCode).toBe(1);
});
it("sets non-zero exit code for backup reset failures in JSON mode", async () => {
resetMatrixRoomKeyBackupMock.mockResolvedValue({
success: false,
error: "reset failed",
previousVersion: "1",
deletedVersion: "1",
createdVersion: null,
backup: {
serverVersion: null,
activeVersion: null,
trusted: null,
matchesDecryptionKey: null,
decryptionKeyCached: null,
keyLoadAttempted: false,
keyLoadError: null,
},
});
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "backup", "reset", "--yes", "--json"], {
from: "user",
});
expect(process.exitCode).toBe(1);
});
it("lists matrix devices", async () => {
listMatrixOwnDevicesMock.mockResolvedValue([
{
@@ -732,6 +775,65 @@ describe("matrix CLI verification commands", () => {
);
});
it("includes backup reset guidance when the backup key does not match this device", async () => {
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "21868",
backup: {
serverVersion: "21868",
activeVersion: "21868",
trusted: true,
matchesDecryptionKey: false,
decryptionKeyCached: true,
keyLoadAttempted: false,
keyLoadError: null,
},
recoveryKeyStored: true,
recoveryKeyCreatedAt: "2026-03-09T14:40:00.000Z",
pendingVerifications: 0,
});
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
expect(console.log).toHaveBeenCalledWith(
"- If you want a fresh backup baseline and accept losing unrecoverable history, run 'openclaw matrix verify backup reset --yes'.",
);
});
it("requires --yes before resetting the Matrix room-key backup", async () => {
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "backup", "reset"], { from: "user" });
expect(process.exitCode).toBe(1);
expect(resetMatrixRoomKeyBackupMock).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(
"Backup reset failed: Refusing to reset Matrix room-key backup without --yes",
);
});
it("resets the Matrix room-key backup when confirmed", async () => {
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "backup", "reset", "--yes"], {
from: "user",
});
expect(resetMatrixRoomKeyBackupMock).toHaveBeenCalledWith({ accountId: "default" });
expect(console.log).toHaveBeenCalledWith("Reset success: yes");
expect(console.log).toHaveBeenCalledWith("Previous backup version: 1");
expect(console.log).toHaveBeenCalledWith("Deleted backup version: 1");
expect(console.log).toHaveBeenCalledWith("Current backup version: 2");
expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device");
});
it("prints resolved account-aware guidance when a named Matrix account is selected implicitly", async () => {
resolveMatrixAuthContextMock.mockImplementation(
({ cfg, accountId }: { cfg: unknown; accountId?: string | null }) => ({

View File

@@ -12,6 +12,7 @@ import {
bootstrapMatrixVerification,
getMatrixRoomKeyBackupStatus,
getMatrixVerificationStatus,
resetMatrixRoomKeyBackup,
restoreMatrixRoomKeyBackup,
verifyMatrixRecoveryKey,
} from "./matrix/actions/verification.js";
@@ -572,10 +573,16 @@ function buildVerificationGuidance(
nextSteps.add(
`Backup key mismatch on this device. Re-run '${formatMatrixCliCommand("verify device <key>", accountId)}' with the matching recovery key.`,
);
nextSteps.add(
`If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'.`,
);
} else if (backupIssue.code === "untrusted-signature") {
nextSteps.add(
`Backup trust chain is not verified on this device. Re-run '${formatMatrixCliCommand("verify device <key>", accountId)}'.`,
);
nextSteps.add(
`If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'.`,
);
} else if (backupIssue.code === "indeterminate") {
nextSteps.add(
`Run '${formatMatrixCliCommand("verify status --verbose", accountId)}' to inspect backup trust diagnostics.`,
@@ -828,6 +835,47 @@ export function registerMatrixCli(params: { program: Command }): void {
});
});
backup
.command("reset")
.description("Delete the current server backup and create a fresh room-key backup baseline")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--yes", "Confirm destructive backup reset", false)
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (options: { account?: string; yes?: boolean; verbose?: boolean; json?: boolean }) => {
const accountId = resolveMatrixCliAccountId(options.account);
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () => {
if (options.yes !== true) {
throw new Error("Refusing to reset Matrix room-key backup without --yes");
}
return await resetMatrixRoomKeyBackup({ accountId });
},
onText: (result, verbose) => {
printAccountLabel(accountId);
console.log(`Reset success: ${result.success ? "yes" : "no"}`);
if (result.error) {
console.log(`Error: ${result.error}`);
}
console.log(`Previous backup version: ${result.previousVersion ?? "none"}`);
console.log(`Deleted backup version: ${result.deletedVersion ?? "none"}`);
console.log(`Current backup version: ${result.createdVersion ?? "none"}`);
printBackupSummary(result.backup);
if (verbose) {
printTimestamp("Reset at", result.resetAt);
printBackupStatus(result.backup);
}
},
shouldFail: (result) => !result.success,
errorPrefix: "Backup reset failed",
onJsonError: (message) => ({ success: false, error: message }),
});
},
);
backup
.command("restore")
.description("Restore encrypted room keys from server backup")

View File

@@ -28,6 +28,7 @@ export {
listMatrixVerifications,
mismatchMatrixVerificationSas,
requestMatrixVerification,
resetMatrixRoomKeyBackup,
restoreMatrixRoomKeyBackup,
scanMatrixVerificationQr,
startMatrixVerification,

View File

@@ -215,6 +215,10 @@ export async function restoreMatrixRoomKeyBackup(
);
}
export async function resetMatrixRoomKeyBackup(opts: MatrixActionClientOpts = {}) {
return await withStartedActionClient(opts, async (client) => await client.resetRoomKeyBackup());
}
export async function bootstrapMatrixVerification(
opts: MatrixActionClientOpts & {
recoveryKey?: string;

View File

@@ -1342,6 +1342,90 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(result.backup.matchesDecryptionKey).toBe(false);
});
it("resets the current room-key backup and creates a fresh trusted version", async () => {
const checkKeyBackupAndEnable = vi.fn(async () => {});
const bootstrapSecretStorage = vi.fn(async () => {});
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapSecretStorage,
checkKeyBackupAndEnable,
getActiveSessionBackupVersion: vi.fn(async () => "21869"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "21869",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => {
if (method === "GET" && String(endpoint).includes("/room_keys/version")) {
return { version: "21868" };
}
if (method === "DELETE" && String(endpoint).includes("/room_keys/version/21868")) {
return {};
}
return {};
});
const result = await client.resetRoomKeyBackup();
expect(result.success).toBe(true);
expect(result.previousVersion).toBe("21868");
expect(result.deletedVersion).toBe("21868");
expect(result.createdVersion).toBe("21869");
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
expect.objectContaining({ setupNewKeyBackup: true }),
);
expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1);
});
it("fails reset when the recreated backup still does not match the local decryption key", async () => {
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapSecretStorage: vi.fn(async () => {}),
checkKeyBackupAndEnable: vi.fn(async () => {}),
getActiveSessionBackupVersion: vi.fn(async () => "21868"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "21868",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: false,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => {
if (method === "GET" && String(endpoint).includes("/room_keys/version")) {
return { version: "21868" };
}
if (method === "DELETE" && String(endpoint).includes("/room_keys/version/21868")) {
return {};
}
return {};
});
const result = await client.resetRoomKeyBackup();
expect(result.success).toBe(false);
expect(result.error).toContain("does not have the matching backup decryption key");
expect(result.createdVersion).toBe("21868");
expect(result.backup.matchesDecryptionKey).toBe(false);
});
it("reports bootstrap failure when cross-signing keys are not published", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");

View File

@@ -84,6 +84,16 @@ export type MatrixRoomKeyBackupRestoreResult = {
backup: MatrixRoomKeyBackupStatus;
};
export type MatrixRoomKeyBackupResetResult = {
success: boolean;
error?: string;
previousVersion: string | null;
deletedVersion: string | null;
createdVersion: string | null;
resetAt?: string;
backup: MatrixRoomKeyBackupStatus;
};
export type MatrixRecoveryKeyVerificationResult = MatrixOwnDeviceVerificationStatus & {
success: boolean;
verifiedAt?: string;
@@ -126,6 +136,17 @@ function normalizeOptionalString(value: string | null | undefined): string | nul
return normalized ? normalized : null;
}
function isMatrixNotFoundError(err: unknown): boolean {
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
if (errObj?.statusCode === 404 || errObj?.body?.errcode === "M_NOT_FOUND") {
return true;
}
const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
return (
message.includes("m_not_found") || message.includes("[404]") || message.includes("not found")
);
}
export class MatrixClient {
private readonly client: MatrixJsClient;
private readonly emitter = new EventEmitter();
@@ -900,6 +921,92 @@ export class MatrixClient {
}
}
async resetRoomKeyBackup(): Promise<MatrixRoomKeyBackupResetResult> {
let previousVersion: string | null = null;
let deletedVersion: string | null = null;
const fail = async (error: string): Promise<MatrixRoomKeyBackupResetResult> => {
const backup = await this.getRoomKeyBackupStatus();
return {
success: false,
error,
previousVersion,
deletedVersion,
createdVersion: backup.serverVersion,
backup,
};
};
if (!this.encryptionEnabled) {
return await fail("Matrix encryption is disabled for this client");
}
await this.ensureStartedForCryptoControlPlane();
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (!crypto) {
return await fail("Matrix crypto is not available (start client with encryption enabled)");
}
previousVersion = await this.resolveRoomKeyBackupVersion();
try {
if (previousVersion) {
try {
await this.doRequest(
"DELETE",
`/_matrix/client/v3/room_keys/version/${encodeURIComponent(previousVersion)}`,
);
} catch (err) {
if (!isMatrixNotFoundError(err)) {
throw err;
}
}
deletedVersion = previousVersion;
}
await this.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, {
setupNewKeyBackup: true,
});
await this.enableTrustedRoomKeyBackupIfPossible(crypto);
const backup = await this.getRoomKeyBackupStatus();
const createdVersion = backup.serverVersion;
if (!createdVersion) {
return await fail("Matrix room key backup is still missing after reset.");
}
if (backup.activeVersion !== createdVersion) {
return await fail(
"Matrix room key backup was recreated on the server but is not active on this device.",
);
}
if (backup.decryptionKeyCached === false) {
return await fail(
"Matrix room key backup was recreated but its decryption key is not cached on this device.",
);
}
if (backup.matchesDecryptionKey === false) {
return await fail(
"Matrix room key backup was recreated but this device does not have the matching backup decryption key.",
);
}
if (backup.trusted === false) {
return await fail(
"Matrix room key backup was recreated but is not trusted on this device.",
);
}
return {
success: true,
previousVersion,
deletedVersion,
createdVersion,
resetAt: new Date().toISOString(),
backup,
};
} catch (err) {
return await fail(err instanceof Error ? err.message : String(err));
}
}
async getOwnCrossSigningPublicationStatus(): Promise<MatrixOwnCrossSigningPublicationStatus> {
const userId = this.client.getUserId() ?? this.selfUserId ?? null;
if (!userId) {