diff --git a/src/secrets.ts b/src/secrets.ts index 064ffcd..6556d6f 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -9,12 +9,30 @@ import { join } from "node:path"; // holding the fd — this one. A freshly spawned age has no such fd and fails // with ENOENT. Not `-i /dev/stdin` either: node closes the pipe before age // re-opens it by path (ENXIO); `-` makes age read the inherited fd directly. +// +// And it is read exactly ONCE per process: a process substitution is a +// read-once pipe, but `diff --all` / `apply --all` call decryptSecrets once +// per project. The first read drains the pipe; a re-read would hand age an +// empty identity and the second project's decrypt would fail. Caching by key +// path changes nothing about exposure — the key already transits this +// process's memory on every call. +const identities = new Map(); + +function readIdentity(keyFile: string): Buffer { + let identity = identities.get(keyFile); + if (identity === undefined) { + identity = readFileSync(keyFile); + identities.set(keyFile, identity); + } + return identity; +} + export function decryptSecrets( file: string, keyFile: string, ): Record { const out = execFileSync("age", ["-d", "-i", "-", file], { - input: readFileSync(keyFile), + input: readIdentity(keyFile), encoding: "utf8", }); const secrets: Record = {}; diff --git a/test/secrets.test.ts b/test/secrets.test.ts index e6136cf..a436552 100644 --- a/test/secrets.test.ts +++ b/test/secrets.test.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { closeSync, mkdirSync, @@ -63,6 +63,38 @@ describe("secretsFileFor", () => { }); }); +// Pins the read-once shape of `<(pm read …)` that the fd-path test above +// cannot: a regular file behind an fd path re-opens at offset 0 on every +// read, but a pipe drains. `diff --all` / `apply --all` decrypt once per +// project, so the identity must be read once per process and reused. +describe("decryptSecrets identity caching", () => { + it("a read-once pipe key survives two decrypts — the --all loop shape", () => { + const { keyFile, enc } = ageFixture(); + const dir = mkdtempSync(join(tmpdir(), "infra-fifo-")); + const fifo = join(dir, "key.fifo"); + execFileSync("mkfifo", [fifo]); + // One writer, one serving of the key: exactly what a process substitution + // delivers. It pairs with the first decrypt's open and exits. + const once = spawn("sh", ["-c", `cat "${keyFile}" > "${fifo}"`], { + stdio: "ignore", + }); + const expected = { MAILGUN_KEY: "mk-123", OPENROUTER_KEY: "or-456" }; + expect(decryptSecrets(enc, fifo)).toEqual(expected); + // The pipe is now drained. A second writer serves nothing, so if the + // per-process cache ever regresses, the re-read hands age an empty + // identity and fails loudly instead of blocking the suite on a + // writerless FIFO open. With the cache, nobody opens the FIFO again and + // the writer is still blocked in open() when we kill it. + const drained = spawn("sh", ["-c", `: > "${fifo}"`], { stdio: "ignore" }); + try { + expect(decryptSecrets(enc, fifo)).toEqual(expected); + } finally { + once.kill("SIGKILL"); + drained.kill("SIGKILL"); + } + }); +}); + describe("keyFileFor", () => { it("an env with no injected var and no standing key refuses, naming both ways in", () => { Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_PROD");