Merge pull request #35 from claude-hdb/fix/age-key-stdin

fix: hand the age identity to age on stdin — fd paths resolve only in cast's process
This commit is contained in:
Daniel Marin 2026-07-13 23:51:04 +01:00 committed by GitHub
commit 2b202580ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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 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 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 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 The state directory holds ciphertext. It must never hold the identity that opens
it. it.

View file

@ -1,13 +1,20 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; 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( export function decryptSecrets(
file: string, file: string,
keyFile: string, keyFile: string,
): Record<string, string> { ): Record<string, string> {
const out = execFileSync("age", ["-d", "-i", keyFile, file], { const out = execFileSync("age", ["-d", "-i", "-", file], {
input: readFileSync(keyFile),
encoding: "utf8", encoding: "utf8",
}); });
const secrets: Record<string, string> = {}; const secrets: Record<string, string> = {};

View file

@ -1,27 +1,57 @@
import { execFileSync } from "node:child_process"; 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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { decryptSecrets, keyFileFor, secretsFileFor } from "../src/secrets.js"; 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", () => { describe("decryptSecrets", () => {
it("round-trips an env file through age", () => { it("round-trips an env file through age", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-age-")); const { keyFile, enc } = ageFixture();
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]);
expect(decryptSecrets(enc, keyFile)).toEqual({ expect(decryptSecrets(enc, keyFile)).toEqual({
MAILGUN_KEY: "mk-123", MAILGUN_KEY: "mk-123",
OPENROUTER_KEY: "or-456", 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", () => { describe("secretsFileFor", () => {