Matrix: harden backup and recovery verification

This commit is contained in:
Gustavo Madeira Santana
2026-03-12 21:12:21 +00:00
parent e74a3cfc15
commit f8e57f839f
6 changed files with 643 additions and 192 deletions

View File

@@ -16,6 +16,7 @@ import {
restoreMatrixRoomKeyBackup,
verifyMatrixRecoveryKey,
} from "./matrix/actions/verification.js";
import { resolveMatrixRoomKeyBackupIssue } from "./matrix/backup-health.js";
import { resolveMatrixAuthContext } from "./matrix/client.js";
import { setMatrixSdkConsoleLogging, setMatrixSdkLogMode } from "./matrix/client/logging.js";
import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
@@ -400,22 +401,6 @@ function resolveBackupStatus(status: {
};
}
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";
@@ -474,77 +459,8 @@ function printVerificationGuidance(status: MatrixCliVerificationStatus, accountI
printGuidance(buildVerificationGuidance(status, accountId));
}
function resolveBackupIssue(backup: MatrixCliBackupStatus): MatrixCliBackupIssue {
if (!backup.serverVersion) {
return {
code: "missing-server-backup",
summary: "missing on server",
message: "no room-key backup exists on the homeserver",
};
}
if (backup.decryptionKeyCached === false) {
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 {
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 {
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 {
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 {
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);
const issue = resolveMatrixRoomKeyBackupIssue(backup);
console.log(`Backup: ${issue.summary}`);
if (backup.serverVersion) {
console.log(`Backup version: ${backup.serverVersion}`);
@@ -556,7 +472,7 @@ function buildVerificationGuidance(
accountId?: string,
): string[] {
const backup = resolveBackupStatus(status);
const backupIssue = resolveBackupIssue(backup);
const backupIssue = resolveMatrixRoomKeyBackupIssue(backup);
const nextSteps = new Set<string>();
if (!status.verified) {
nextSteps.add(
@@ -590,7 +506,7 @@ function buildVerificationGuidance(
);
} else if (backupIssue.code === "untrusted-signature") {
nextSteps.add(
`Backup trust chain is not verified on this device. Re-run '${formatMatrixCliCommand("verify device <key>", accountId)}'.`,
`Backup trust chain is not verified on this device. Re-run '${formatMatrixCliCommand("verify device <key>", accountId)}' if you have the correct recovery key.`,
);
nextSteps.add(
`If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'.`,
@@ -623,7 +539,7 @@ function printVerificationStatus(
): void {
console.log(`Verified by owner: ${status.verified ? "yes" : "no"}`);
const backup = resolveBackupStatus(status);
const backupIssue = resolveBackupIssue(backup);
const backupIssue = resolveMatrixRoomKeyBackupIssue(backup);
printVerificationBackupSummary(status);
if (backupIssue.message) {
console.log(`Backup issue: ${backupIssue.message}`);

View File

@@ -0,0 +1,115 @@
export type MatrixRoomKeyBackupStatusLike = {
serverVersion: string | null;
activeVersion: string | null;
trusted: boolean | null;
matchesDecryptionKey: boolean | null;
decryptionKeyCached: boolean | null;
keyLoadAttempted: boolean;
keyLoadError: string | null;
};
export type MatrixRoomKeyBackupIssueCode =
| "missing-server-backup"
| "key-load-failed"
| "key-not-loaded"
| "key-mismatch"
| "untrusted-signature"
| "inactive"
| "indeterminate"
| "ok";
export type MatrixRoomKeyBackupIssue = {
code: MatrixRoomKeyBackupIssueCode;
summary: string;
message: string | null;
};
export function resolveMatrixRoomKeyBackupIssue(
backup: MatrixRoomKeyBackupStatusLike,
): MatrixRoomKeyBackupIssue {
if (!backup.serverVersion) {
return {
code: "missing-server-backup",
summary: "missing on server",
message: "no room-key backup exists on the homeserver",
};
}
if (backup.decryptionKeyCached === false) {
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 {
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 {
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 {
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 {
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,
};
}
export function resolveMatrixRoomKeyBackupReadinessError(
backup: MatrixRoomKeyBackupStatusLike,
opts: {
requireServerBackup: boolean;
},
): string | null {
const issue = resolveMatrixRoomKeyBackupIssue(backup);
if (issue.code === "missing-server-backup") {
return opts.requireServerBackup ? "Matrix room key backup is missing on the homeserver." : null;
}
if (issue.code === "ok") {
return null;
}
if (issue.message) {
return `Matrix room key backup is not usable: ${issue.message}.`;
}
return "Matrix room key backup is not usable on this device.";
}

View File

@@ -1118,6 +1118,116 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(result.error).toContain("not verified by its owner");
});
it("fails recovery-key verification when backup remains untrusted after device verification", async () => {
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: { SSSSKEY: true },
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
})),
checkKeyBackupAndEnable: vi.fn(async () => {}),
getActiveSessionBackupVersion: vi.fn(async () => "11"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "11",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: false,
matchesDecryptionKey: true,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-untrusted-"));
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
recoveryKeyPath,
});
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(false);
expect(result.verified).toBe(true);
expect(result.error).toContain("backup signature chain is not trusted");
expect(result.recoveryKeyStored).toBe(false);
expect(fs.existsSync(recoveryKeyPath)).toBe(false);
});
it("does not overwrite the stored recovery key when recovery-key verification fails", async () => {
const previousEncoded = encodeRecoveryKey(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
);
const attemptedEncoded = encodeRecoveryKey(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 55)),
);
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {
throw new Error("secret storage rejected recovery key");
}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: { SSSSKEY: true },
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: false,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-preserve-"));
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "SSSSKEY",
encodedPrivateKey: previousEncoded,
privateKeyBase64: Buffer.from(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
).toString("base64"),
}),
"utf8",
);
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
recoveryKeyPath,
});
const result = await client.verifyWithRecoveryKey(attemptedEncoded as string);
expect(result.success).toBe(false);
expect(result.error).toContain("not verified by its owner");
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
encodedPrivateKey?: string;
};
expect(persisted.encodedPrivateKey).toBe(previousEncoded);
});
it("reports detailed room-key backup health", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
@@ -1287,13 +1397,16 @@ describe("MatrixClient crypto bootstrapping", () => {
const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {});
const checkKeyBackupAndEnable = vi.fn(async () => {});
const restoreKeyBackup = vi.fn(async () => ({ imported: 4, total: 10 }));
matrixJsClient.getCrypto = vi.fn(() => ({
const crypto = {
on: vi.fn(),
getActiveSessionBackupVersion,
loadSessionBackupPrivateKeyFromSecretStorage,
checkKeyBackupAndEnable,
restoreKeyBackup,
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getSessionBackupPrivateKey: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValue(new Uint8Array([1])),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
@@ -1303,11 +1416,13 @@ describe("MatrixClient crypto bootstrapping", () => {
trusted: true,
matchesDecryptionKey: true,
})),
}));
};
matrixJsClient.getCrypto = vi.fn(() => crypto);
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
vi.spyOn(client, "doRequest").mockResolvedValue({ version: "9" });
const result = await client.restoreRoomKeyBackup();
expect(result.success).toBe(true);
@@ -1330,10 +1445,13 @@ describe("MatrixClient crypto bootstrapping", () => {
const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {});
const checkKeyBackupAndEnable = vi.fn(async () => {});
const restoreKeyBackup = vi.fn(async () => ({ imported: 0, total: 0 }));
matrixJsClient.getCrypto = vi.fn(() => ({
const crypto = {
on: vi.fn(),
getActiveSessionBackupVersion,
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getSessionBackupPrivateKey: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValue(new Uint8Array([1])),
loadSessionBackupPrivateKeyFromSecretStorage,
checkKeyBackupAndEnable,
restoreKeyBackup,
@@ -1346,11 +1464,13 @@ describe("MatrixClient crypto bootstrapping", () => {
trusted: true,
matchesDecryptionKey: true,
})),
}));
};
matrixJsClient.getCrypto = vi.fn(() => crypto);
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
vi.spyOn(client, "doRequest").mockResolvedValue({ version: "5256" });
const result = await client.restoreRoomKeyBackup();
expect(result.success).toBe(true);
@@ -1379,14 +1499,62 @@ describe("MatrixClient crypto bootstrapping", () => {
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
vi.spyOn(client, "doRequest").mockResolvedValue({ version: "3" });
const result = await client.restoreRoomKeyBackup();
expect(result.success).toBe(false);
expect(result.error).toContain("cannot load backup keys from secret storage");
expect(result.error).toContain("backup decryption key could not be loaded from secret storage");
expect(result.backupVersion).toBe("3");
expect(result.backup.matchesDecryptionKey).toBe(false);
});
it("reloads the matching backup key before restore when the cached key mismatches", async () => {
const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {});
const restoreKeyBackup = vi.fn(async () => ({ imported: 6, total: 9 }));
const isKeyBackupTrusted = vi
.fn()
.mockResolvedValueOnce({
trusted: true,
matchesDecryptionKey: false,
})
.mockResolvedValueOnce({
trusted: true,
matchesDecryptionKey: true,
})
.mockResolvedValueOnce({
trusted: true,
matchesDecryptionKey: true,
});
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
getActiveSessionBackupVersion: vi.fn(async () => "49262"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
loadSessionBackupPrivateKeyFromSecretStorage,
checkKeyBackupAndEnable: vi.fn(async () => {}),
restoreKeyBackup,
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "49262",
})),
isKeyBackupTrusted,
}));
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("49262");
expect(result.imported).toBe(6);
expect(result.total).toBe(9);
expect(result.loadedFromSecretStorage).toBe(true);
expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1);
expect(restoreKeyBackup).toHaveBeenCalledTimes(1);
});
it("resets the current room-key backup and creates a fresh trusted version", async () => {
const checkKeyBackupAndEnable = vi.fn(async () => {});
const bootstrapSecretStorage = vi.fn(async () => {});
@@ -1575,6 +1743,17 @@ describe("MatrixClient crypto bootstrapping", () => {
crossSigningVerified: true,
signedByOwner: true,
})),
getActiveSessionBackupVersion: vi.fn(async () => "9"),
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, {
@@ -1587,6 +1766,7 @@ describe("MatrixClient crypto bootstrapping", () => {
userSigningKeyPublished: true,
published: true,
});
vi.spyOn(client, "doRequest").mockResolvedValue({ version: "9" });
const result = await client.bootstrapOwnDeviceVerification();
expect(result.success).toBe(true);
@@ -1648,6 +1828,17 @@ describe("MatrixClient crypto bootstrapping", () => {
crossSigningVerified: true,
signedByOwner: true,
})),
getActiveSessionBackupVersion: vi.fn(async () => "7"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "7",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
@@ -1695,6 +1886,17 @@ describe("MatrixClient crypto bootstrapping", () => {
crossSigningVerified: true,
signedByOwner: true,
})),
getActiveSessionBackupVersion: vi.fn(async () => "9"),
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, {
@@ -1727,6 +1929,7 @@ describe("MatrixClient crypto bootstrapping", () => {
});
it("does not report bootstrap errors when final verification state is healthy", async () => {
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 90)));
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
@@ -1747,6 +1950,17 @@ describe("MatrixClient crypto bootstrapping", () => {
crossSigningVerified: true,
signedByOwner: true,
})),
getActiveSessionBackupVersion: vi.fn(async () => "12"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "12",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
@@ -1759,9 +1973,10 @@ describe("MatrixClient crypto bootstrapping", () => {
userSigningKeyPublished: true,
published: true,
});
vi.spyOn(client, "doRequest").mockResolvedValue({ version: "12" });
const result = await client.bootstrapOwnDeviceVerification({
recoveryKey: "not-a-valid-recovery-key",
recoveryKey: encoded as string,
});
expect(result.success).toBe(true);

View File

@@ -9,6 +9,7 @@ import {
type MatrixEvent,
} from "matrix-js-sdk";
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
import { resolveMatrixRoomKeyBackupReadinessError } from "./backup-health.js";
import { createMatrixJsSdkClientLogger } from "./client/logging.js";
import { MatrixCryptoBootstrapper } from "./sdk/crypto-bootstrap.js";
import type { MatrixCryptoBootstrapResult } from "./sdk/crypto-bootstrap.js";
@@ -706,28 +707,36 @@ export class MatrixClient {
let { activeVersion, decryptionKeyCached } = await this.resolveRoomKeyBackupLocalState(crypto);
let { serverVersion, trusted, matchesDecryptionKey } =
await this.resolveRoomKeyBackupTrustState(crypto, serverVersionFallback);
const shouldLoadBackupKey =
Boolean(serverVersion) && (decryptionKeyCached === false || matchesDecryptionKey === false);
const shouldActivateBackup = Boolean(serverVersion) && !activeVersion;
let keyLoadAttempted = false;
let keyLoadError: string | null = null;
if (serverVersion && (decryptionKeyCached === false || matchesDecryptionKey === false)) {
if (
typeof crypto.loadSessionBackupPrivateKeyFromSecretStorage ===
"function" /* pragma: allowlist secret */
) {
keyLoadAttempted = true;
try {
await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); // pragma: allowlist secret
await this.enableTrustedRoomKeyBackupIfPossible(crypto);
} catch (err) {
keyLoadError = err instanceof Error ? err.message : String(err);
if (serverVersion && (shouldLoadBackupKey || shouldActivateBackup)) {
if (shouldLoadBackupKey) {
if (
typeof crypto.loadSessionBackupPrivateKeyFromSecretStorage ===
"function" /* pragma: allowlist secret */
) {
keyLoadAttempted = true;
try {
await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); // pragma: allowlist secret
} catch (err) {
keyLoadError = err instanceof Error ? err.message : String(err);
}
} else {
keyLoadError =
"Matrix crypto backend does not support loading backup keys from secret storage";
}
({ 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";
}
if (!keyLoadError) {
await this.enableTrustedRoomKeyBackupIfPossible(crypto);
}
({ activeVersion, decryptionKeyCached } = await this.resolveRoomKeyBackupLocalState(crypto));
({ serverVersion, trusted, matchesDecryptionKey } = await this.resolveRoomKeyBackupTrustState(
crypto,
serverVersion,
));
}
return {
@@ -810,41 +819,55 @@ export class MatrixClient {
return await fail("Matrix recovery key is required");
}
let defaultKeyId: string | null | undefined = undefined;
const getSecretStorageStatus = crypto.getSecretStorageStatus; // pragma: allowlist secret
if (typeof getSecretStorageStatus === "function") {
const status = await getSecretStorageStatus.call(crypto).catch(() => null); // pragma: allowlist secret
defaultKeyId = status?.defaultKeyId;
}
try {
this.recoveryKeyStore.storeEncodedRecoveryKey({
this.recoveryKeyStore.stageEncodedRecoveryKey({
encodedPrivateKey: trimmedRecoveryKey,
keyId: defaultKeyId,
keyId: await this.resolveDefaultSecretStorageKeyId(crypto),
});
} catch (err) {
return await fail(err instanceof Error ? err.message : String(err));
}
await this.cryptoBootstrapper.bootstrap(crypto, {
allowAutomaticCrossSigningReset: false,
});
await this.enableTrustedRoomKeyBackupIfPossible(crypto);
const status = await this.getOwnDeviceVerificationStatus();
if (!status.verified) {
return {
success: false,
error:
"Matrix device is still not verified by its owner after applying the recovery key. Ensure cross-signing is available and the device is signed.",
...status,
};
}
try {
await this.cryptoBootstrapper.bootstrap(crypto, {
allowAutomaticCrossSigningReset: false,
});
await this.enableTrustedRoomKeyBackupIfPossible(crypto);
const status = await this.getOwnDeviceVerificationStatus();
if (!status.verified) {
this.recoveryKeyStore.discardStagedRecoveryKey();
return {
success: false,
error:
"Matrix device is still not verified by its owner after applying the recovery key. Ensure cross-signing is available and the device is signed.",
...status,
};
}
const backupError = resolveMatrixRoomKeyBackupReadinessError(status.backup, {
requireServerBackup: false,
});
if (backupError) {
this.recoveryKeyStore.discardStagedRecoveryKey();
return {
success: false,
error: backupError,
...status,
};
}
return {
success: true,
verifiedAt: new Date().toISOString(),
...status,
};
this.recoveryKeyStore.commitStagedRecoveryKey({
keyId: await this.resolveDefaultSecretStorageKeyId(crypto),
});
const committedStatus = await this.getOwnDeviceVerificationStatus();
return {
success: true,
verifiedAt: new Date().toISOString(),
...committedStatus,
};
} catch (err) {
this.recoveryKeyStore.discardStagedRecoveryKey();
return await fail(err instanceof Error ? err.message : String(err));
}
}
async restoreRoomKeyBackup(
@@ -879,54 +902,44 @@ export class MatrixClient {
try {
const rawRecoveryKey = params.recoveryKey?.trim();
if (rawRecoveryKey) {
let defaultKeyId: string | null | undefined = undefined;
const getSecretStorageStatus = crypto.getSecretStorageStatus; // pragma: allowlist secret
if (typeof getSecretStorageStatus === "function") {
const status = await getSecretStorageStatus.call(crypto).catch(() => null); // pragma: allowlist secret
defaultKeyId = status?.defaultKeyId;
}
this.recoveryKeyStore.storeEncodedRecoveryKey({
this.recoveryKeyStore.stageEncodedRecoveryKey({
encodedPrivateKey: rawRecoveryKey,
keyId: defaultKeyId,
keyId: await this.resolveDefaultSecretStorageKeyId(crypto),
});
}
let activeVersion = await this.resolveActiveRoomKeyBackupVersion(crypto);
if (!activeVersion) {
if (
typeof crypto.loadSessionBackupPrivateKeyFromSecretStorage !==
"function" /* pragma: allowlist secret */
) {
return await fail(
"Matrix crypto backend cannot load backup keys from secret storage. Verify this device with 'openclaw matrix verify device <key>' first.",
);
}
await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); // pragma: allowlist secret
loadedFromSecretStorage = true;
await this.enableTrustedRoomKeyBackupIfPossible(crypto);
activeVersion = await this.resolveActiveRoomKeyBackupVersion(crypto);
}
if (!activeVersion) {
return await fail(
"Matrix key backup is not active on this device after loading from secret storage.",
);
const backup = await this.getRoomKeyBackupStatus();
loadedFromSecretStorage = backup.keyLoadAttempted && !backup.keyLoadError;
const backupError = resolveMatrixRoomKeyBackupReadinessError(backup, {
requireServerBackup: true,
});
if (backupError) {
this.recoveryKeyStore.discardStagedRecoveryKey();
return await fail(backupError);
}
if (typeof crypto.restoreKeyBackup !== "function") {
this.recoveryKeyStore.discardStagedRecoveryKey();
return await fail("Matrix crypto backend does not support full key backup restore");
}
const restore = await crypto.restoreKeyBackup();
const backup = await this.getRoomKeyBackupStatus();
if (rawRecoveryKey) {
this.recoveryKeyStore.commitStagedRecoveryKey({
keyId: await this.resolveDefaultSecretStorageKeyId(crypto),
});
}
const finalBackup = await this.getRoomKeyBackupStatus();
return {
success: true,
backupVersion: activeVersion,
backupVersion: backup.serverVersion,
imported: typeof restore.imported === "number" ? restore.imported : 0,
total: typeof restore.total === "number" ? restore.total : 0,
loadedFromSecretStorage,
restoredAt: new Date().toISOString(),
backup,
backup: finalBackup,
};
} catch (err) {
this.recoveryKeyStore.discardStagedRecoveryKey();
return await fail(err instanceof Error ? err.message : String(err));
}
}
@@ -1086,15 +1099,9 @@ export class MatrixClient {
const rawRecoveryKey = params?.recoveryKey?.trim();
if (rawRecoveryKey) {
let defaultKeyId: string | null | undefined = undefined;
const getSecretStorageStatus = crypto.getSecretStorageStatus; // pragma: allowlist secret
if (typeof getSecretStorageStatus === "function") {
const status = await getSecretStorageStatus.call(crypto).catch(() => null); // pragma: allowlist secret
defaultKeyId = status?.defaultKeyId;
}
this.recoveryKeyStore.storeEncodedRecoveryKey({
this.recoveryKeyStore.stageEncodedRecoveryKey({
encodedPrivateKey: rawRecoveryKey,
keyId: defaultKeyId,
keyId: await this.resolveDefaultSecretStorageKeyId(crypto),
});
}
@@ -1105,20 +1112,38 @@ export class MatrixClient {
});
await this.ensureRoomKeyBackupEnabled(crypto);
} catch (err) {
this.recoveryKeyStore.discardStagedRecoveryKey();
bootstrapError = err instanceof Error ? err.message : String(err);
}
const verification = await this.getOwnDeviceVerificationStatus();
const crossSigning = await this.getOwnCrossSigningPublicationStatus();
const success = verification.verified && crossSigning.published;
const error = success
? undefined
: (bootstrapError ??
"Matrix verification bootstrap did not produce a device verified by its owner with published cross-signing keys");
const verificationError =
verification.verified && crossSigning.published
? null
: (bootstrapError ??
"Matrix verification bootstrap did not produce a device verified by its owner with published cross-signing keys");
const backupError =
verificationError === null
? resolveMatrixRoomKeyBackupReadinessError(verification.backup, {
requireServerBackup: true,
})
: null;
const success = verificationError === null && backupError === null;
if (success) {
this.recoveryKeyStore.commitStagedRecoveryKey({
keyId: await this.resolveDefaultSecretStorageKeyId(
this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined,
),
});
} else {
this.recoveryKeyStore.discardStagedRecoveryKey();
}
const error = success ? undefined : (backupError ?? verificationError ?? undefined);
return {
success,
error,
verification,
verification: success ? await this.getOwnDeviceVerificationStatus() : verification,
crossSigning,
pendingVerifications: await pendingVerifications(),
cryptoBootstrap: bootstrapSummary,
@@ -1244,6 +1269,17 @@ export class MatrixClient {
return { serverVersion, trusted, matchesDecryptionKey };
}
private async resolveDefaultSecretStorageKeyId(
crypto: MatrixCryptoBootstrapApi | undefined,
): Promise<string | null | undefined> {
const getSecretStorageStatus = crypto?.getSecretStorageStatus; // pragma: allowlist secret
if (typeof getSecretStorageStatus !== "function") {
return undefined;
}
const status = await getSecretStorageStatus.call(crypto).catch(() => null); // pragma: allowlist secret
return status?.defaultKeyId;
}
private async resolveRoomKeyBackupVersion(): Promise<string | null> {
try {
const response = (await this.doRequest("GET", "/_matrix/client/v3/room_keys/version")) as {

View File

@@ -299,4 +299,85 @@ describe("MatrixRecoveryKeyStore", () => {
),
).toBe(true);
});
it("stages a recovery key for secret storage without persisting it until commit", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
fs.rmSync(recoveryKeyPath, { force: true });
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const encoded = encodeRecoveryKey(
new Uint8Array(Array.from({ length: 32 }, (_, i) => (i + 11) % 255)),
);
expect(encoded).toBeTypeOf("string");
store.stageEncodedRecoveryKey({
encodedPrivateKey: encoded as string,
keyId: "SSSSKEY",
});
expect(fs.existsSync(recoveryKeyPath)).toBe(false);
const callbacks = store.buildCryptoCallbacks();
const resolved = await callbacks.getSecretStorageKey?.(
{ keys: { SSSSKEY: { name: "test" } } },
"m.cross_signing.master",
);
expect(resolved?.[0]).toBe("SSSSKEY");
store.commitStagedRecoveryKey({ keyId: "SSSSKEY" });
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
keyId?: string;
encodedPrivateKey?: string;
};
expect(persisted.keyId).toBe("SSSSKEY");
expect(persisted.encodedPrivateKey).toBe(encoded);
});
it("does not overwrite the stored recovery key while a staged key is only being validated", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const storedEncoded = encodeRecoveryKey(
new Uint8Array(Array.from({ length: 32 }, (_, i) => (i + 1) % 255)),
);
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: "2026-03-12T00:00:00.000Z",
keyId: "OLD",
encodedPrivateKey: storedEncoded,
privateKeyBase64: Buffer.from(
new Uint8Array(Array.from({ length: 32 }, (_, i) => (i + 1) % 255)),
).toString("base64"),
}),
"utf8",
);
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const stagedEncoded = encodeRecoveryKey(
new Uint8Array(Array.from({ length: 32 }, (_, i) => (i + 101) % 255)),
);
store.stageEncodedRecoveryKey({
encodedPrivateKey: stagedEncoded as string,
keyId: "NEW",
});
const crypto = {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
createRecoveryKeyFromPassphrase: vi.fn(async () => {
throw new Error("should not be called");
}),
getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "NEW" })),
requestOwnUserVerification: vi.fn(async () => null),
} as unknown as MatrixCryptoBootstrapApi;
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
keyId?: string;
encodedPrivateKey?: string;
};
expect(persisted.keyId).toBe("OLD");
expect(persisted.encodedPrivateKey).toBe(storedEncoded);
});
});

View File

@@ -32,6 +32,8 @@ export class MatrixRecoveryKeyStore {
string,
{ key: Uint8Array; keyInfo?: MatrixStoredRecoveryKey["keyInfo"] }
>();
private stagedRecoveryKey: MatrixStoredRecoveryKey | null = null;
private readonly stagedCacheKeyIds = new Set<string>();
constructor(private readonly recoveryKeyPath?: string) {}
@@ -50,8 +52,24 @@ export class MatrixRecoveryKeyStore {
}
}
const staged = this.stagedRecoveryKey;
if (staged?.privateKeyBase64) {
const privateKey = new Uint8Array(Buffer.from(staged.privateKeyBase64, "base64"));
if (privateKey.length > 0) {
const stagedKeyId =
staged.keyId && requestedKeyIds.includes(staged.keyId)
? staged.keyId
: requestedKeyIds[0];
if (stagedKeyId) {
this.rememberSecretStorageKey(stagedKeyId, privateKey, staged.keyInfo);
this.stagedCacheKeyIds.add(stagedKeyId);
return [stagedKeyId, privateKey];
}
}
}
const stored = this.loadStoredRecoveryKey();
if (!stored || !stored.privateKeyBase64) {
if (!stored?.privateKeyBase64) {
return null;
}
const privateKey = new Uint8Array(Buffer.from(stored.privateKeyBase64, "base64"));
@@ -143,6 +161,69 @@ export class MatrixRecoveryKeyStore {
return this.getRecoveryKeySummary() ?? {};
}
stageEncodedRecoveryKey(params: {
encodedPrivateKey: string;
keyId?: string | null;
keyInfo?: MatrixStoredRecoveryKey["keyInfo"];
}): void {
const encodedPrivateKey = params.encodedPrivateKey.trim();
if (!encodedPrivateKey) {
throw new Error("Matrix recovery key is required");
}
let privateKey: Uint8Array;
try {
privateKey = decodeRecoveryKey(encodedPrivateKey);
} catch (err) {
throw new Error(
`Invalid Matrix recovery key: ${err instanceof Error ? err.message : String(err)}`,
);
}
const normalizedKeyId =
typeof params.keyId === "string" && params.keyId.trim() ? params.keyId.trim() : null;
this.discardStagedRecoveryKey();
this.stagedRecoveryKey = {
version: 1,
createdAt: new Date().toISOString(),
keyId: normalizedKeyId,
encodedPrivateKey,
privateKeyBase64: Buffer.from(privateKey).toString("base64"),
keyInfo: params.keyInfo ?? this.loadStoredRecoveryKey()?.keyInfo,
};
}
commitStagedRecoveryKey(params?: {
keyId?: string | null;
keyInfo?: MatrixStoredRecoveryKey["keyInfo"];
}): {
encodedPrivateKey?: string;
keyId?: string | null;
createdAt?: string;
} | null {
if (!this.stagedRecoveryKey) {
return this.getRecoveryKeySummary();
}
const staged = this.stagedRecoveryKey;
const privateKey = new Uint8Array(Buffer.from(staged.privateKeyBase64, "base64"));
const keyId =
typeof params?.keyId === "string" && params.keyId.trim() ? params.keyId.trim() : staged.keyId;
this.saveRecoveryKeyToDisk({
keyId,
keyInfo: params?.keyInfo ?? staged.keyInfo,
privateKey,
encodedPrivateKey: staged.encodedPrivateKey,
});
this.clearStagedRecoveryKeyTracking();
return this.getRecoveryKeySummary();
}
discardStagedRecoveryKey(): void {
for (const keyId of this.stagedCacheKeyIds) {
this.secretStorageKeyCache.delete(keyId);
}
this.clearStagedRecoveryKeyTracking();
}
async bootstrapSecretStorageWithRecoveryKey(
crypto: MatrixCryptoBootstrapApi,
options: {
@@ -167,18 +248,20 @@ export class MatrixRecoveryKeyStore {
);
let generatedRecoveryKey = false;
const storedRecovery = this.loadStoredRecoveryKey();
let recoveryKey: MatrixGeneratedSecretStorageKey | null = storedRecovery
const stagedRecovery = this.stagedRecoveryKey;
const sourceRecovery = stagedRecovery ?? storedRecovery;
let recoveryKey: MatrixGeneratedSecretStorageKey | null = sourceRecovery
? {
keyInfo: storedRecovery.keyInfo,
privateKey: new Uint8Array(Buffer.from(storedRecovery.privateKeyBase64, "base64")),
encodedPrivateKey: storedRecovery.encodedPrivateKey,
keyInfo: sourceRecovery.keyInfo,
privateKey: new Uint8Array(Buffer.from(sourceRecovery.privateKeyBase64, "base64")),
encodedPrivateKey: sourceRecovery.encodedPrivateKey,
}
: null;
if (recoveryKey && status?.defaultKeyId) {
const defaultKeyId = status.defaultKeyId;
this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo);
if (storedRecovery?.keyId !== defaultKeyId) {
if (!stagedRecovery && storedRecovery && storedRecovery.keyId !== defaultKeyId) {
this.saveRecoveryKeyToDisk({
keyId: defaultKeyId,
keyInfo: recoveryKey.keyInfo,
@@ -258,6 +341,11 @@ export class MatrixRecoveryKeyStore {
}
}
private clearStagedRecoveryKeyTracking(): void {
this.stagedRecoveryKey = null;
this.stagedCacheKeyIds.clear();
}
private rememberSecretStorageKey(
keyId: string,
key: Uint8Array,