A process substitution (`CAST_AGE_KEY_FILE_<ENV>=<(pm read ...)`) is a read-once pipe, but `diff --all` / `apply --all` call decryptSecrets once per project. The first project drained the pipe; every later project re-read the key file, handed age an empty identity, and failed — the fleet loop then misreported the project as unreachable (diff) or aborted the fleet (apply). Latent today because only one registered project has a prod store; real the moment a second one gains one. Cache the key bytes by key path, module-level, so the identity is read exactly once per process. Exposure is unchanged: the key already transits this process's memory on every call. The regression test uses a FIFO, which really drains — unlike the existing /proc/self/fd test, whose regular file re-opens at offset 0 on every read. A second writer serves emptiness after the first decrypt so a regression fails loudly (age: no secret keys found) instead of blocking the suite on a writerless FIFO open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
97 lines
3.8 KiB
TypeScript
97 lines
3.8 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
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.
|
|
//
|
|
// And it is read exactly ONCE per process: a process substitution is a
|
|
// read-once pipe, but `diff --all` / `apply --all` call decryptSecrets once
|
|
// per project. The first read drains the pipe; a re-read would hand age an
|
|
// empty identity and the second project's decrypt would fail. Caching by key
|
|
// path changes nothing about exposure — the key already transits this
|
|
// process's memory on every call.
|
|
const identities = new Map<string, Buffer>();
|
|
|
|
function readIdentity(keyFile: string): Buffer {
|
|
let identity = identities.get(keyFile);
|
|
if (identity === undefined) {
|
|
identity = readFileSync(keyFile);
|
|
identities.set(keyFile, identity);
|
|
}
|
|
return identity;
|
|
}
|
|
|
|
export function decryptSecrets(
|
|
file: string,
|
|
keyFile: string,
|
|
): Record<string, string> {
|
|
const out = execFileSync("age", ["-d", "-i", "-", file], {
|
|
input: readIdentity(keyFile),
|
|
encoding: "utf8",
|
|
});
|
|
const secrets: Record<string, string> = {};
|
|
for (const raw of out.split("\n")) {
|
|
const line = raw.trim();
|
|
if (line === "" || line.startsWith("#")) continue;
|
|
const eq = line.indexOf("=");
|
|
if (eq === -1)
|
|
throw new Error("age store: malformed line (expected KEY=value)");
|
|
secrets[line.slice(0, eq)] = line.slice(eq + 1);
|
|
}
|
|
return secrets;
|
|
}
|
|
|
|
// Write an environment's secret store, encrypted to its recipient.
|
|
//
|
|
// The plaintext goes to age on STDIN and the ciphertext straight to `file`: it
|
|
// is never a temp file, never reaches the terminal, and never lands in shell
|
|
// history. The hand-run recipe this replaces assembled /dev/shm/prod.env and
|
|
// relied on remembering to `shred -u` it afterwards — a step that is invisible
|
|
// when it is skipped.
|
|
export function encryptSecrets(
|
|
recipient: string,
|
|
file: string,
|
|
vars: Record<string, string>,
|
|
): void {
|
|
const plaintext = `${Object.entries(vars)
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join("\n")}\n`;
|
|
execFileSync("age", ["-r", recipient, "-o", file], {
|
|
input: plaintext,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
}
|
|
|
|
// The age identity for an environment, resolved without cast knowing anything
|
|
// about your environment names:
|
|
//
|
|
// 1. $CAST_AGE_KEY_FILE_<ENV> — injected for this invocation
|
|
// 2. ~/.config/cast/age-<env>.key — a standing key on this machine
|
|
//
|
|
// This is the whole mechanism behind attended vs unattended applies: an
|
|
// environment whose key you never leave on disk can only be applied by an
|
|
// operator who injects it. Keep the key OUT of the state repo — the state repo
|
|
// holds ciphertext, never the identity that opens it.
|
|
export function keyFileFor(envName: string): string {
|
|
const injected = process.env[`CAST_AGE_KEY_FILE_${envName.toUpperCase()}`];
|
|
if (injected) return injected;
|
|
const standing = join(homedir(), ".config", "cast", `age-${envName}.key`);
|
|
if (existsSync(standing)) return standing;
|
|
throw new Error(
|
|
`no age key for ${envName}: set CAST_AGE_KEY_FILE_${envName.toUpperCase()} (attended apply) or place a standing key at ${standing}`,
|
|
);
|
|
}
|
|
|
|
export function secretsFileFor(
|
|
stateDir: string,
|
|
repoShortName: string,
|
|
envName: string,
|
|
): string {
|
|
return join(stateDir, "secrets", `${repoShortName}.${envName}.env.age`);
|
|
}
|