diff --git a/README.md b/README.md index f5394fd..4154af4 100644 --- a/README.md +++ b/README.md @@ -460,7 +460,16 @@ An environment's age identity is resolved in exactly two ways: That is the whole mechanism behind attended vs unattended applies: **an environment whose key you never leave on disk can only be applied by someone who injects it.** Keep a standing key for staging if you like; keep prod's in a -password manager and pass it per apply. +password manager and pass it per apply, straight from the manager with a +process substitution: + +```sh +CAST_AGE_KEY_FILE_PROD=<(pm read cast-prod-key) cast apply heavy-duty/incubator --env prod … +``` + +cast reads the identity itself and hands it to age on stdin, so this works even +though `<(…)` yields a path only cast's own process can resolve — and the key +never becomes a file, never appears in argv, and never enters the environment. The state directory holds ciphertext. It must never hold the identity that opens it. diff --git a/src/secrets.ts b/src/secrets.ts index bb4ab9e..064ffcd 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -1,13 +1,20 @@ import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +// The identity is read here and handed to age on stdin (`-i -`), never as a +// path: keyFile may be a process substitution (`CAST_AGE_KEY_FILE_PROD=<(pm +// read …)` → /proc/self/fd/N), and that path resolves only inside the process +// 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. export function decryptSecrets( file: string, keyFile: string, ): Record { - const out = execFileSync("age", ["-d", "-i", keyFile, file], { + const out = execFileSync("age", ["-d", "-i", "-", file], { + input: readFileSync(keyFile), encoding: "utf8", }); const secrets: Record = {}; diff --git a/test/secrets.test.ts b/test/secrets.test.ts index 8cb5468..5dd2a0b 100644 --- a/test/secrets.test.ts +++ b/test/secrets.test.ts @@ -1,27 +1,57 @@ import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { + closeSync, + mkdirSync, + mkdtempSync, + openSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { decryptSecrets, keyFileFor, secretsFileFor } from "../src/secrets.js"; +// A key file and a store encrypted to it, for the decrypt tests. +function ageFixture(): { keyFile: string; enc: string } { + const dir = mkdtempSync(join(tmpdir(), "infra-age-")); + const keyFile = join(dir, "key.txt"); + execFileSync("age-keygen", ["-o", keyFile]); + const recipient = execFileSync("age-keygen", ["-y", keyFile], { + encoding: "utf8", + }).trim(); + const plain = join(dir, "s.env"); + writeFileSync(plain, "MAILGUN_KEY=mk-123\nOPENROUTER_KEY=or-456\n"); + const enc = join(dir, "s.env.age"); + execFileSync("age", ["-r", recipient, "-o", enc, plain]); + return { keyFile, enc }; +} + describe("decryptSecrets", () => { it("round-trips an env file through age", () => { - const dir = mkdtempSync(join(tmpdir(), "infra-age-")); - const keyFile = join(dir, "key.txt"); - execFileSync("age-keygen", ["-o", keyFile]); - const recipient = execFileSync("age-keygen", ["-y", keyFile], { - encoding: "utf8", - }).trim(); - const plain = join(dir, "s.env"); - writeFileSync(plain, "MAILGUN_KEY=mk-123\nOPENROUTER_KEY=or-456\n"); - const enc = join(dir, "s.env.age"); - execFileSync("age", ["-r", recipient, "-o", enc, plain]); + const { keyFile, enc } = ageFixture(); expect(decryptSecrets(enc, keyFile)).toEqual({ MAILGUN_KEY: "mk-123", OPENROUTER_KEY: "or-456", }); }); + + it("accepts a key path only this process can resolve — what <(pm read …) injects", () => { + // Process substitution hands cast a path like /proc/self/fd/11 that is + // meaningful only inside the process holding the fd. A spawned age does + // not hold it, so passing the path through as `-i ` can never work; + // the identity must travel to age on stdin. Opening the key here and + // pointing at our own fd reproduces exactly that shape. + const { keyFile, enc } = ageFixture(); + const fd = openSync(keyFile, "r"); + try { + expect(decryptSecrets(enc, `/proc/self/fd/${fd}`)).toEqual({ + MAILGUN_KEY: "mk-123", + OPENROUTER_KEY: "or-456", + }); + } finally { + closeSync(fd); + } + }); }); describe("secretsFileFor", () => {