Matrix-js: add backup restore and status guidance

This commit is contained in:
Gustavo Madeira Santana
2026-02-25 15:48:46 -05:00
parent 5598535aa9
commit 341d949632
9 changed files with 642 additions and 39 deletions

View File

@@ -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",

View File

@@ -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");
});
});

View File

@@ -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();

View File

@@ -20,11 +20,13 @@ export {
confirmMatrixVerificationSas,
generateMatrixVerificationQr,
getMatrixEncryptionStatus,
getMatrixRoomKeyBackupStatus,
getMatrixVerificationStatus,
getMatrixVerificationSas,
listMatrixVerifications,
mismatchMatrixVerificationSas,
requestMatrixVerification,
restoreMatrixRoomKeyBackup,
scanMatrixVerificationQr,
startMatrixVerification,
verifyMatrixRecoveryKey,

View File

@@ -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;

View File

@@ -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");

View File

@@ -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;
}

View File

@@ -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>;

View File

@@ -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 });