diff --git a/backend/src/workspaces/secret-store.ts b/backend/src/workspaces/secret-store.ts new file mode 100644 index 00000000..a33a21e8 --- /dev/null +++ b/backend/src/workspaces/secret-store.ts @@ -0,0 +1,311 @@ +import { + chmodSync, + closeSync, + constants, + fchmodSync, + fsyncSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { basename, join } from "node:path"; + +const STORE_ERROR = "Workspace secret store is unavailable."; +const DEFAULT_MAX_SECRET_BYTES = 64 * 1024; +const ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/; + +interface EncryptedEntry { + workspaceId: string; + requirementId: string; + iv: string; + tag: string; + ciphertext: string; +} + +interface VaultDocument { + version: 1; + generation: number; + entries: Record; +} + +export interface WorkspaceSecretMaterialization { + files: ReadonlyMap; + release(): void; +} + +export interface WorkspaceSecretStoreOptions { + root: string; + runtimeRoot?: string; + installationId: string; + maxSecretBytes?: number; +} + +function assertIdentifier(value: string, label: string): void { + if (!ID_PATTERN.test(value)) throw new Error(`Invalid ${label}.`); +} + +function entryKey(workspaceId: string, requirementId: string): string { + return Buffer.from(`${workspaceId}\0${requirementId}`, "utf8").toString("base64url"); +} + +function directorySync(path: string): void { + mkdirSync(path, { recursive: true, mode: 0o700 }); + chmodSync(path, 0o700); +} + +function syncDirectory(path: string): void { + const fd = openSync(path, constants.O_RDONLY); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +function atomicPrivateWrite(path: string, contents: string | Buffer): void { + const parent = join(path, ".."); + const temporary = join(parent, `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}`); + const fd = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); + try { + fchmodSync(fd, 0o600); + writeFileSync(fd, contents); + fsyncSync(fd); + } finally { + closeSync(fd); + } + renameSync(temporary, path); + chmodSync(path, 0o600); + syncDirectory(parent); +} + +function emptyVault(): VaultDocument { + return { version: 1, generation: 0, entries: {} }; +} + +export class WorkspaceSecretStore { + private readonly root: string; + private readonly runtimeRoot: string; + private readonly installationId: string; + private readonly maxSecretBytes: number; + private readonly keyPath: string; + private readonly vaultPath: string; + + constructor(options: WorkspaceSecretStoreOptions) { + if (!options.installationId.trim()) throw new Error("Installation identifier is required."); + this.root = options.root; + this.runtimeRoot = options.runtimeRoot ?? join(options.root, "runtime"); + this.installationId = options.installationId; + this.maxSecretBytes = options.maxSecretBytes ?? DEFAULT_MAX_SECRET_BYTES; + this.keyPath = join(this.root, "master.key"); + this.vaultPath = join(this.root, "vault.json"); + directorySync(this.root); + directorySync(this.runtimeRoot); + this.ensureInitialized(); + } + + configured(workspaceId: string): string[] { + assertIdentifier(workspaceId, "workspace identifier"); + const vault = this.readVault(); + return Object.values(vault.entries) + .filter((entry) => entry.workspaceId === workspaceId) + .map((entry) => entry.requirementId) + .sort(); + } + + has(workspaceId: string, requirementId: string): boolean { + this.assertIds(workspaceId, requirementId); + return this.readVault().entries[entryKey(workspaceId, requirementId)] !== undefined; + } + + generation(workspaceId: string): number { + assertIdentifier(workspaceId, "workspace identifier"); + const vault = this.readVault(); + return Object.values(vault.entries).some((entry) => entry.workspaceId === workspaceId) + ? vault.generation + : 0; + } + + put(workspaceId: string, requirementId: string, value: string): void { + this.putMany(workspaceId, { [requirementId]: value }); + } + + putMany(workspaceId: string, values: Readonly>): void { + assertIdentifier(workspaceId, "workspace identifier"); + const items = Object.entries(values); + if (items.length === 0) throw new Error("At least one workspace secret is required."); + for (const [requirementId, value] of items) { + assertIdentifier(requirementId, "secret requirement identifier"); + const size = Buffer.byteLength(value, "utf8"); + if (size === 0) throw new Error("Workspace secrets cannot be empty."); + if (size > this.maxSecretBytes) throw new Error("Workspace secret exceeds the size limit."); + } + + const vault = this.readVault(); + const key = this.readKey(); + for (const [requirementId, value] of items) { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + cipher.setAAD(this.additionalData(workspaceId, requirementId)); + const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + vault.entries[entryKey(workspaceId, requirementId)] = { + workspaceId, + requirementId, + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + ciphertext: ciphertext.toString("base64"), + }; + } + vault.generation += 1; + this.writeVault(vault); + } + + forget(workspaceId: string, requirementId: string): void { + this.assertIds(workspaceId, requirementId); + const vault = this.readVault(); + const key = entryKey(workspaceId, requirementId); + if (vault.entries[key] === undefined) return; + delete vault.entries[key]; + vault.generation += 1; + this.writeVault(vault); + } + + materialize( + workspaceId: string, + requirementIds: readonly string[], + ): WorkspaceSecretMaterialization { + assertIdentifier(workspaceId, "workspace identifier"); + for (const requirementId of requirementIds) { + assertIdentifier(requirementId, "secret requirement identifier"); + } + + let directory: string | undefined; + try { + const vault = this.readVault(); + const key = this.readKey(); + directory = mkdtempSync(join(this.runtimeRoot, "lease-")); + chmodSync(directory, 0o700); + const files = new Map(); + for (const requirementId of [...new Set(requirementIds)]) { + const entry = vault.entries[entryKey(workspaceId, requirementId)]; + if (entry === undefined) continue; + if (entry.workspaceId !== workspaceId || entry.requirementId !== requirementId) { + throw new Error(STORE_ERROR); + } + const decipher = createDecipheriv( + "aes-256-gcm", + key, + Buffer.from(entry.iv, "base64"), + ); + decipher.setAAD(this.additionalData(workspaceId, requirementId)); + decipher.setAuthTag(Buffer.from(entry.tag, "base64")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(entry.ciphertext, "base64")), + decipher.final(), + ]); + const path = join(directory, randomBytes(16).toString("hex")); + const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o400); + try { + fchmodSync(fd, 0o400); + writeFileSync(fd, plaintext); + fsyncSync(fd); + } finally { + plaintext.fill(0); + closeSync(fd); + } + files.set(requirementId, path); + } + let released = false; + const leasedDirectory = directory; + return { + files, + release: () => { + if (released) return; + released = true; + rmSync(leasedDirectory, { recursive: true, force: true }); + }, + }; + } catch { + if (directory !== undefined) rmSync(directory, { recursive: true, force: true }); + throw new Error(STORE_ERROR); + } + } + + private assertIds(workspaceId: string, requirementId: string): void { + assertIdentifier(workspaceId, "workspace identifier"); + assertIdentifier(requirementId, "secret requirement identifier"); + } + + private additionalData(workspaceId: string, requirementId: string): Buffer { + return Buffer.from(`${this.installationId}\0${workspaceId}\0${requirementId}`, "utf8"); + } + + private ensureInitialized(): void { + try { + readFileSync(this.keyPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") throw new Error(STORE_ERROR); + try { + atomicPrivateWrite(this.keyPath, randomBytes(32)); + } catch (writeError) { + if ((writeError as NodeJS.ErrnoException).code !== "EEXIST") throw new Error(STORE_ERROR); + } + } + try { + readFileSync(this.vaultPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") throw new Error(STORE_ERROR); + atomicPrivateWrite(this.vaultPath, `${JSON.stringify(emptyVault())}\n`); + } + this.readKey(); + this.readVault(); + } + + private readKey(): Buffer { + try { + const key = readFileSync(this.keyPath); + if (key.length !== 32) throw new Error(STORE_ERROR); + chmodSync(this.keyPath, 0o600); + return key; + } catch { + throw new Error(STORE_ERROR); + } + } + + private readVault(): VaultDocument { + try { + const parsed = JSON.parse(readFileSync(this.vaultPath, "utf8")) as Partial; + if (parsed.version !== 1 || !Number.isSafeInteger(parsed.generation) || + parsed.generation! < 0 || typeof parsed.entries !== "object" || parsed.entries === null) { + throw new Error(STORE_ERROR); + } + for (const [key, entry] of Object.entries(parsed.entries)) { + if (entry === null || typeof entry !== "object" || + typeof entry.workspaceId !== "string" || typeof entry.requirementId !== "string" || + typeof entry.iv !== "string" || typeof entry.tag !== "string" || + typeof entry.ciphertext !== "string" || + key !== entryKey(entry.workspaceId, entry.requirementId)) { + throw new Error(STORE_ERROR); + } + } + chmodSync(this.vaultPath, 0o600); + return parsed as VaultDocument; + } catch { + throw new Error(STORE_ERROR); + } + } + + private writeVault(vault: VaultDocument): void { + try { + atomicPrivateWrite(this.vaultPath, `${JSON.stringify(vault)}\n`); + } catch { + throw new Error(STORE_ERROR); + } + } +} diff --git a/backend/test/workspace-secret-store.test.ts b/backend/test/workspace-secret-store.test.ts new file mode 100644 index 00000000..46d96e70 --- /dev/null +++ b/backend/test/workspace-secret-store.test.ts @@ -0,0 +1,112 @@ +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js"; + +const roots: string[] = []; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "thoth-workspace-secret-store-")); + const runtimeRoot = mkdtempSync(join(tmpdir(), "thoth-workspace-secret-runtime-")); + roots.push(root, runtimeRoot); + return { + root, + runtimeRoot, + store: new WorkspaceSecretStore({ + root, + runtimeRoot, + installationId: "installation-test", + }), + }; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("WorkspaceSecretStore", () => { + test("persists ciphertext and exposes status without exposing plaintext", () => { + const { root, store } = fixture(); + const secret = "correct horse battery staple"; + + store.put("psd-clinical", "dwh.password", secret); + + expect(store.has("psd-clinical", "dwh.password")).toBe(true); + expect(store.configured("psd-clinical")).toEqual(["dwh.password"]); + const vault = readFileSync(join(root, "vault.json"), "utf8"); + expect(vault).not.toContain(secret); + expect(statSync(join(root, "vault.json")).mode & 0o777).toBe(0o600); + expect(statSync(join(root, "master.key")).mode & 0o777).toBe(0o600); + }); + + test("blind replacement changes the materialized value and forget removes it", () => { + const { store } = fixture(); + store.put("psd-clinical", "dwh.password", "old-value"); + store.put("psd-clinical", "dwh.password", "new-value"); + + const lease = store.materialize("psd-clinical", ["dwh.password"]); + const path = lease.files.get("dwh.password"); + expect(path).toBeDefined(); + expect(readFileSync(path!, "utf8")).toBe("new-value"); + expect(statSync(path!).mode & 0o777).toBe(0o400); + lease.release(); + expect(existsSync(path!)).toBe(false); + + store.forget("psd-clinical", "dwh.password"); + expect(store.has("psd-clinical", "dwh.password")).toBe(false); + }); + + test("materializes only requested secrets and cleans the whole lease directory", () => { + const { runtimeRoot, store } = fixture(); + store.putMany("psd-clinical", { + "dwh.password": "warehouse-password", + "evidence.api_key": "evidence-key", + }); + + const lease = store.materialize("psd-clinical", ["evidence.api_key"]); + expect([...lease.files.keys()]).toEqual(["evidence.api_key"]); + expect(readFileSync(lease.files.get("evidence.api_key")!, "utf8")).toBe("evidence-key"); + expect(statSync(runtimeRoot).mode & 0o777).toBe(0o700); + const directory = join(lease.files.get("evidence.api_key")!, ".."); + lease.release(); + expect(existsSync(directory)).toBe(false); + }); + + test("fails closed with a sanitized error when the encrypted vault is tampered", () => { + const { root, store } = fixture(); + const secret = "must-never-appear-in-errors"; + store.put("psd-clinical", "dwh.password", secret); + + const path = join(root, "vault.json"); + const document = JSON.parse(readFileSync(path, "utf8")) as { + entries: Record; + }; + const record = Object.values(document.entries)[0]!; + record.ciphertext = Buffer.from("tampered").toString("base64"); + writeFileSync(path, JSON.stringify(document), { mode: 0o600 }); + + expect(() => store.materialize("psd-clinical", ["dwh.password"])) + .toThrow("Workspace secret store is unavailable."); + try { + store.materialize("psd-clinical", ["dwh.password"]); + } catch (error) { + expect(String(error)).not.toContain(secret); + } + }); + + test("rejects invalid identifiers and oversized values", () => { + const { store } = fixture(); + expect(() => store.put("../workspace", "dwh.password", "secret")).toThrow(); + expect(() => store.put("psd-clinical", "../password", "secret")).toThrow(); + expect(() => store.put("psd-clinical", "dwh.password", "x".repeat(65_537))).toThrow(); + }); +});