mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-31 10:53:31 +00:00
Matrix-js: align backup status issue and guidance
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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:");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user