The documented prod-key recipe (CAST_AGE_KEY_FILE_PROD=<(pm read …)) cannot work: age resolves the fd path in its own process #34

Closed
opened 2026-07-13 22:34:10 +00:00 by dan-claude-bot · 0 comments
dan-claude-bot commented 2026-07-13 22:34:10 +00:00 (Migrated from github.com)

Symptom

Hit live during the incubator prod migration (Task 7's --full diff gate against the legacy box):

$ CAST_AGE_KEY_FILE_PROD=<(bw get notes <id>) cast diff heavy-duty/incubator --env prod --instance box-b --full …
age: error: reading "/proc/self/fd/11": failed to open file: open /proc/self/fd/11: no such file or directory
Command failed: age -d -i /proc/self/fd/11 secrets/incubator.prod.env.age

Why it can never work

src/secrets.ts:10:

const out = execFileSync("age", ["-d", "-i", keyFile, file], { encoding: "utf8" });

The shell's <(…) expands to /proc/self/fd/11 (zsh) or /dev/fd/63 (bash). That path is meaningful only inside the process holding that fd — the shell, and then node, which inherits it. cast passes the string to a freshly-spawned age, which resolves /proc/self/fd/N against its own fd table, where N is not open. So it fails with ENOENT, for every password manager, on every shell.

Reproduced minimally:

// node passing a process-substitution path straight to age — what cast does today
execFileSync("age", ["-d", "-i", "/dev/fd/63", "s.age"])
// age: error: reading "/dev/fd/63": failed to open file: no such file or directory

This matters because CAST_AGE_KEY_FILE_PROD=<(pm-cli read …) is the documented way to inject the prod key, and the incubator project's runbook, STATE, and operator punch list all carry it as the attendance mechanism for a key that must never touch disk. The recipe is unusable as written, and the workaround people will reach for is a plaintext key file on disk — the exact thing the design forbids.

Fix (verified)

node can resolve /proc/self/fd/N, because node owns that fd. So read the identity in cast and hand it to age on stdin, which age accepts as -i -:

const key = readFileSync(keyFile);                       // resolves /proc/self/fd/N correctly
const out = execFileSync("age", ["-d", "-i", "-", file], // "-" = identity from stdin
  { input: key, encoding: "utf8" });

Verified end-to-end, including through a real process substitution:

$ CAST_AGE_KEY_FILE_PROD=<(cat k.txt) ./drive.sh
FOO=bar   ^ WORKS via <(...)

Notes on two paths that do not work, so nobody re-derives them:

  • age -d -i /dev/stdin <file> with node's input: option → ENXIO: no such device or address. Node closes the pipe before age opens the path. Use -i -, which reads the fd directly rather than re-opening it by path.
  • Passing the key file's fd through to the child as fd 3 (stdio: [..., fd], -i /dev/fd/3) also works in principle, but is strictly more machinery than -i - for no gain.

With this, the documented recipe works as written, and the key material never becomes a file, never appears in argv, and never enters the environment.

While we're here: please do NOT add a --age-key flag

The obvious "just let me pass the key" request should be refused, and cast has already taken this position for --override values (README: "an --override's value is read from $CAST_CAPTURE_<NAME>, never from the command line: argv is visible in ps to every process on the box"). The prod age key is strictly more sensitive than any single override — it opens every prod secret — so it must not go anywhere argv goes (/proc/<pid>/cmdline is world-readable, and it lands in shell history).

An env var carrying the key material (CAST_AGE_KEY_PROD=$(pm read …)) would be defensible — /proc/<pid>/environ is same-UID-only, and it matches the CAST_CAPTURE_* / CAST_GIT_TOKEN precedent — but once -i - lands, the file-path indirection is strictly better: the env var would be inherited by every child cast spawns (age, git), and it survives in the shell if anyone exports it.

Workaround until this lands

( umask 077
  k=$(mktemp /dev/shm/cast-age.XXXXXX)
  trap 'shred -u "$k" 2>/dev/null' EXIT
  pm read … > "$k"
  CAST_AGE_KEY_FILE_PROD="$k" cast diff … )

/dev/shm is tmpfs, so the key stays in RAM; the trap shreds it even when cast fails — which is the step the old hand-run recipe relied on people remembering, and encryptSecrets's own comment already calls that out as "a step that is invisible when it is skipped."

## Symptom Hit live during the incubator prod migration (Task 7's `--full` diff gate against the legacy box): ``` $ CAST_AGE_KEY_FILE_PROD=<(bw get notes <id>) cast diff heavy-duty/incubator --env prod --instance box-b --full … age: error: reading "/proc/self/fd/11": failed to open file: open /proc/self/fd/11: no such file or directory Command failed: age -d -i /proc/self/fd/11 secrets/incubator.prod.env.age ``` ## Why it can never work `src/secrets.ts:10`: ```ts const out = execFileSync("age", ["-d", "-i", keyFile, file], { encoding: "utf8" }); ``` The shell's `<(…)` expands to `/proc/self/fd/11` (zsh) or `/dev/fd/63` (bash). That path is meaningful **only inside the process holding that fd** — the shell, and then node, which inherits it. cast passes the *string* to a freshly-spawned `age`, which resolves `/proc/self/fd/N` against **its own** fd table, where N is not open. So it fails with ENOENT, for every password manager, on every shell. Reproduced minimally: ```js // node passing a process-substitution path straight to age — what cast does today execFileSync("age", ["-d", "-i", "/dev/fd/63", "s.age"]) // age: error: reading "/dev/fd/63": failed to open file: no such file or directory ``` This matters because `CAST_AGE_KEY_FILE_PROD=<(pm-cli read …)` is the **documented** way to inject the prod key, and the incubator project's runbook, STATE, and operator punch list all carry it as *the* attendance mechanism for a key that must never touch disk. The recipe is unusable as written, and the workaround people will reach for is a plaintext key file on disk — the exact thing the design forbids. ## Fix (verified) `node` **can** resolve `/proc/self/fd/N`, because node owns that fd. So read the identity in cast and hand it to age on **stdin**, which age accepts as `-i -`: ```ts const key = readFileSync(keyFile); // resolves /proc/self/fd/N correctly const out = execFileSync("age", ["-d", "-i", "-", file], // "-" = identity from stdin { input: key, encoding: "utf8" }); ``` Verified end-to-end, including through a real process substitution: ``` $ CAST_AGE_KEY_FILE_PROD=<(cat k.txt) ./drive.sh FOO=bar ^ WORKS via <(...) ``` Notes on two paths that do **not** work, so nobody re-derives them: - `age -d -i /dev/stdin <file>` with node's `input:` option → `ENXIO: no such device or address`. Node closes the pipe before age opens the path. Use `-i -`, which reads the fd directly rather than re-opening it by path. - Passing the key file's fd through to the child as fd 3 (`stdio: [..., fd]`, `-i /dev/fd/3`) also works in principle, but is strictly more machinery than `-i -` for no gain. With this, the documented recipe works as written, and the key material never becomes a file, never appears in argv, and never enters the environment. ## While we're here: please do NOT add a `--age-key` flag The obvious "just let me pass the key" request should be refused, and cast has already taken this position for `--override` values (README: *"an `--override`'s value is read from `$CAST_CAPTURE_<NAME>`, never from the command line: argv is visible in `ps` to every process on the box"*). The prod age key is strictly more sensitive than any single override — it opens **every** prod secret — so it must not go anywhere argv goes (`/proc/<pid>/cmdline` is world-readable, and it lands in shell history). An env var carrying the key *material* (`CAST_AGE_KEY_PROD=$(pm read …)`) would be defensible — `/proc/<pid>/environ` is same-UID-only, and it matches the `CAST_CAPTURE_*` / `CAST_GIT_TOKEN` precedent — but once `-i -` lands, the file-path indirection is strictly better: the env var would be inherited by every child cast spawns (`age`, `git`), and it survives in the shell if anyone exports it. ## Workaround until this lands ```sh ( umask 077 k=$(mktemp /dev/shm/cast-age.XXXXXX) trap 'shred -u "$k" 2>/dev/null' EXIT pm read … > "$k" CAST_AGE_KEY_FILE_PROD="$k" cast diff … ) ``` `/dev/shm` is tmpfs, so the key stays in RAM; the `trap` shreds it even when cast fails — which is the step the old hand-run recipe relied on people remembering, and `encryptSecrets`'s own comment already calls that out as *"a step that is invisible when it is skipped."*
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#34
No description provided.