mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 08:32:00 +00:00
Matrix: harden backup and recovery verification
This commit is contained in:
@@ -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}`);
|
||||
|
||||
115
extensions/matrix/src/matrix/backup-health.ts
Normal file
115
extensions/matrix/src/matrix/backup-health.ts
Normal 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.";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user