cast/test/secrets.test.ts
claude-hdb b9ded195da 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>
2026-07-13 22:43:00 +00:00

90 lines
3.2 KiB
TypeScript

import { execFileSync } from "node:child_process";
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 { 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", () => {
it("resolves the age store under the state dir it is given, not the cwd", () => {
expect(secretsFileFor("/srv/state", "widget", "prod")).toBe(
"/srv/state/secrets/widget.prod.env.age",
);
});
});
describe("keyFileFor", () => {
it("an env with no injected var and no standing key refuses, naming both ways in", () => {
Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_PROD");
expect(() => keyFileFor("prod")).toThrow(
/no age key for prod.*CAST_AGE_KEY_FILE_PROD.*age-prod\.key/s,
);
});
it("the injected var wins, and is resolved per environment name", () => {
process.env.CAST_AGE_KEY_FILE_PROD = "/tmp/prod.key";
expect(keyFileFor("prod")).toBe("/tmp/prod.key");
Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_PROD");
});
it("falls back to a standing key on disk when one exists", () => {
const home = process.env.HOME;
const dir = mkdtempSync(join(tmpdir(), "cast-home-"));
const cfg = join(dir, ".config", "cast");
mkdirSync(cfg, { recursive: true });
writeFileSync(join(cfg, "age-staging.key"), "AGE-SECRET-KEY-1\n");
process.env.HOME = dir; // os.homedir() reads $HOME on POSIX
try {
expect(keyFileFor("staging")).toBe(join(cfg, "age-staging.key"));
} finally {
process.env.HOME = home;
}
});
});