115 lines
4.8 KiB
JavaScript
115 lines
4.8 KiB
JavaScript
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { createDesktopVaultKeyProvider } from "./secure-vault-key.mjs";
|
|
|
|
/**
|
|
* @param {Partial<import("electron").SafeStorage>} overrides
|
|
* @param {string} marker fake OS keychain secret; payloads sealed with a different marker fail to decrypt
|
|
* @returns {import("electron").SafeStorage}
|
|
*/
|
|
function fakeSafeStorage(overrides = {}, marker = "sealed") {
|
|
return /** @type {import("electron").SafeStorage} */ ({
|
|
decryptString: () => { throw new Error("sync safe storage is not used"); },
|
|
encryptString: () => { throw new Error("sync safe storage is not used"); },
|
|
isAsyncEncryptionAvailable: async () => true,
|
|
isEncryptionAvailable: () => true,
|
|
setUsePlainTextEncryption: () => {},
|
|
getSelectedStorageBackend: () => "gnome_libsecret",
|
|
encryptStringAsync: async (plaintext) => Buffer.from(`${marker}:${Buffer.from(plaintext).toString("hex")}`),
|
|
decryptStringAsync: async (encrypted) => {
|
|
const payload = encrypted.toString();
|
|
if (!payload.startsWith(`${marker}:`)) {
|
|
throw new Error("safe storage cannot decrypt this payload");
|
|
}
|
|
return {
|
|
result: Buffer.from(payload.slice(`${marker}:`.length), "hex").toString(),
|
|
shouldReEncrypt: false,
|
|
};
|
|
},
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {string} filePath
|
|
*/
|
|
async function backupSiblings(filePath) {
|
|
const prefix = `${path.basename(filePath)}.openwork-backup-`;
|
|
return (await readdir(path.dirname(filePath))).filter((name) => name.startsWith(prefix));
|
|
}
|
|
|
|
describe("desktop managed MCP vault key", () => {
|
|
it("persists only an OS-protected blob and restores the same key", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "openwork-vault-key-"));
|
|
const filePath = path.join(root, "vault-key.bin");
|
|
try {
|
|
const safeStorage = fakeSafeStorage();
|
|
const firstProvider = createDesktopVaultKeyProvider({ filePath, loadSafeStorage: () => safeStorage });
|
|
const first = await firstProvider();
|
|
assert.equal(first.byteLength, 32);
|
|
assert.deepEqual(await firstProvider(), first);
|
|
|
|
const protectedBlob = await readFile(filePath);
|
|
assert.equal(protectedBlob.includes(first.toString("base64")), false);
|
|
if (process.platform !== "win32") {
|
|
assert.equal((await stat(filePath)).mode & 0o777, 0o600);
|
|
}
|
|
|
|
const restartedProvider = createDesktopVaultKeyProvider({ filePath, loadSafeStorage: () => safeStorage });
|
|
assert.deepEqual(await restartedProvider(), first);
|
|
assert.deepEqual(await backupSiblings(filePath), []);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("quarantines a blob the OS keychain can no longer decrypt and mints a fresh key", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "openwork-vault-key-"));
|
|
const filePath = path.join(root, "vault-key.bin");
|
|
try {
|
|
const oldKey = await createDesktopVaultKeyProvider({ filePath, loadSafeStorage: () => fakeSafeStorage() })();
|
|
const originalBlob = await readFile(filePath);
|
|
|
|
const rotatedStorage = fakeSafeStorage({}, "resealed");
|
|
const providerB = createDesktopVaultKeyProvider({ filePath, loadSafeStorage: () => rotatedStorage });
|
|
const newKey = await providerB();
|
|
assert.equal(newKey.byteLength, 32);
|
|
assert.notDeepEqual(newKey, oldKey);
|
|
|
|
const backups = await backupSiblings(filePath);
|
|
assert.equal(backups.length, 1);
|
|
assert.deepEqual(await readFile(path.join(root, backups[0])), originalBlob);
|
|
|
|
const providerC = createDesktopVaultKeyProvider({ filePath, loadSafeStorage: () => rotatedStorage });
|
|
assert.deepEqual(await providerC(), newKey);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects Electron's insecure Linux basic-text backend", async () => {
|
|
const filePath = path.join(os.tmpdir(), "unused-openwork-vault-key.bin");
|
|
const provider = createDesktopVaultKeyProvider({
|
|
filePath,
|
|
loadSafeStorage: () => fakeSafeStorage({ getSelectedStorageBackend: () => "basic_text" }),
|
|
platform: "linux",
|
|
});
|
|
await assert.rejects(provider(), /secure Linux password store/);
|
|
assert.deepEqual(await backupSiblings(filePath), []);
|
|
});
|
|
|
|
it("fails closed when OS secure storage is unavailable", async () => {
|
|
const filePath = path.join(os.tmpdir(), "unused-openwork-vault-key.bin");
|
|
const provider = createDesktopVaultKeyProvider({
|
|
filePath,
|
|
loadSafeStorage: () => fakeSafeStorage({ isAsyncEncryptionAvailable: async () => false }),
|
|
});
|
|
await assert.rejects(provider(), /secure storage is unavailable/);
|
|
assert.deepEqual(await backupSiblings(filePath), []);
|
|
});
|
|
});
|