Merge pull request #105 from dan-claude-bot/fix/age-key-env-name

fix: CAST_AGE_KEY_FILE_<ENV> maps to a name a shell can set
This commit is contained in:
Daniel Marin 2026-07-19 14:13:09 +01:00 committed by GitHub
commit ffaa850723
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 56 additions and 4 deletions

View file

@ -39,6 +39,16 @@ actually cutting it, and this file starts there.
decrypt, the age key is not demanded either. The moment any template
gains a `${…}` ref, the refusal returns byte-identical to before.
`capture` and `destroy` are untouched.
- **`CAST_AGE_KEY_FILE_<ENV>` is now settable for every environment name**
(#102) — `<ENV>` was the name uppercased verbatim, so env `drill-b`
advertised `CAST_AGE_KEY_FILE_DRILL-B`: a variable no POSIX shell can
export, which walled off the injected-key channel (and its
process-substitution trick) for every hyphenated environment. Found live
in the 2026-07-19 release drill. Characters outside `[A-Z0-9]` now map to
`_` — env `drill-b` reads `CAST_AGE_KEY_FILE_DRILL_B` — and the refusal
advertises the mapped name. The standing-key path keeps the exact
environment name, so two names that collide on the variable still resolve
their own keys on disk.
### Added

View file

@ -608,8 +608,12 @@ Coolify's default `production`) in a blueprint that claims to describe the box.
An environment's age identity is resolved in exactly two ways:
1. `$CAST_AGE_KEY_FILE_<ENV>` — injected for this invocation
2. `~/.config/cast/age-<env>.key` — a standing key on this machine
1. `$CAST_AGE_KEY_FILE_<ENV>` — injected for this invocation. `<ENV>` is the
environment name uppercased, with every character a shell cannot carry in a
variable name mapped to `_`: env `drill-b` reads
`CAST_AGE_KEY_FILE_DRILL_B`.
2. `~/.config/cast/age-<env>.key` — a standing key on this machine, under the
environment's exact name.
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

View file

@ -79,15 +79,27 @@ export function encryptSecrets(
// 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()}`];
const injected = process.env[ageKeyVarFor(envName)];
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}`,
`no age key for ${envName}: set ${ageKeyVarFor(envName)} (attended apply) or place a standing key at ${standing}`,
);
}
// The env var carrying an environment's injected key. Uppercased AND mapped to
// the character set a shell can actually set: `drill-b`.toUpperCase() is
// `DRILL-B`, and `CAST_AGE_KEY_FILE_DRILL-B` is a name no POSIX shell can
// export — the refusal above used to advertise it anyway, an escape hatch that
// pointed at a wall (#102, found live in the release drill). Every character
// outside [A-Z0-9] collapses to `_`, so `drill-b` and `drill.b` both read
// CAST_AGE_KEY_FILE_DRILL_B — a collision that costs nothing, because both
// names still resolve their OWN standing key (which keeps the exact env name).
export function ageKeyVarFor(envName: string): string {
return `CAST_AGE_KEY_FILE_${envName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
}
export function secretsFileFor(
stateDir: string,
repoShortName: string,

View file

@ -107,6 +107,32 @@ describe("keyFileFor", () => {
expect(keyFileFor("prod")).toBe("/tmp/prod.key");
Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_PROD");
});
// #102: `drill-b`.toUpperCase() is `DRILL-B`, and a var named
// CAST_AGE_KEY_FILE_DRILL-B cannot be set by any POSIX shell — the injected
// channel (and its process-substitution trick) was unreachable for every
// hyphenated environment name. Non-alphanumerics map to `_`.
it("a hyphenated env name maps to a settable var name", () => {
process.env.CAST_AGE_KEY_FILE_DRILL_B = "/tmp/drill-b.key";
try {
expect(keyFileFor("drill-b")).toBe("/tmp/drill-b.key");
} finally {
Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_DRILL_B");
}
});
it("the refusal advertises the mapped (settable) var, and the exact-name standing path", () => {
Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_DRILL_B");
// Isolate $HOME: a standing age-drill-b.key on the dev machine must not
// turn the refusal into a hit (os.homedir() reads $HOME on POSIX).
const home = process.env.HOME;
process.env.HOME = mkdtempSync(join(tmpdir(), "cast-home-"));
try {
expect(() => keyFileFor("drill-b")).toThrow(
/no age key for drill-b.*CAST_AGE_KEY_FILE_DRILL_B.*age-drill-b\.key/s,
);
} finally {
process.env.HOME = home;
}
});
it("falls back to a standing key on disk when one exists", () => {
const home = process.env.HOME;
const dir = mkdtempSync(join(tmpdir(), "cast-home-"));