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:
parent
7d65524054
commit
b9ded195da
3 changed files with 60 additions and 14 deletions
11
README.md
11
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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> = {};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
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";
|
||||
|
||||
describe("decryptSecrets", () => {
|
||||
it("round-trips an env file through age", () => {
|
||||
// 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]);
|
||||
|
|
@ -17,11 +23,35 @@ describe("decryptSecrets", () => {
|
|||
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 { 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", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue