fix: hand the age identity to age on stdin — fd paths resolve only in cast's process #35

Merged
dan-claude-bot merged 1 commit from fix/age-key-stdin into main 2026-07-13 22:51:04 +00:00
dan-claude-bot commented 2026-07-13 22:43:21 +00:00 (Migrated from github.com)

Fixes #34.

What was broken

CAST_AGE_KEY_FILE_PROD=<(pm read …) — the documented recipe for injecting a prod key that never touches disk — could never work. The shell's <(…) expands to /proc/self/fd/N (zsh) or /dev/fd/N (bash), a path meaningful only inside the process holding the fd. cast passed that string to a freshly-spawned age, which resolved it against its own fd table and failed with ENOENT — for every password manager, on every shell. The workaround people reach for is a plaintext key file on disk, the exact thing the design forbids.

The fix

Exactly what the issue prescribes: node does own the fd, so decryptSecrets now reads the identity itself and hands it to age as -i - on stdin:

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

The key still never becomes a file, never appears in argv, and never enters the environment. Per the issue's notes, -i /dev/stdin is deliberately not used (node closes the pipe before age re-opens it by path → ENXIO), and no --age-key flag was added.

Verification

  • Regression test reproduces the failure shape faithfully: it opens the key in the test process and passes /proc/self/fd/N as the key path. Against the old code it fails with the exact error from the live incident (age: error: reading "/proc/self/fd/22": failed to open file); with the fix it decrypts.
  • End-to-end through a real process substitution: node -e '…decryptSecrets(store, key)…' store.env.age <(cat key.txt) → decrypts correctly.
  • Full suite: 279 passed (278 existing + the new regression test). npm run check clean.

Also documents the process-substitution recipe in the README's Secrets, and attended applies section, since it now works as designed.

🤖 Generated with Claude Code

Fixes #34. ## What was broken `CAST_AGE_KEY_FILE_PROD=<(pm read …)` — the **documented** recipe for injecting a prod key that never touches disk — could never work. The shell's `<(…)` expands to `/proc/self/fd/N` (zsh) or `/dev/fd/N` (bash), a path meaningful only inside the process holding the fd. cast passed that *string* to a freshly-spawned `age`, which resolved it against its **own** fd table and failed with ENOENT — for every password manager, on every shell. The workaround people reach for is a plaintext key file on disk, the exact thing the design forbids. ## The fix Exactly what the issue prescribes: node *does* own the fd, so `decryptSecrets` now reads the identity itself and hands it to age as `-i -` on stdin: ```ts const out = execFileSync("age", ["-d", "-i", "-", file], { input: readFileSync(keyFile), encoding: "utf8", }); ``` The key still never becomes a file, never appears in argv, and never enters the environment. Per the issue's notes, `-i /dev/stdin` is deliberately **not** used (node closes the pipe before age re-opens it by path → ENXIO), and no `--age-key` flag was added. ## Verification - **Regression test** reproduces the failure shape faithfully: it opens the key in the test process and passes `/proc/self/fd/N` as the key path. Against the old code it fails with the exact error from the live incident (`age: error: reading "/proc/self/fd/22": failed to open file`); with the fix it decrypts. - **End-to-end through a real process substitution**: `node -e '…decryptSecrets(store, key)…' store.env.age <(cat key.txt)` → decrypts correctly. - Full suite: **279 passed** (278 existing + the new regression test). `npm run check` clean. Also documents the process-substitution recipe in the README's *Secrets, and attended applies* section, since it now works as designed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
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#35
No description provided.