fix(FN-4789): harden rotation semantics for active backend

Fusion-Task-Id: FN-4789
Fusion-Task-Lineage: cef0d12e-556e-43d8-9ce0-e4962c2d091b
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 19:26:57 -07:00
committed by gsxdsm
parent 10e44eda3a
commit 1a527fa753
2 changed files with 215 additions and 62 deletions

View File

@@ -1,75 +1,223 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync } from "node:fs";
import { stat, rm } from "node:fs/promises";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import * as fs from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { MasterKeyManager, MASTER_KEY_FILENAME, type KeytarLike } from "../master-key.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
function createTempDir(): string {
return mkdtempSync(join(tmpdir(), "fn-master-key-test-"));
}
vi.mock("keytar", () => {
throw new Error("MODULE_NOT_FOUND");
});
function createKeytarStub(initial?: string): KeytarLike {
let value = initial ?? null;
import {
MASTER_KEY_FILENAME,
MasterKeyCorruptError,
MasterKeyManager,
MasterKeyPermissionError,
type KeytarLike,
} from "../master-key.js";
type MutableKeytar = KeytarLike & { writes: number; stored: string | null };
function createKeytar(initial?: Buffer): MutableKeytar {
let stored = initial ? initial.toString("base64") : null;
return {
async getPassword() {
return value;
writes: 0,
get stored() {
return stored;
},
async setPassword(_service, _account, password) {
value = password;
set stored(v: string | null) {
stored = v;
},
async getPassword() {
return stored;
},
async setPassword(_s, _a, value) {
stored = value;
this.writes += 1;
},
async deletePassword() {
value = null;
stored = null;
return true;
},
};
}
describe("MasterKeyManager", () => {
const tempDirs: string[] = [];
let globalDir: string;
afterEach(async () => {
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })));
tempDirs.length = 0;
beforeEach(() => {
globalDir = mkdtempSync(join(tmpdir(), "fn-master-key-test-"));
});
it("creates and reuses a key in keychain backend", async () => {
const globalDir = createTempDir();
tempDirs.push(globalDir);
const manager = new MasterKeyManager({ globalDir, keytarModule: createKeytarStub() });
const first = await manager.getOrCreateKey();
const second = await manager.getOrCreateKey();
expect(first.byteLength).toBe(32);
expect(second.equals(first)).toBe(true);
expect(await manager.getBackend()).toBe("keychain");
afterEach(() => {
rmSync(globalDir, { recursive: true, force: true });
});
it("falls back to file backend when keychain is unavailable", async () => {
const globalDir = createTempDir();
tempDirs.push(globalDir);
it("uses keychain on first run", async () => {
const keytar = createKeytar();
const manager = new MasterKeyManager({ globalDir, keytarModule: keytar });
const key = await manager.getOrCreateKey();
expect(key).toHaveLength(32);
await expect(fs.stat(join(globalDir, MASTER_KEY_FILENAME))).rejects.toMatchObject({ code: "ENOENT" });
await expect(manager.getBackend()).resolves.toBe("keychain");
});
it("falls back to file when keychain unavailable", async () => {
const failing: KeytarLike = {
async getPassword() {
throw new Error("keychain unavailable");
},
async setPassword() {
throw new Error("keychain unavailable");
},
async deletePassword() {
throw new Error("keychain unavailable");
},
};
const manager = new MasterKeyManager({ globalDir, keytarModule: failing });
const key = await manager.getOrCreateKey();
expect(key).toHaveLength(32);
const keyPath = join(globalDir, MASTER_KEY_FILENAME);
const fileStat = await fs.stat(keyPath);
expect(fileStat.size).toBe(32);
expect(fileStat.mode & 0o777).toBe(0o600);
await expect(manager.getBackend()).resolves.toBe("file");
});
it("is idempotent on keychain", async () => {
const original = Buffer.alloc(32, 7);
const keytar = createKeytar(original);
const manager = new MasterKeyManager({ globalDir, keytarModule: keytar });
const a = await manager.getOrCreateKey();
const b = await manager.getOrCreateKey();
expect(a.equals(original)).toBe(true);
expect(b.equals(original)).toBe(true);
expect(keytar.writes).toBe(0);
});
it("is idempotent on file backend", async () => {
const failing = {
async getPassword() {
throw new Error("unavailable");
},
async setPassword() {
throw new Error("unavailable");
},
async deletePassword() {
throw new Error("unavailable");
},
} satisfies KeytarLike;
const manager = new MasterKeyManager({ globalDir, keytarModule: failing });
const a = await manager.getOrCreateKey();
const b = await manager.getOrCreateKey();
expect(a.equals(b)).toBe(true);
});
it("handles race by returning externally written keychain value", async () => {
const external = Buffer.alloc(32, 9).toString("base64");
let stored: string | null = null;
const keytar: KeytarLike = {
async getPassword() {
return stored;
},
async setPassword() {
stored = external;
throw new Error("write lost race");
},
async deletePassword() {
return true;
},
};
const manager = new MasterKeyManager({ globalDir, keytarModule: keytar });
const key = await manager.getOrCreateKey();
expect(key.equals(Buffer.from(external, "base64"))).toBe(true);
});
it("throws on corrupt keychain entry", async () => {
const keytar = createKeytar();
keytar.stored = Buffer.alloc(10).toString("base64");
const manager = new MasterKeyManager({ globalDir, keytarModule: keytar });
await expect(manager.getOrCreateKey()).rejects.toBeInstanceOf(MasterKeyCorruptError);
});
it("throws on corrupt file entry", async () => {
writeFileSync(join(globalDir, MASTER_KEY_FILENAME), Buffer.alloc(10));
const manager = new MasterKeyManager({ globalDir });
await expect(manager.getOrCreateKey()).rejects.toBeInstanceOf(MasterKeyCorruptError);
});
it("throws when file permission verification fails", async () => {
const failing: KeytarLike = {
async getPassword() {
throw new Error("no keychain");
},
async setPassword() {
throw new Error("no keychain");
},
async deletePassword() {
return true;
},
};
const manager = new MasterKeyManager({
globalDir,
keytarModule: {
getPassword: async () => {
throw new Error("keychain unavailable");
},
setPassword: async () => {
throw new Error("keychain unavailable");
},
deletePassword: async () => {
throw new Error("keychain unavailable");
},
keytarModule: failing,
fsModule: {
...fs,
stat: async (path) => ({ ...(await fs.stat(path)), mode: 0o644 }),
},
});
const key = await manager.getOrCreateKey();
const fileStat = await stat(join(globalDir, MASTER_KEY_FILENAME));
await expect(manager.getOrCreateKey()).rejects.toBeInstanceOf(MasterKeyPermissionError);
});
expect(key.byteLength).toBe(32);
expect(await manager.getBackend()).toBe("file");
expect(fileStat.size).toBe(32);
expect(fileStat.mode & 0o777).toBe(0o600);
it("rotates key and persists to active backend", async () => {
const keytar = createKeytar();
const manager = new MasterKeyManager({ globalDir, keytarModule: keytar });
const before = await manager.getOrCreateKey();
const rotated = await manager.rotateKey();
const after = await manager.getOrCreateKey();
expect(rotated.equals(before)).toBe(false);
expect(after.equals(rotated)).toBe(true);
await expect(manager.getBackend()).resolves.toBe("keychain");
});
it("throws if active keychain backend cannot be updated during rotation", async () => {
const original = Buffer.alloc(32, 5).toString("base64");
const keytar: KeytarLike = {
async getPassword() {
return original;
},
async setPassword() {
throw new Error("keychain unavailable");
},
async deletePassword() {
return false;
},
};
const manager = new MasterKeyManager({ globalDir, keytarModule: keytar });
await expect(manager.rotateKey()).rejects.toThrow(
"unable to rotate master key in active keychain backend",
);
});
it("falls back to file when keytar import is missing", async () => {
const manager = new MasterKeyManager({ globalDir });
const key = await manager.getOrCreateKey();
expect(key).toHaveLength(32);
await expect(fs.stat(join(globalDir, MASTER_KEY_FILENAME))).resolves.toBeTruthy();
await expect(manager.getBackend()).resolves.toBe("file");
});
});

View File

@@ -55,8 +55,8 @@ export class MasterKeyManager {
const generated = randomBytes(32);
const persisted = await this.persistNewKeyWithRaceHandling(generated);
console.info(`master key created (${await this.getBackend()})`);
return persisted;
console.info(`master key created (${persisted.backend})`);
return persisted.key;
}
async rotateKey(): Promise<Buffer> {
@@ -69,15 +69,18 @@ export class MasterKeyManager {
return next;
}
const wroteKeychain = await this.writeKeychainKey(next);
if (wroteKeychain) {
if (backend === "keychain") {
const wroteKeychain = await this.writeKeychainKey(next);
if (!wroteKeychain) {
throw new Error("unable to rotate master key in active keychain backend");
}
console.info("master key rotated (keychain)");
return next;
}
await this.writeFileKey(next, { overwrite: true });
console.info("master key rotated (file)");
return next;
const persisted = await this.persistNewKeyWithRaceHandling(next);
console.info(`master key rotated (${persisted.backend})`);
return persisted.key;
}
async getBackend(): Promise<"keychain" | "file" | "missing"> {
@@ -94,12 +97,14 @@ export class MasterKeyManager {
return "missing";
}
private async persistNewKeyWithRaceHandling(generated: Buffer): Promise<Buffer> {
private async persistNewKeyWithRaceHandling(
generated: Buffer,
): Promise<{ key: Buffer; backend: "keychain" | "file" }> {
const keytar = await this.loadKeytar();
if (keytar) {
const raced = await this.readKeychainKey();
if (raced) {
return raced;
return { key: raced, backend: "keychain" };
}
try {
await keytar.setPassword(
@@ -107,11 +112,11 @@ export class MasterKeyManager {
MASTER_KEY_KEYCHAIN_ACCOUNT,
generated.toString("base64"),
);
return generated;
return { key: generated, backend: "keychain" };
} catch {
const afterRace = await this.readKeychainKey();
if (afterRace) {
return afterRace;
return { key: afterRace, backend: "keychain" };
}
console.warn("master key keychain unavailable; using file backend");
}
@@ -119,19 +124,19 @@ export class MasterKeyManager {
const racedFile = await this.readFileKey();
if (racedFile) {
return racedFile;
return { key: racedFile, backend: "file" };
}
try {
await this.writeFileKey(generated, { overwrite: false });
return generated;
return { key: generated, backend: "file" };
} catch (error) {
if (error instanceof MasterKeyPermissionError) {
throw error;
}
const afterRace = await this.readFileKey();
if (afterRace) {
return afterRace;
return { key: afterRace, backend: "file" };
}
throw new Error("failed to persist master key", { cause: error });
}