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:
commit
2b202580ea
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,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", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue