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

CAST_AGE_KEY_FILE_PROD=<(pm read …) — the documented way to inject a prod
key that never touches disk — expands to /proc/self/fd/N, 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.

node owns the fd, so cast now reads the identity itself and hands it to age
as `-i -` on stdin. The key still never becomes a file, never appears in
argv, and never enters the environment. Not `-i /dev/stdin`: node closes
the pipe before age re-opens it by path (ENXIO).

The regression test reproduces the shape exactly — a key path that only
this process can resolve — and fails against the old code with the same
age ENOENT hit live during the incubator prod migration.

Fixes #34

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
claude-hdb 2026-07-13 22:43:00 +00:00
parent 7d65524054
commit b9ded195da
3 changed files with 60 additions and 14 deletions

View file

@ -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.

View file

@ -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<string, string> {
const out = execFileSync("age", ["-d", "-i", keyFile, file], {
const out = execFileSync("age", ["-d", "-i", "-", file], {
input: readFileSync(keyFile),
encoding: "utf8",
});
const secrets: Record<string, string> = {};

View file

@ -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 <path>` 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", () => {