matrix: force SSSS recreation on backup reset when SSSS key is broken (bad MAC) (#60599)

Merged via squash.

Prepared head SHA: 3b0a623407
Co-authored-by: emonty <95156+emonty@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Monty Taylor
2026-04-03 19:34:23 -07:00
committed by GitHub
parent fb1cb99c88
commit d605cb08c5
7 changed files with 235 additions and 9 deletions

View File

@@ -870,7 +870,7 @@ describe("matrix CLI verification commands", () => {
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'.",
"- If you want a fresh backup baseline and accept losing unrecoverable history, run 'openclaw matrix verify backup reset --yes'. This may also repair secret storage so the new backup key can be loaded after restart.",
);
});

View File

@@ -610,14 +610,14 @@ function buildVerificationGuidance(
`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)}'.`,
`If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'. This may also repair secret storage so the new backup key can be loaded after restart.`,
);
} else if (backupIssue.code === "untrusted-signature") {
nextSteps.add(
`Backup trust chain is not verified on this device. Re-run '${formatMatrixCliCommand("verify device <key>", accountId)}' if you have the correct recovery key.`,
);
nextSteps.add(
`If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'.`,
`If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'. This may also repair secret storage so the new backup key can be loaded after restart.`,
);
} else if (backupIssue.code === "indeterminate") {
nextSteps.add(
@@ -949,7 +949,9 @@ export function registerMatrixCli(params: { program: Command }): void {
backup
.command("reset")
.description("Delete the current server backup and create a fresh room-key backup baseline")
.description(
"Delete the current server backup and create a fresh room-key backup baseline, repairing secret storage if needed for a durable reset",
)
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--yes", "Confirm destructive backup reset", false)
.option("--verbose", "Show detailed diagnostics")

View File

@@ -1918,6 +1918,186 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(result.backup.matchesDecryptionKey).toBe(false);
});
it("forces SSSS recreation when backup-secret access fails with bad MAC before reset", async () => {
// Simulates the state after a cross-signing bootstrap that recreated SSSS but left the
// old m.megolm_backup.v1 SSSS entry (encrypted with the old key) on the homeserver.
// The reset preflight now probes backup-secret access directly, so a missing cached
// key plus a repairable secret-storage load failure should force SSSS recreation.
const bootstrapSecretStorage = vi.fn(async () => {});
const checkKeyBackupAndEnable = vi.fn(async () => {});
const loadSessionBackupPrivateKeyFromSecretStorage = vi
.fn()
.mockRejectedValueOnce(new Error("Error decrypting secret m.megolm_backup.v1: bad MAC"));
const getSessionBackupPrivateKey = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValue(new Uint8Array([1]));
const getSecretStorageStatus = vi.fn(async () => ({
ready: true,
defaultKeyId: "key-new",
}));
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapSecretStorage,
checkKeyBackupAndEnable,
loadSessionBackupPrivateKeyFromSecretStorage,
getSessionBackupPrivateKey,
getSecretStorageStatus,
getActiveSessionBackupVersion: vi.fn(async () => "22000"),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "22000",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
});
vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => {
if (method === "GET" && String(endpoint).includes("/room_keys/version")) {
return { version: "21999" };
}
if (method === "DELETE" && String(endpoint).includes("/room_keys/version/21999")) {
return {};
}
return {};
});
const result = await client.resetRoomKeyBackup();
expect(result.success).toBe(true);
expect(result.createdVersion).toBe("22000");
// bootstrapSecretStorage must have been called with setupNewSecretStorage: true
// because the pre-reset bad MAC status triggered forceNewSecretStorage.
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
expect.objectContaining({
setupNewKeyBackup: true,
setupNewSecretStorage: true,
}),
);
expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1);
});
it("forces SSSS recreation when backup-secret access is broken even without a current server backup", async () => {
const bootstrapSecretStorage = vi.fn(async () => {});
const checkKeyBackupAndEnable = vi.fn(async () => {});
const loadSessionBackupPrivateKeyFromSecretStorage = vi
.fn()
.mockRejectedValueOnce(new Error("Error decrypting secret m.megolm_backup.v1: bad MAC"));
const getSessionBackupPrivateKey = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValue(new Uint8Array([1]));
const getActiveSessionBackupVersion = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValue("22001");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapSecretStorage,
checkKeyBackupAndEnable,
loadSessionBackupPrivateKeyFromSecretStorage,
getActiveSessionBackupVersion,
getSessionBackupPrivateKey,
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "22001",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
});
const doRequest = vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => {
if (method === "GET" && String(endpoint).includes("/room_keys/version")) {
return {};
}
return {};
});
const result = await client.resetRoomKeyBackup();
expect(result.success).toBe(true);
expect(result.previousVersion).toBe(null);
expect(result.deletedVersion).toBe(null);
expect(result.createdVersion).toBe("22001");
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
expect.objectContaining({
setupNewKeyBackup: true,
setupNewSecretStorage: true,
}),
);
expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1);
expect(doRequest).not.toHaveBeenCalledWith(
"DELETE",
expect.stringContaining("/room_keys/version/"),
);
});
it("forces SSSS recreation when backup-secret access returns a falsey callback error before reset", async () => {
const bootstrapSecretStorage = vi.fn(async () => {});
const checkKeyBackupAndEnable = vi.fn(async () => {});
const loadSessionBackupPrivateKeyFromSecretStorage = vi
.fn()
.mockRejectedValueOnce(new Error("getSecretStorageKey callback returned falsey"));
const getSessionBackupPrivateKey = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValue(new Uint8Array([1]));
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapSecretStorage,
checkKeyBackupAndEnable,
loadSessionBackupPrivateKeyFromSecretStorage,
getActiveSessionBackupVersion: vi.fn(async () => "22002"),
getSessionBackupPrivateKey,
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "22002",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
});
vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => {
if (method === "GET" && String(endpoint).includes("/room_keys/version")) {
return { version: "22000" };
}
if (method === "DELETE" && String(endpoint).includes("/room_keys/version/22000")) {
return {};
}
return {};
});
const result = await client.resetRoomKeyBackup();
expect(result.success).toBe(true);
expect(result.createdVersion).toBe("22002");
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
expect.objectContaining({
setupNewKeyBackup: true,
setupNewSecretStorage: true,
}),
);
expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1);
});
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

@@ -25,7 +25,10 @@ import { matrixEventToRaw, parseMxc } from "./sdk/event-helpers.js";
import { MatrixAuthedHttpClient } from "./sdk/http-client.js";
import { MATRIX_IDB_PERSIST_INTERVAL_MS } from "./sdk/idb-persistence-lock.js";
import { ConsoleLogger, LogService, noop } from "./sdk/logger.js";
import { MatrixRecoveryKeyStore } from "./sdk/recovery-key-store.js";
import {
MatrixRecoveryKeyStore,
isRepairableSecretStorageAccessError,
} from "./sdk/recovery-key-store.js";
import { createMatrixGuardedFetch, type HttpMethod, type QueryParams } from "./sdk/transport.js";
import type {
MatrixClientEventMap,
@@ -1151,6 +1154,12 @@ export class MatrixClient {
previousVersion = await this.resolveRoomKeyBackupVersion();
// Probe backup-secret access directly before reset. This keeps the reset preflight
// focused on durable secret-storage health instead of the broader backup status flow,
// and still catches stale SSSS/recovery-key state even when the server backup is gone.
const forceNewSecretStorage =
await this.shouldForceSecretStorageRecreationForBackupReset(crypto);
try {
if (previousVersion) {
try {
@@ -1168,6 +1177,12 @@ export class MatrixClient {
await this.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, {
setupNewKeyBackup: true,
// Force SSSS recreation when the existing SSSS key is broken (bad MAC), so
// the new backup key is written into a fresh SSSS consistent with recovery_key.json.
forceNewSecretStorage,
// Also allow recreation if bootstrapSecretStorage itself surfaces a repairable
// error (e.g. bad MAC from a different SSSS entry).
allowSecretStorageRecreateWithoutRecoveryKey: true,
});
await this.enableTrustedRoomKeyBackupIfPossible(crypto);
@@ -1427,6 +1442,26 @@ export class MatrixClient {
return { activeVersion, decryptionKeyCached };
}
private async shouldForceSecretStorageRecreationForBackupReset(
crypto: MatrixCryptoBootstrapApi,
): Promise<boolean> {
const decryptionKeyCached = await this.resolveCachedRoomKeyBackupDecryptionKey(crypto);
if (decryptionKeyCached !== false) {
return false;
}
const loadSessionBackupPrivateKeyFromSecretStorage =
crypto.loadSessionBackupPrivateKeyFromSecretStorage; // pragma: allowlist secret
if (typeof loadSessionBackupPrivateKeyFromSecretStorage !== "function") {
return false;
}
try {
await loadSessionBackupPrivateKeyFromSecretStorage.call(crypto); // pragma: allowlist secret
return false;
} catch (err) {
return isRepairableSecretStorageAccessError(err);
}
}
private async resolveRoomKeyBackupTrustState(
crypto: MatrixCryptoBootstrapApi,
fallbackVersion: string | null,