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