Matrix-js: align backup status issue and guidance

This commit is contained in:
Gustavo Madeira Santana
2026-02-25 16:21:33 -05:00
parent 204b64dbbc
commit ad86d24834
4 changed files with 287 additions and 58 deletions

View File

@@ -267,6 +267,8 @@ describe("matrix-js CLI verification commands", () => {
trusted: true,
matchesDecryptionKey: false,
decryptionKeyCached: false,
keyLoadAttempted: true,
keyLoadError: null,
},
recoveryKeyStored: true,
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
@@ -277,7 +279,42 @@ describe("matrix-js CLI verification commands", () => {
await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" });
expect(console.log).toHaveBeenCalledWith(
"Backup issue: backup decryption key is not loaded on this device",
"Backup issue: backup decryption key is not loaded on this device (secret storage did not return a key)",
);
expect(console.log).toHaveBeenCalledWith(
"- Backup key is not loaded on this device. Run 'openclaw matrix-js verify backup restore' to load it and restore old room keys.",
);
expect(console.log).not.toHaveBeenCalledWith(
"- Backup is present but not trusted for this device. Re-run 'openclaw matrix-js verify device <key>'.",
);
});
it("includes key load failure details in status output", async () => {
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "5256",
backup: {
serverVersion: "5256",
activeVersion: null,
trusted: true,
matchesDecryptionKey: false,
decryptionKeyCached: false,
keyLoadAttempted: true,
keyLoadError: "secret storage key is not available",
},
recoveryKeyStored: true,
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
pendingVerifications: 0,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" });
expect(console.log).toHaveBeenCalledWith(
"Backup issue: backup decryption key could not be loaded from secret storage (secret storage key is not available)",
);
});
@@ -288,6 +325,8 @@ describe("matrix-js CLI verification commands", () => {
trusted: true,
matchesDecryptionKey: false,
decryptionKeyCached: false,
keyLoadAttempted: true,
keyLoadError: null,
});
const program = buildProgram();

View File

@@ -54,6 +54,8 @@ type MatrixCliBackupStatus = {
trusted: boolean | null;
matchesDecryptionKey: boolean | null;
decryptionKeyCached: boolean | null;
keyLoadAttempted: boolean;
keyLoadError: string | null;
};
type MatrixCliVerificationStatus = {
@@ -78,9 +80,27 @@ function resolveBackupStatus(status: {
trusted: status.backup?.trusted ?? null,
matchesDecryptionKey: status.backup?.matchesDecryptionKey ?? null,
decryptionKeyCached: status.backup?.decryptionKeyCached ?? null,
keyLoadAttempted: status.backup?.keyLoadAttempted ?? false,
keyLoadError: status.backup?.keyLoadError ?? null,
};
}
type MatrixCliBackupIssueCode =
| "missing-server-backup"
| "key-load-failed"
| "key-not-loaded"
| "key-mismatch"
| "untrusted-signature"
| "inactive"
| "indeterminate"
| "ok";
type MatrixCliBackupIssue = {
code: MatrixCliBackupIssueCode;
summary: string;
message: string | null;
};
function yesNoUnknown(value: boolean | null): string {
if (value === true) {
return "yes";
@@ -97,76 +117,124 @@ function printBackupStatus(backup: MatrixCliBackupStatus): void {
console.log(`Backup trusted by this device: ${yesNoUnknown(backup.trusted)}`);
console.log(`Backup matches local decryption key: ${yesNoUnknown(backup.matchesDecryptionKey)}`);
console.log(`Backup key cached locally: ${yesNoUnknown(backup.decryptionKeyCached)}`);
console.log(`Backup key load attempted: ${yesNoUnknown(backup.keyLoadAttempted)}`);
if (backup.keyLoadError) {
console.log(`Backup key load error: ${backup.keyLoadError}`);
}
}
function summarizeBackupHealth(backup: MatrixCliBackupStatus): string {
function resolveBackupIssue(backup: MatrixCliBackupStatus): MatrixCliBackupIssue {
if (!backup.serverVersion) {
return "missing on server";
}
if (backup.trusted === false || backup.matchesDecryptionKey === false) {
return "present but not trusted on this device";
}
if (!backup.activeVersion) {
return "present on server but inactive on this device";
}
return "active and trusted on this device";
}
function printBackupSummary(backup: MatrixCliBackupStatus): void {
console.log(`Backup: ${summarizeBackupHealth(backup)}`);
if (backup.serverVersion) {
console.log(`Backup version: ${backup.serverVersion}`);
}
}
function describeBackupIssue(backup: MatrixCliBackupStatus): string | null {
if (!backup.serverVersion) {
return "no room-key backup exists on the homeserver";
return {
code: "missing-server-backup",
summary: "missing on server",
message: "no room-key backup exists on the homeserver",
};
}
if (backup.decryptionKeyCached === false) {
return "backup decryption key is not loaded on this device";
if (backup.keyLoadError) {
return {
code: "key-load-failed",
summary: "present but backup key unavailable on this device",
message: `backup decryption key could not be loaded from secret storage (${backup.keyLoadError})`,
};
}
if (backup.keyLoadAttempted) {
return {
code: "key-not-loaded",
summary: "present but backup key unavailable on this device",
message:
"backup decryption key is not loaded on this device (secret storage did not return a key)",
};
}
return {
code: "key-not-loaded",
summary: "present but backup key unavailable on this device",
message: "backup decryption key is not loaded on this device",
};
}
if (backup.matchesDecryptionKey === false) {
return "backup key mismatch (this device does not have the matching backup decryption key)";
return {
code: "key-mismatch",
summary: "present but backup key mismatch on this device",
message: "backup key mismatch (this device does not have the matching backup decryption key)",
};
}
if (backup.trusted === false) {
return "backup signature chain is not trusted by this device";
return {
code: "untrusted-signature",
summary: "present but not trusted on this device",
message: "backup signature chain is not trusted by this device",
};
}
if (!backup.activeVersion) {
return "backup exists but is not active on this device";
return {
code: "inactive",
summary: "present on server but inactive on this device",
message: "backup exists but is not active on this device",
};
}
if (
backup.trusted === null ||
backup.matchesDecryptionKey === null ||
backup.decryptionKeyCached === null
) {
return "backup trust state could not be fully determined";
return {
code: "indeterminate",
summary: "present but trust state unknown",
message: "backup trust state could not be fully determined",
};
}
return {
code: "ok",
summary: "active and trusted on this device",
message: null,
};
}
function printBackupSummary(backup: MatrixCliBackupStatus): void {
const issue = resolveBackupIssue(backup);
console.log(`Backup: ${issue.summary}`);
if (backup.serverVersion) {
console.log(`Backup version: ${backup.serverVersion}`);
}
return null;
}
function buildVerificationGuidance(status: MatrixCliVerificationStatus): string[] {
const backup = resolveBackupStatus(status);
const backupIssue = resolveBackupIssue(backup);
const nextSteps = new Set<string>();
if (!status.verified) {
nextSteps.add("Run 'openclaw matrix-js verify device <key>' to verify this device.");
}
if (!backup.serverVersion) {
if (backupIssue.code === "missing-server-backup") {
nextSteps.add("Run 'openclaw matrix-js verify bootstrap' to create a room key backup.");
} else if (backup.trusted === false || backup.matchesDecryptionKey === false) {
nextSteps.add(
"Backup is present but not trusted for this device. Re-run 'openclaw matrix-js verify device <key>'.",
);
} else if (!backup.activeVersion) {
} else if (
backupIssue.code === "key-load-failed" ||
backupIssue.code === "key-not-loaded" ||
backupIssue.code === "inactive"
) {
if (status.recoveryKeyStored) {
nextSteps.add(
"Run 'openclaw matrix-js verify backup restore' to load the backup key and restore old room keys.",
"Backup key is not loaded on this device. Run 'openclaw matrix-js verify backup restore' to load it and restore old room keys.",
);
} else {
nextSteps.add(
"Store a recovery key with 'openclaw matrix-js verify device <key>', then run 'openclaw matrix-js verify backup restore'.",
);
}
} else if (backupIssue.code === "key-mismatch") {
nextSteps.add(
"Backup key mismatch on this device. Re-run 'openclaw matrix-js verify device <key>' with the matching recovery key.",
);
} else if (backupIssue.code === "untrusted-signature") {
nextSteps.add(
"Backup trust chain is not verified on this device. Re-run 'openclaw matrix-js verify device <key>'.",
);
} else if (backupIssue.code === "indeterminate") {
nextSteps.add(
"Run 'openclaw matrix-js verify status --verbose' to inspect backup trust diagnostics.",
);
}
if (status.pendingVerifications > 0) {
nextSteps.add(`Complete ${status.pendingVerifications} pending verification request(s).`);
@@ -186,11 +254,11 @@ function printGuidance(lines: string[]): void {
function printVerificationStatus(status: MatrixCliVerificationStatus, verbose = false): void {
const backup = resolveBackupStatus(status);
const backupIssue = resolveBackupIssue(backup);
console.log(`Verified: ${status.verified ? "yes" : "no"}`);
printBackupSummary(backup);
const backupIssue = describeBackupIssue(backup);
if (backupIssue) {
console.log(`Backup issue: ${backupIssue}`);
if (backupIssue.message) {
console.log(`Backup issue: ${backupIssue.message}`);
}
if (verbose) {
console.log("Diagnostics:");

View File

@@ -924,9 +924,84 @@ describe("MatrixClient crypto bootstrapping", () => {
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
keyLoadAttempted: false,
keyLoadError: null,
});
});
it("tries loading backup keys from secret storage when key is missing from cache", async () => {
const getActiveSessionBackupVersion = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce("9");
const getSessionBackupPrivateKey = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(new Uint8Array([1]));
const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {});
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
getActiveSessionBackupVersion,
getSessionBackupPrivateKey,
loadSessionBackupPrivateKeyFromSecretStorage,
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "9",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
const backup = await client.getRoomKeyBackupStatus();
expect(backup).toMatchObject({
serverVersion: "9",
activeVersion: "9",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
keyLoadAttempted: true,
keyLoadError: null,
});
expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1);
});
it("reports why backup key loading failed during status checks", async () => {
const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {
throw new Error("secret storage key is not available");
});
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
getActiveSessionBackupVersion: vi.fn(async () => null),
getSessionBackupPrivateKey: vi.fn(async () => null),
loadSessionBackupPrivateKeyFromSecretStorage,
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "9",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: false,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
const backup = await client.getRoomKeyBackupStatus();
expect(backup.keyLoadAttempted).toBe(true);
expect(backup.keyLoadError).toContain("secret storage key is not available");
expect(backup.decryptionKeyCached).toBe(false);
});
it("restores room keys from backup after loading key from secret storage", async () => {
const getActiveSessionBackupVersion = vi
.fn()

View File

@@ -63,6 +63,8 @@ export type MatrixRoomKeyBackupStatus = {
trusted: boolean | null;
matchesDecryptionKey: boolean | null;
decryptionKeyCached: boolean | null;
keyLoadAttempted: boolean;
keyLoadError: string | null;
};
export type MatrixRoomKeyBackupRestoreResult = {
@@ -562,6 +564,8 @@ export class MatrixClient {
trusted: null,
matchesDecryptionKey: null,
decryptionKeyCached: null,
keyLoadAttempted: false,
keyLoadError: null,
};
}
@@ -574,35 +578,42 @@ export class MatrixClient {
trusted: null,
matchesDecryptionKey: null,
decryptionKeyCached: null,
keyLoadAttempted: false,
keyLoadError: null,
};
}
const [activeVersionRaw, cachedBackupKey] = await Promise.all([
this.resolveActiveRoomKeyBackupVersion(crypto),
this.resolveCachedRoomKeyBackupDecryptionKey(crypto),
]);
let serverVersion = serverVersionFallback;
let trusted: boolean | null = null;
let matchesDecryptionKey: boolean | null = null;
if (typeof crypto.getKeyBackupInfo === "function") {
const info = await crypto.getKeyBackupInfo().catch(() => null);
serverVersion = normalizeOptionalString(info?.version) ?? serverVersion;
if (info && typeof crypto.isKeyBackupTrusted === "function") {
const trustInfo = await crypto.isKeyBackupTrusted(info).catch(() => null);
trusted = typeof trustInfo?.trusted === "boolean" ? trustInfo.trusted : null;
matchesDecryptionKey =
typeof trustInfo?.matchesDecryptionKey === "boolean"
? trustInfo.matchesDecryptionKey
: null;
let { activeVersion, decryptionKeyCached } = await this.resolveRoomKeyBackupLocalState(crypto);
let { serverVersion, trusted, matchesDecryptionKey } =
await this.resolveRoomKeyBackupTrustState(crypto, serverVersionFallback);
let keyLoadAttempted = false;
let keyLoadError: string | null = null;
if (serverVersion && decryptionKeyCached === false) {
if (typeof crypto.loadSessionBackupPrivateKeyFromSecretStorage === "function") {
keyLoadAttempted = true;
try {
await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
} catch (err) {
keyLoadError = err instanceof Error ? err.message : String(err);
}
({ activeVersion, decryptionKeyCached } =
await this.resolveRoomKeyBackupLocalState(crypto));
({ serverVersion, trusted, matchesDecryptionKey } =
await this.resolveRoomKeyBackupTrustState(crypto, serverVersion));
} else {
keyLoadError =
"Matrix crypto backend does not support loading backup keys from secret storage";
}
}
return {
serverVersion,
activeVersion: activeVersionRaw,
activeVersion,
trusted,
matchesDecryptionKey,
decryptionKeyCached: cachedBackupKey,
decryptionKeyCached,
keyLoadAttempted,
keyLoadError,
};
}
@@ -912,6 +923,42 @@ export class MatrixClient {
return key ? key.length > 0 : false;
}
private async resolveRoomKeyBackupLocalState(
crypto: MatrixCryptoBootstrapApi,
): Promise<{ activeVersion: string | null; decryptionKeyCached: boolean | null }> {
const [activeVersion, decryptionKeyCached] = await Promise.all([
this.resolveActiveRoomKeyBackupVersion(crypto),
this.resolveCachedRoomKeyBackupDecryptionKey(crypto),
]);
return { activeVersion, decryptionKeyCached };
}
private async resolveRoomKeyBackupTrustState(
crypto: MatrixCryptoBootstrapApi,
fallbackVersion: string | null,
): Promise<{
serverVersion: string | null;
trusted: boolean | null;
matchesDecryptionKey: boolean | null;
}> {
let serverVersion = fallbackVersion;
let trusted: boolean | null = null;
let matchesDecryptionKey: boolean | null = null;
if (typeof crypto.getKeyBackupInfo === "function") {
const info = await crypto.getKeyBackupInfo().catch(() => null);
serverVersion = normalizeOptionalString(info?.version) ?? serverVersion;
if (info && typeof crypto.isKeyBackupTrusted === "function") {
const trustInfo = await crypto.isKeyBackupTrusted(info).catch(() => null);
trusted = typeof trustInfo?.trusted === "boolean" ? trustInfo.trusted : null;
matchesDecryptionKey =
typeof trustInfo?.matchesDecryptionKey === "boolean"
? trustInfo.matchesDecryptionKey
: null;
}
}
return { serverVersion, trusted, matchesDecryptionKey };
}
private async resolveRoomKeyBackupVersion(): Promise<string | null> {
try {
const response = (await this.doRequest("GET", "/_matrix/client/v3/room_keys/version")) as {