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>
90 lines
3.2 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
});
|