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
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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> = {};
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue