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:
committed by
gsxdsm
parent
10e44eda3a
commit
1a527fa753
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user