The documented prod-key recipe (CAST_AGE_KEY_FILE_PROD=<(pm read …)) cannot work: age resolves the fd path in its own process #34
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#34
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Symptom
Hit live during the incubator prod migration (Task 7's
--fulldiff gate against the legacy box):Why it can never work
src/secrets.ts:10: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-spawnedage, which resolves/proc/self/fd/Nagainst its own fd table, where N is not open. So it fails with ENOENT, for every password manager, on every shell.Reproduced minimally:
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)
nodecan 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 -:Verified end-to-end, including through a real process substitution:
Notes on two paths that do not work, so nobody re-derives them:
age -d -i /dev/stdin <file>with node'sinput: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.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-keyflagThe obvious "just let me pass the key" request should be refused, and cast has already taken this position for
--overridevalues (README: "an--override's value is read from$CAST_CAPTURE_<NAME>, never from the command line: argv is visible inpsto 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>/cmdlineis 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>/environis same-UID-only, and it matches theCAST_CAPTURE_*/CAST_GIT_TOKENprecedent — 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
/dev/shmis tmpfs, so the key stays in RAM; thetrapshreds it even when cast fails — which is the step the old hand-run recipe relied on people remembering, andencryptSecrets's own comment already calls that out as "a step that is invisible when it is skipped."