mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:41:36 +00:00
203 lines
7.1 KiB
TypeScript
203 lines
7.1 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { encodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
|
|
import type { MatrixCryptoBootstrapApi } from "./types.js";
|
|
|
|
function createTempRecoveryKeyPath(): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-recovery-key-store-"));
|
|
return path.join(dir, "recovery-key.json");
|
|
}
|
|
|
|
describe("MatrixRecoveryKeyStore", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("loads a stored recovery key for requested secret-storage keys", async () => {
|
|
const recoveryKeyPath = createTempRecoveryKeyPath();
|
|
fs.writeFileSync(
|
|
recoveryKeyPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
createdAt: new Date().toISOString(),
|
|
keyId: "SSSS",
|
|
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
|
|
}),
|
|
"utf8",
|
|
);
|
|
|
|
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
|
const callbacks = store.buildCryptoCallbacks();
|
|
const resolved = await callbacks.getSecretStorageKey?.(
|
|
{ keys: { SSSS: { name: "test" } } },
|
|
"m.cross_signing.master",
|
|
);
|
|
|
|
expect(resolved?.[0]).toBe("SSSS");
|
|
expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]);
|
|
});
|
|
|
|
it("persists cached secret-storage keys with secure file permissions", () => {
|
|
const recoveryKeyPath = createTempRecoveryKeyPath();
|
|
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
|
const callbacks = store.buildCryptoCallbacks();
|
|
|
|
callbacks.cacheSecretStorageKey?.(
|
|
"KEY123",
|
|
{
|
|
name: "openclaw",
|
|
},
|
|
new Uint8Array([9, 8, 7]),
|
|
);
|
|
|
|
const saved = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
|
|
keyId?: string;
|
|
privateKeyBase64?: string;
|
|
};
|
|
expect(saved.keyId).toBe("KEY123");
|
|
expect(saved.privateKeyBase64).toBe(Buffer.from([9, 8, 7]).toString("base64"));
|
|
|
|
const mode = fs.statSync(recoveryKeyPath).mode & 0o777;
|
|
expect(mode).toBe(0o600);
|
|
});
|
|
|
|
it("creates and persists a recovery key when secret storage is missing", async () => {
|
|
const recoveryKeyPath = createTempRecoveryKeyPath();
|
|
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
|
const generated = {
|
|
keyId: "GENERATED",
|
|
keyInfo: { name: "generated" },
|
|
privateKey: new Uint8Array([5, 6, 7, 8]),
|
|
encodedPrivateKey: "encoded-generated-key",
|
|
};
|
|
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
|
|
const bootstrapSecretStorage = vi.fn(
|
|
async (opts?: { createSecretStorageKey?: () => Promise<unknown> }) => {
|
|
await opts?.createSecretStorageKey?.();
|
|
},
|
|
);
|
|
const crypto = {
|
|
on: vi.fn(),
|
|
bootstrapCrossSigning: vi.fn(async () => {}),
|
|
bootstrapSecretStorage,
|
|
createRecoveryKeyFromPassphrase,
|
|
getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: null })),
|
|
requestOwnUserVerification: vi.fn(async () => null),
|
|
} as unknown as MatrixCryptoBootstrapApi;
|
|
|
|
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
|
|
|
|
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
|
|
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
setupNewSecretStorage: true,
|
|
}),
|
|
);
|
|
expect(store.getRecoveryKeySummary()).toMatchObject({
|
|
keyId: "GENERATED",
|
|
encodedPrivateKey: "encoded-generated-key",
|
|
});
|
|
});
|
|
|
|
it("rebinds stored recovery key to server default key id when it changes", async () => {
|
|
const recoveryKeyPath = createTempRecoveryKeyPath();
|
|
fs.writeFileSync(
|
|
recoveryKeyPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
createdAt: new Date().toISOString(),
|
|
keyId: "OLD",
|
|
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
|
|
}),
|
|
"utf8",
|
|
);
|
|
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
|
|
|
const bootstrapSecretStorage = vi.fn(async () => {});
|
|
const createRecoveryKeyFromPassphrase = vi.fn(async () => {
|
|
throw new Error("should not be called");
|
|
});
|
|
const crypto = {
|
|
on: vi.fn(),
|
|
bootstrapCrossSigning: vi.fn(async () => {}),
|
|
bootstrapSecretStorage,
|
|
createRecoveryKeyFromPassphrase,
|
|
getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "NEW" })),
|
|
requestOwnUserVerification: vi.fn(async () => null),
|
|
} as unknown as MatrixCryptoBootstrapApi;
|
|
|
|
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
|
|
|
|
expect(createRecoveryKeyFromPassphrase).not.toHaveBeenCalled();
|
|
expect(store.getRecoveryKeySummary()).toMatchObject({
|
|
keyId: "NEW",
|
|
});
|
|
});
|
|
|
|
it("recreates secret storage when default key exists but is not usable locally", async () => {
|
|
const recoveryKeyPath = createTempRecoveryKeyPath();
|
|
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
|
const generated = {
|
|
keyId: "RECOVERED",
|
|
keyInfo: { name: "recovered" },
|
|
privateKey: new Uint8Array([1, 1, 2, 3]),
|
|
encodedPrivateKey: "encoded-recovered-key",
|
|
};
|
|
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
|
|
const bootstrapSecretStorage = vi.fn(
|
|
async (opts?: { createSecretStorageKey?: () => Promise<unknown> }) => {
|
|
await opts?.createSecretStorageKey?.();
|
|
},
|
|
);
|
|
const crypto = {
|
|
on: vi.fn(),
|
|
bootstrapCrossSigning: vi.fn(async () => {}),
|
|
bootstrapSecretStorage,
|
|
createRecoveryKeyFromPassphrase,
|
|
getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: "LEGACY" })),
|
|
requestOwnUserVerification: vi.fn(async () => null),
|
|
} as unknown as MatrixCryptoBootstrapApi;
|
|
|
|
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
|
|
|
|
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
|
|
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
setupNewSecretStorage: true,
|
|
}),
|
|
);
|
|
expect(store.getRecoveryKeySummary()).toMatchObject({
|
|
keyId: "RECOVERED",
|
|
encodedPrivateKey: "encoded-recovered-key",
|
|
});
|
|
});
|
|
|
|
it("stores an encoded recovery key and decodes its private key material", () => {
|
|
const recoveryKeyPath = createTempRecoveryKeyPath();
|
|
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
|
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
|
|
expect(encoded).toBeTypeOf("string");
|
|
|
|
const summary = store.storeEncodedRecoveryKey({
|
|
encodedPrivateKey: encoded as string,
|
|
keyId: "SSSSKEY",
|
|
});
|
|
|
|
expect(summary.keyId).toBe("SSSSKEY");
|
|
expect(summary.encodedPrivateKey).toBe(encoded);
|
|
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
|
|
privateKeyBase64?: string;
|
|
keyId?: string;
|
|
};
|
|
expect(persisted.keyId).toBe("SSSSKEY");
|
|
expect(
|
|
Buffer.from(persisted.privateKeyBase64 ?? "", "base64").equals(
|
|
Buffer.from(Array.from({ length: 32 }, (_, i) => i + 1)),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
});
|