matrix: force SSSS recreation on backup reset when SSSS key is broken (bad MAC) (#60599)
Merged via squash. Prepared head SHA: 3b0a623407153b43d91793980ac2d1b0de66f347 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:
@@ -112,6 +112,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/Anthropic Vertex: read ADC files directly during auth discovery so explicit Google credentials and default ADC no longer depend on `existsSync` preflight checks. (#60592) Thanks @gumadeiras.
|
||||
- Android/Talk Mode: restore spoken assistant replies on node-scoped sessions by keeping reply routing synced to the resolved node session key and pausing mic capture during reply playback. (#60306) Thanks @MKV21.
|
||||
- Cron: replay interrupted recurring jobs on the first gateway restart instead of clearing the stale running marker and skipping catch-up until a second restart. (#60583) Thanks @joelnishanth.
|
||||
- Matrix/backup reset: recreate secret storage during backup reset when stale SSSS state blocks durable backup-key reload, including no-backup repair paths. (#60599) thanks @emonty.
|
||||
|
||||
## 2026.4.2
|
||||
|
||||
|
||||
@@ -319,7 +319,9 @@ Verbose restore diagnostics:
|
||||
openclaw matrix verify backup restore --verbose
|
||||
```
|
||||
|
||||
Delete the current server backup and create a fresh backup baseline:
|
||||
Delete the current server backup and create a fresh backup baseline. If the stored
|
||||
backup key cannot be loaded cleanly, this reset can also recreate secret storage so
|
||||
future cold starts can load the new backup key:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup reset --yes
|
||||
@@ -366,8 +368,11 @@ If the homeserver requires interactive auth to upload cross-signing keys, OpenCl
|
||||
|
||||
Use `--force-reset-cross-signing` only when you intentionally want to discard the current cross-signing identity and create a new one.
|
||||
|
||||
If you intentionally want to discard the current room-key backup and start a new backup baseline for future messages, use `openclaw matrix verify backup reset --yes`.
|
||||
Do this only when you accept that unrecoverable old encrypted history will stay unavailable.
|
||||
If you intentionally want to discard the current room-key backup and start a new
|
||||
backup baseline for future messages, use `openclaw matrix verify backup reset --yes`.
|
||||
Do this only when you accept that unrecoverable old encrypted history will stay
|
||||
unavailable and that OpenClaw may recreate secret storage if the current backup
|
||||
secret cannot be loaded safely.
|
||||
|
||||
### Fresh backup baseline
|
||||
|
||||
|
||||
@@ -275,7 +275,10 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
|
||||
- Meaning: the stored key does not match the active Matrix backup.
|
||||
- What to do: rerun `openclaw matrix verify device "<your-recovery-key>"` with the correct key.
|
||||
|
||||
If you accept losing unrecoverable old encrypted history, you can instead reset the current backup baseline with `openclaw matrix verify backup reset --yes`.
|
||||
If you accept losing unrecoverable old encrypted history, you can instead reset the
|
||||
current backup baseline with `openclaw matrix verify backup reset --yes`. When the
|
||||
stored backup secret is broken, that reset may also recreate secret storage so the
|
||||
new backup key can load correctly after restart.
|
||||
|
||||
`Backup trust chain is not verified on this device. Re-run 'openclaw matrix verify device <key>'.`
|
||||
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user