mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-31 13:53:31 +00:00
Matrix-js: add backup restore and status guidance
This commit is contained in:
@@ -206,6 +206,8 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
"verification-status": "verificationStatus",
|
||||
"verification-bootstrap": "verificationBootstrap",
|
||||
"verification-recovery-key": "verificationRecoveryKey",
|
||||
"verification-backup-status": "verificationBackupStatus",
|
||||
"verification-backup-restore": "verificationBackupRestore",
|
||||
"verification-list": "verificationList",
|
||||
"verification-request": "verificationRequest",
|
||||
"verification-accept": "verificationAccept",
|
||||
|
||||
@@ -3,12 +3,16 @@ import { formatZonedTimestamp } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const bootstrapMatrixVerificationMock = vi.fn();
|
||||
const getMatrixRoomKeyBackupStatusMock = vi.fn();
|
||||
const getMatrixVerificationStatusMock = vi.fn();
|
||||
const restoreMatrixRoomKeyBackupMock = vi.fn();
|
||||
const verifyMatrixRecoveryKeyMock = vi.fn();
|
||||
|
||||
vi.mock("./matrix/actions/verification.js", () => ({
|
||||
bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args),
|
||||
getMatrixRoomKeyBackupStatus: (...args: unknown[]) => getMatrixRoomKeyBackupStatusMock(...args),
|
||||
getMatrixVerificationStatus: (...args: unknown[]) => getMatrixVerificationStatusMock(...args),
|
||||
restoreMatrixRoomKeyBackup: (...args: unknown[]) => restoreMatrixRoomKeyBackupMock(...args),
|
||||
verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args),
|
||||
}));
|
||||
|
||||
@@ -69,6 +73,31 @@ describe("matrix-js CLI verification commands", () => {
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("sets non-zero exit code for backup restore failures in JSON mode", async () => {
|
||||
restoreMatrixRoomKeyBackupMock.mockResolvedValue({
|
||||
success: false,
|
||||
error: "missing backup key",
|
||||
backupVersion: null,
|
||||
imported: 0,
|
||||
total: 0,
|
||||
loadedFromSecretStorage: false,
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: null,
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
decryptionKeyCached: false,
|
||||
},
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix-js", "verify", "backup", "restore", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps zero exit code for successful bootstrap in JSON mode", async () => {
|
||||
process.exitCode = 0;
|
||||
bootstrapMatrixVerificationMock.mockResolvedValue({
|
||||
@@ -88,10 +117,18 @@ describe("matrix-js CLI verification commands", () => {
|
||||
it("prints local timezone timestamps for verify status output", async () => {
|
||||
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "1",
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: "1",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: recoveryCreatedAt,
|
||||
pendingVerifications: 0,
|
||||
@@ -111,10 +148,24 @@ describe("matrix-js CLI verification commands", () => {
|
||||
bootstrapMatrixVerificationMock.mockResolvedValue({
|
||||
success: true,
|
||||
verification: {
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "1",
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: "1",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyId: "SSSS",
|
||||
recoveryKeyCreatedAt: recoveryCreatedAt,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
},
|
||||
crossSigning: {
|
||||
published: true,
|
||||
@@ -127,9 +178,23 @@ describe("matrix-js CLI verification commands", () => {
|
||||
});
|
||||
verifyMatrixRecoveryKeyMock.mockResolvedValue({
|
||||
success: true,
|
||||
encryptionEnabled: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "1",
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: "1",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
},
|
||||
verified: true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyId: "SSSS",
|
||||
recoveryKeyCreatedAt: recoveryCreatedAt,
|
||||
verifiedAt,
|
||||
});
|
||||
@@ -145,4 +210,23 @@ describe("matrix-js CLI verification commands", () => {
|
||||
`Verified at: ${formatExpectedLocalTimestamp(verifiedAt)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("prints backup health lines for verify backup status", async () => {
|
||||
getMatrixRoomKeyBackupStatusMock.mockResolvedValue({
|
||||
serverVersion: "2",
|
||||
activeVersion: null,
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
decryptionKeyCached: false,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix-js", "verify", "backup", "status"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith("Backup server version: 2");
|
||||
expect(console.log).toHaveBeenCalledWith("Backup active on this device: no");
|
||||
expect(console.log).toHaveBeenCalledWith("Backup trusted by this device: yes");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { Command } from "commander";
|
||||
import { formatZonedTimestamp } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
bootstrapMatrixVerification,
|
||||
getMatrixRoomKeyBackupStatus,
|
||||
getMatrixVerificationStatus,
|
||||
restoreMatrixRoomKeyBackup,
|
||||
verifyMatrixRecoveryKey,
|
||||
} from "./matrix/actions/verification.js";
|
||||
|
||||
@@ -41,15 +43,97 @@ function printTimestamp(label: string, value: string | null | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
function printVerificationStatus(status: {
|
||||
type MatrixCliBackupStatus = {
|
||||
serverVersion: string | null;
|
||||
activeVersion: string | null;
|
||||
trusted: boolean | null;
|
||||
matchesDecryptionKey: boolean | null;
|
||||
decryptionKeyCached: boolean | null;
|
||||
};
|
||||
|
||||
type MatrixCliVerificationStatus = {
|
||||
encryptionEnabled: boolean;
|
||||
verified: boolean;
|
||||
userId: string | null;
|
||||
deviceId: string | null;
|
||||
backupVersion: string | null;
|
||||
backup?: MatrixCliBackupStatus;
|
||||
recoveryKeyStored: boolean;
|
||||
recoveryKeyCreatedAt: string | null;
|
||||
pendingVerifications: number;
|
||||
}): void {
|
||||
};
|
||||
|
||||
function resolveBackupStatus(status: {
|
||||
backupVersion: string | null;
|
||||
backup?: MatrixCliBackupStatus;
|
||||
}): MatrixCliBackupStatus {
|
||||
return {
|
||||
serverVersion: status.backup?.serverVersion ?? status.backupVersion ?? null,
|
||||
activeVersion: status.backup?.activeVersion ?? null,
|
||||
trusted: status.backup?.trusted ?? null,
|
||||
matchesDecryptionKey: status.backup?.matchesDecryptionKey ?? null,
|
||||
decryptionKeyCached: status.backup?.decryptionKeyCached ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function yesNoUnknown(value: boolean | null): string {
|
||||
if (value === true) {
|
||||
return "yes";
|
||||
}
|
||||
if (value === false) {
|
||||
return "no";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function printBackupStatus(backup: MatrixCliBackupStatus): void {
|
||||
console.log(`Backup server version: ${backup.serverVersion ?? "none"}`);
|
||||
console.log(`Backup active on this device: ${backup.activeVersion ?? "no"}`);
|
||||
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)}`);
|
||||
}
|
||||
|
||||
function buildVerificationGuidance(status: MatrixCliVerificationStatus): string[] {
|
||||
const backup = resolveBackupStatus(status);
|
||||
const nextSteps = new Set<string>();
|
||||
if (!status.verified) {
|
||||
nextSteps.add("Run 'openclaw matrix-js verify device <key>' to verify this device.");
|
||||
}
|
||||
if (!backup.serverVersion) {
|
||||
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) {
|
||||
if (status.recoveryKeyStored) {
|
||||
nextSteps.add(
|
||||
"Run 'openclaw matrix-js verify backup restore' to load the backup key 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'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (status.pendingVerifications > 0) {
|
||||
nextSteps.add(`Complete ${status.pendingVerifications} pending verification request(s).`);
|
||||
}
|
||||
return Array.from(nextSteps);
|
||||
}
|
||||
|
||||
function printGuidance(lines: string[]): void {
|
||||
if (lines.length === 0) {
|
||||
return;
|
||||
}
|
||||
console.log("Next steps:");
|
||||
for (const line of lines) {
|
||||
console.log(`- ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printVerificationStatus(status: MatrixCliVerificationStatus): void {
|
||||
if (status.verified) {
|
||||
console.log("Verified: yes");
|
||||
console.log(`User: ${status.userId ?? "unknown"}`);
|
||||
@@ -58,12 +142,12 @@ function printVerificationStatus(status: {
|
||||
console.log("Verified: no");
|
||||
console.log(`User: ${status.userId ?? "unknown"}`);
|
||||
console.log(`Device: ${status.deviceId ?? "unknown"}`);
|
||||
console.log("Run 'openclaw matrix-js verify device <key>' to verify this device.");
|
||||
}
|
||||
console.log(`Backup version: ${status.backupVersion ?? "none"}`);
|
||||
printBackupStatus(resolveBackupStatus(status));
|
||||
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
|
||||
printTimestamp("Recovery key created at", status.recoveryKeyCreatedAt);
|
||||
console.log(`Pending verifications: ${status.pendingVerifications}`);
|
||||
printGuidance(buildVerificationGuidance(status));
|
||||
}
|
||||
|
||||
export function registerMatrixJsCli(params: { program: Command }): void {
|
||||
@@ -104,6 +188,80 @@ export function registerMatrixJsCli(params: { program: Command }): void {
|
||||
}
|
||||
});
|
||||
|
||||
const backup = verify.command("backup").description("Matrix room-key backup health and restore");
|
||||
|
||||
backup
|
||||
.command("status")
|
||||
.description("Show Matrix room-key backup status for this device")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (options: { account?: string; json?: boolean }) => {
|
||||
try {
|
||||
const status = await getMatrixRoomKeyBackupStatus({ accountId: options.account });
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
}
|
||||
printBackupStatus(status);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(`Backup status failed: ${message}`);
|
||||
}
|
||||
markCliFailure();
|
||||
} finally {
|
||||
scheduleMatrixJsCliExit();
|
||||
}
|
||||
});
|
||||
|
||||
backup
|
||||
.command("restore")
|
||||
.description("Restore encrypted room keys from server backup")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--recovery-key <key>", "Optional recovery key to load before restoring")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (options: { account?: string; recoveryKey?: string; json?: boolean }) => {
|
||||
try {
|
||||
const result = await restoreMatrixRoomKeyBackup({
|
||||
accountId: options.account,
|
||||
recoveryKey: options.recoveryKey,
|
||||
});
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.success) {
|
||||
markCliFailure();
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log(`Restore success: ${result.success ? "yes" : "no"}`);
|
||||
if (result.error) {
|
||||
console.log(`Error: ${result.error}`);
|
||||
}
|
||||
console.log(`Backup version: ${result.backupVersion ?? "none"}`);
|
||||
console.log(`Imported keys: ${result.imported}/${result.total}`);
|
||||
console.log(
|
||||
`Loaded key from secret storage: ${result.loadedFromSecretStorage ? "yes" : "no"}`,
|
||||
);
|
||||
printTimestamp("Restored at", result.restoredAt);
|
||||
printBackupStatus(result.backup);
|
||||
if (!result.success) {
|
||||
markCliFailure();
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ success: false, error: message }, null, 2));
|
||||
} else {
|
||||
console.error(`Backup restore failed: ${message}`);
|
||||
}
|
||||
markCliFailure();
|
||||
} finally {
|
||||
scheduleMatrixJsCliExit();
|
||||
}
|
||||
});
|
||||
|
||||
verify
|
||||
.command("bootstrap")
|
||||
.description("Bootstrap Matrix-js cross-signing and device verification state")
|
||||
@@ -141,9 +299,15 @@ export function registerMatrixJsCli(params: { program: Command }): void {
|
||||
console.log(
|
||||
`Cross-signing published: ${result.crossSigning.published ? "yes" : "no"} (master=${result.crossSigning.masterKeyPublished ? "yes" : "no"}, self=${result.crossSigning.selfSigningKeyPublished ? "yes" : "no"}, user=${result.crossSigning.userSigningKeyPublished ? "yes" : "no"})`,
|
||||
);
|
||||
console.log(`Backup version: ${result.verification.backupVersion ?? "none"}`);
|
||||
printBackupStatus(resolveBackupStatus(result.verification));
|
||||
printTimestamp("Recovery key created at", result.verification.recoveryKeyCreatedAt);
|
||||
console.log(`Pending verifications: ${result.pendingVerifications}`);
|
||||
printGuidance(
|
||||
buildVerificationGuidance({
|
||||
...result.verification,
|
||||
pendingVerifications: result.pendingVerifications,
|
||||
}),
|
||||
);
|
||||
if (!result.success) {
|
||||
markCliFailure();
|
||||
}
|
||||
@@ -178,9 +342,15 @@ export function registerMatrixJsCli(params: { program: Command }): void {
|
||||
console.log("Device verification completed successfully.");
|
||||
console.log(`User: ${result.userId ?? "unknown"}`);
|
||||
console.log(`Device: ${result.deviceId ?? "unknown"}`);
|
||||
console.log(`Backup version: ${result.backupVersion ?? "none"}`);
|
||||
printBackupStatus(resolveBackupStatus(result));
|
||||
printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt);
|
||||
printTimestamp("Verified at", result.verifiedAt);
|
||||
printGuidance(
|
||||
buildVerificationGuidance({
|
||||
...result,
|
||||
pendingVerifications: 0,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.error(`Verification failed: ${result.error ?? "unknown error"}`);
|
||||
markCliFailure();
|
||||
|
||||
@@ -20,11 +20,13 @@ export {
|
||||
confirmMatrixVerificationSas,
|
||||
generateMatrixVerificationQr,
|
||||
getMatrixEncryptionStatus,
|
||||
getMatrixRoomKeyBackupStatus,
|
||||
getMatrixVerificationStatus,
|
||||
getMatrixVerificationSas,
|
||||
listMatrixVerifications,
|
||||
mismatchMatrixVerificationSas,
|
||||
requestMatrixVerification,
|
||||
restoreMatrixRoomKeyBackup,
|
||||
scanMatrixVerificationQr,
|
||||
startMatrixVerification,
|
||||
verifyMatrixRecoveryKey,
|
||||
|
||||
@@ -246,6 +246,17 @@ export async function getMatrixVerificationStatus(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMatrixRoomKeyBackupStatus(opts: MatrixActionClientOpts = {}) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
return await client.getRoomKeyBackupStatus();
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyMatrixRecoveryKey(
|
||||
recoveryKey: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
@@ -260,6 +271,23 @@ export async function verifyMatrixRecoveryKey(
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreMatrixRoomKeyBackup(
|
||||
opts: MatrixActionClientOpts & {
|
||||
recoveryKey?: string;
|
||||
} = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
return await client.restoreRoomKeyBackup({
|
||||
recoveryKey: opts.recoveryKey?.trim() || undefined,
|
||||
});
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function bootstrapMatrixVerification(
|
||||
opts: MatrixActionClientOpts & {
|
||||
recoveryKey?: string;
|
||||
|
||||
@@ -876,6 +876,112 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
expect(bootstrapCrossSigning).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports detailed room-key backup health", async () => {
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
getActiveSessionBackupVersion: vi.fn(async () => "11"),
|
||||
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1, 2, 3])),
|
||||
getKeyBackupInfo: vi.fn(async () => ({
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
auth_data: {},
|
||||
version: "11",
|
||||
})),
|
||||
isKeyBackupTrusted: vi.fn(async () => ({
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
})),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
});
|
||||
vi.spyOn(client, "doRequest").mockResolvedValue({ version: "11" });
|
||||
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
expect(status.backupVersion).toBe("11");
|
||||
expect(status.backup).toEqual({
|
||||
serverVersion: "11",
|
||||
activeVersion: "11",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("restores room keys from backup after loading key from secret storage", async () => {
|
||||
const getActiveSessionBackupVersion = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce("9")
|
||||
.mockResolvedValue("9");
|
||||
const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {});
|
||||
const restoreKeyBackup = vi.fn(async () => ({ imported: 4, total: 10 }));
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
getActiveSessionBackupVersion,
|
||||
loadSessionBackupPrivateKeyFromSecretStorage,
|
||||
restoreKeyBackup,
|
||||
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
|
||||
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 result = await client.restoreRoomKeyBackup();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.backupVersion).toBe("9");
|
||||
expect(result.imported).toBe(4);
|
||||
expect(result.total).toBe(10);
|
||||
expect(result.loadedFromSecretStorage).toBe(true);
|
||||
expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1);
|
||||
expect(restoreKeyBackup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails restore when backup key cannot be loaded on this device", async () => {
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
getActiveSessionBackupVersion: vi.fn(async () => null),
|
||||
getSessionBackupPrivateKey: vi.fn(async () => null),
|
||||
getKeyBackupInfo: vi.fn(async () => ({
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
auth_data: {},
|
||||
version: "3",
|
||||
})),
|
||||
isKeyBackupTrusted: vi.fn(async () => ({
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
});
|
||||
|
||||
const result = await client.restoreRoomKeyBackup();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("cannot load backup keys from secret storage");
|
||||
expect(result.backupVersion).toBe("3");
|
||||
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");
|
||||
|
||||
@@ -53,6 +53,26 @@ export type MatrixOwnDeviceVerificationStatus = {
|
||||
recoveryKeyCreatedAt: string | null;
|
||||
recoveryKeyId: string | null;
|
||||
backupVersion: string | null;
|
||||
backup: MatrixRoomKeyBackupStatus;
|
||||
};
|
||||
|
||||
export type MatrixRoomKeyBackupStatus = {
|
||||
serverVersion: string | null;
|
||||
activeVersion: string | null;
|
||||
trusted: boolean | null;
|
||||
matchesDecryptionKey: boolean | null;
|
||||
decryptionKeyCached: boolean | null;
|
||||
};
|
||||
|
||||
export type MatrixRoomKeyBackupRestoreResult = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
backupVersion: string | null;
|
||||
imported: number;
|
||||
total: number;
|
||||
loadedFromSecretStorage: boolean;
|
||||
restoredAt?: string;
|
||||
backup: MatrixRoomKeyBackupStatus;
|
||||
};
|
||||
|
||||
export type MatrixRecoveryKeyVerificationResult = MatrixOwnDeviceVerificationStatus & {
|
||||
@@ -89,6 +109,11 @@ function isMatrixDeviceVerified(
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | null | undefined): string | null {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
export class MatrixClient {
|
||||
private readonly client: MatrixJsClient;
|
||||
private readonly emitter = new EventEmitter();
|
||||
@@ -521,10 +546,63 @@ export class MatrixClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getRoomKeyBackupStatus(): Promise<MatrixRoomKeyBackupStatus> {
|
||||
if (!this.encryptionEnabled) {
|
||||
return {
|
||||
serverVersion: null,
|
||||
activeVersion: null,
|
||||
trusted: null,
|
||||
matchesDecryptionKey: null,
|
||||
decryptionKeyCached: null,
|
||||
};
|
||||
}
|
||||
|
||||
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
|
||||
const serverVersionFallback = await this.resolveRoomKeyBackupVersion();
|
||||
if (!crypto) {
|
||||
return {
|
||||
serverVersion: serverVersionFallback,
|
||||
activeVersion: null,
|
||||
trusted: null,
|
||||
matchesDecryptionKey: null,
|
||||
decryptionKeyCached: 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;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
serverVersion,
|
||||
activeVersion: activeVersionRaw,
|
||||
trusted,
|
||||
matchesDecryptionKey,
|
||||
decryptionKeyCached: cachedBackupKey,
|
||||
};
|
||||
}
|
||||
|
||||
async getOwnDeviceVerificationStatus(): Promise<MatrixOwnDeviceVerificationStatus> {
|
||||
const recoveryKey = this.recoveryKeyStore.getRecoveryKeySummary();
|
||||
const userId = this.client.getUserId() ?? this.selfUserId ?? null;
|
||||
const deviceId = this.client.getDeviceId()?.trim() || null;
|
||||
const backup = await this.getRoomKeyBackupStatus();
|
||||
|
||||
if (!this.encryptionEnabled) {
|
||||
return {
|
||||
@@ -538,7 +616,8 @@ export class MatrixClient {
|
||||
recoveryKeyStored: Boolean(recoveryKey),
|
||||
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
|
||||
recoveryKeyId: recoveryKey?.keyId ?? null,
|
||||
backupVersion: null,
|
||||
backupVersion: backup.serverVersion,
|
||||
backup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -559,47 +638,32 @@ export class MatrixClient {
|
||||
recoveryKeyStored: Boolean(recoveryKey),
|
||||
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
|
||||
recoveryKeyId: recoveryKey?.keyId ?? null,
|
||||
backupVersion: await this.resolveRoomKeyBackupVersion(),
|
||||
backupVersion: backup.serverVersion,
|
||||
backup,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyWithRecoveryKey(
|
||||
rawRecoveryKey: string,
|
||||
): Promise<MatrixRecoveryKeyVerificationResult> {
|
||||
const fail = async (error: string): Promise<MatrixRecoveryKeyVerificationResult> => ({
|
||||
success: false,
|
||||
error,
|
||||
...(await this.getOwnDeviceVerificationStatus()),
|
||||
});
|
||||
|
||||
if (!this.encryptionEnabled) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Matrix encryption is disabled for this client",
|
||||
encryptionEnabled: false,
|
||||
userId: this.client.getUserId() ?? this.selfUserId ?? null,
|
||||
deviceId: this.client.getDeviceId()?.trim() || null,
|
||||
verified: false,
|
||||
localVerified: false,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
recoveryKeyStored: false,
|
||||
recoveryKeyCreatedAt: null,
|
||||
recoveryKeyId: null,
|
||||
backupVersion: null,
|
||||
};
|
||||
return await fail("Matrix encryption is disabled for this client");
|
||||
}
|
||||
|
||||
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
|
||||
if (!crypto) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Matrix crypto is not available (start client with encryption enabled)",
|
||||
...(await this.getOwnDeviceVerificationStatus()),
|
||||
};
|
||||
return await fail("Matrix crypto is not available (start client with encryption enabled)");
|
||||
}
|
||||
|
||||
const trimmedRecoveryKey = rawRecoveryKey.trim();
|
||||
if (!trimmedRecoveryKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Matrix recovery key is required",
|
||||
...(await this.getOwnDeviceVerificationStatus()),
|
||||
};
|
||||
return await fail("Matrix recovery key is required");
|
||||
}
|
||||
|
||||
let defaultKeyId: string | null | undefined = undefined;
|
||||
@@ -614,11 +678,7 @@ export class MatrixClient {
|
||||
keyId: defaultKeyId,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
...(await this.getOwnDeviceVerificationStatus()),
|
||||
};
|
||||
return await fail(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
await this.cryptoBootstrapper.bootstrap(crypto);
|
||||
@@ -639,6 +699,85 @@ export class MatrixClient {
|
||||
};
|
||||
}
|
||||
|
||||
async restoreRoomKeyBackup(
|
||||
params: {
|
||||
recoveryKey?: string;
|
||||
} = {},
|
||||
): Promise<MatrixRoomKeyBackupRestoreResult> {
|
||||
let loadedFromSecretStorage = false;
|
||||
const fail = async (error: string): Promise<MatrixRoomKeyBackupRestoreResult> => {
|
||||
const backup = await this.getRoomKeyBackupStatus();
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
backupVersion: backup.serverVersion,
|
||||
imported: 0,
|
||||
total: 0,
|
||||
loadedFromSecretStorage,
|
||||
backup,
|
||||
};
|
||||
};
|
||||
|
||||
if (!this.encryptionEnabled) {
|
||||
return await fail("Matrix encryption is disabled for this client");
|
||||
}
|
||||
|
||||
await this.initializeCryptoIfNeeded();
|
||||
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
|
||||
if (!crypto) {
|
||||
return await fail("Matrix crypto is not available (start client with encryption enabled)");
|
||||
}
|
||||
|
||||
try {
|
||||
const rawRecoveryKey = params.recoveryKey?.trim();
|
||||
if (rawRecoveryKey) {
|
||||
let defaultKeyId: string | null | undefined = undefined;
|
||||
if (typeof crypto.getSecretStorageStatus === "function") {
|
||||
const status = await crypto.getSecretStorageStatus().catch(() => null);
|
||||
defaultKeyId = status?.defaultKeyId;
|
||||
}
|
||||
this.recoveryKeyStore.storeEncodedRecoveryKey({
|
||||
encodedPrivateKey: rawRecoveryKey,
|
||||
keyId: defaultKeyId,
|
||||
});
|
||||
}
|
||||
|
||||
let activeVersion = await this.resolveActiveRoomKeyBackupVersion(crypto);
|
||||
if (!activeVersion) {
|
||||
if (typeof crypto.loadSessionBackupPrivateKeyFromSecretStorage !== "function") {
|
||||
return await fail(
|
||||
"Matrix crypto backend cannot load backup keys from secret storage. Verify this device with 'openclaw matrix-js verify device <key>' first.",
|
||||
);
|
||||
}
|
||||
await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
|
||||
loadedFromSecretStorage = true;
|
||||
activeVersion = await this.resolveActiveRoomKeyBackupVersion(crypto);
|
||||
}
|
||||
if (!activeVersion) {
|
||||
return await fail(
|
||||
"Matrix key backup is not active on this device after loading from secret storage.",
|
||||
);
|
||||
}
|
||||
if (typeof crypto.restoreKeyBackup !== "function") {
|
||||
return await fail("Matrix crypto backend does not support full key backup restore");
|
||||
}
|
||||
|
||||
const restore = await crypto.restoreKeyBackup();
|
||||
const backup = await this.getRoomKeyBackupStatus();
|
||||
return {
|
||||
success: true,
|
||||
backupVersion: activeVersion,
|
||||
imported: typeof restore.imported === "number" ? restore.imported : 0,
|
||||
total: typeof restore.total === "number" ? restore.total : 0,
|
||||
loadedFromSecretStorage,
|
||||
restoredAt: 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) {
|
||||
@@ -745,12 +884,32 @@ export class MatrixClient {
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveActiveRoomKeyBackupVersion(
|
||||
crypto: MatrixCryptoBootstrapApi,
|
||||
): Promise<string | null> {
|
||||
if (typeof crypto.getActiveSessionBackupVersion !== "function") {
|
||||
return null;
|
||||
}
|
||||
const version = await crypto.getActiveSessionBackupVersion().catch(() => null);
|
||||
return normalizeOptionalString(version);
|
||||
}
|
||||
|
||||
private async resolveCachedRoomKeyBackupDecryptionKey(
|
||||
crypto: MatrixCryptoBootstrapApi,
|
||||
): Promise<boolean | null> {
|
||||
if (typeof crypto.getSessionBackupPrivateKey !== "function") {
|
||||
return null;
|
||||
}
|
||||
const key = await crypto.getSessionBackupPrivateKey().catch(() => null);
|
||||
return key ? key.length > 0 : false;
|
||||
}
|
||||
|
||||
private async resolveRoomKeyBackupVersion(): Promise<string | null> {
|
||||
try {
|
||||
const response = (await this.doRequest("GET", "/_matrix/client/v3/room_keys/version")) as {
|
||||
version?: string;
|
||||
};
|
||||
return response.version?.trim() || null;
|
||||
return normalizeOptionalString(response.version);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,31 @@ export type MatrixDeviceVerificationStatusLike = {
|
||||
signedByOwner?: boolean;
|
||||
};
|
||||
|
||||
export type MatrixKeyBackupInfo = {
|
||||
algorithm: string;
|
||||
auth_data: Record<string, unknown>;
|
||||
count?: number;
|
||||
etag?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export type MatrixKeyBackupTrustInfo = {
|
||||
trusted: boolean;
|
||||
matchesDecryptionKey: boolean;
|
||||
};
|
||||
|
||||
export type MatrixRoomKeyBackupRestoreResult = {
|
||||
total: number;
|
||||
imported: number;
|
||||
};
|
||||
|
||||
export type MatrixImportRoomKeyProgress = {
|
||||
stage: string;
|
||||
successes?: number;
|
||||
failures?: number;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export type MatrixSecretStorageKeyDescription = {
|
||||
passphrase?: unknown;
|
||||
name?: string;
|
||||
@@ -176,6 +201,15 @@ export type MatrixCryptoBootstrapApi = {
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
) => Promise<MatrixDeviceVerificationStatusLike | null>;
|
||||
getSessionBackupPrivateKey?: () => Promise<Uint8Array | null>;
|
||||
loadSessionBackupPrivateKeyFromSecretStorage?: () => Promise<void>;
|
||||
getActiveSessionBackupVersion?: () => Promise<string | null>;
|
||||
getKeyBackupInfo?: () => Promise<MatrixKeyBackupInfo | null>;
|
||||
isKeyBackupTrusted?: (info: MatrixKeyBackupInfo) => Promise<MatrixKeyBackupTrustInfo>;
|
||||
checkKeyBackupAndEnable?: () => Promise<unknown>;
|
||||
restoreKeyBackup?: (opts?: {
|
||||
progressCallback?: (progress: MatrixImportRoomKeyProgress) => void;
|
||||
}) => Promise<MatrixRoomKeyBackupRestoreResult>;
|
||||
setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise<void>;
|
||||
crossSignDevice?: (deviceId: string) => Promise<void>;
|
||||
isCrossSigningReady?: () => Promise<boolean>;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
editMatrixMessage,
|
||||
generateMatrixVerificationQr,
|
||||
getMatrixEncryptionStatus,
|
||||
getMatrixRoomKeyBackupStatus,
|
||||
getMatrixVerificationStatus,
|
||||
getMatrixMemberInfo,
|
||||
getMatrixRoomInfo,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
pinMatrixMessage,
|
||||
readMatrixMessages,
|
||||
requestMatrixVerification,
|
||||
restoreMatrixRoomKeyBackup,
|
||||
removeMatrixReactions,
|
||||
scanMatrixVerificationQr,
|
||||
sendMatrixMessage,
|
||||
@@ -56,6 +58,8 @@ const verificationActions = new Set([
|
||||
"verificationStatus",
|
||||
"verificationBootstrap",
|
||||
"verificationRecoveryKey",
|
||||
"verificationBackupStatus",
|
||||
"verificationBackupRestore",
|
||||
]);
|
||||
|
||||
function readRoomId(params: Record<string, unknown>, required = true): string {
|
||||
@@ -234,6 +238,20 @@ export async function handleMatrixAction(
|
||||
);
|
||||
return jsonResult({ ok: result.success, result });
|
||||
}
|
||||
if (action === "verificationBackupStatus") {
|
||||
const status = await getMatrixRoomKeyBackupStatus({ accountId });
|
||||
return jsonResult({ ok: true, status });
|
||||
}
|
||||
if (action === "verificationBackupRestore") {
|
||||
const recoveryKey =
|
||||
readStringParam(params, "recoveryKey", { trim: false }) ??
|
||||
readStringParam(params, "key", { trim: false });
|
||||
const result = await restoreMatrixRoomKeyBackup({
|
||||
recoveryKey: recoveryKey ?? undefined,
|
||||
accountId,
|
||||
});
|
||||
return jsonResult({ ok: result.success, result });
|
||||
}
|
||||
if (action === "verificationList") {
|
||||
const verifications = await listMatrixVerifications({ accountId });
|
||||
return jsonResult({ ok: true, verifications });
|
||||
|
||||
Reference in New Issue
Block a user