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 decrypt, the age key is not demanded either. The moment any template
gains a `${…}` ref, the refusal returns byte-identical to before. gains a `${…}` ref, the refusal returns byte-identical to before.
`capture` and `destroy` are untouched. `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 ### 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: An environment's age identity is resolved in exactly two ways:
1. `$CAST_AGE_KEY_FILE_<ENV>` — injected for this invocation 1. `$CAST_AGE_KEY_FILE_<ENV>` — injected for this invocation. `<ENV>` is the
2. `~/.config/cast/age-<env>.key` — a standing key on this machine 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 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

View file

@ -79,15 +79,27 @@ export function encryptSecrets(
// operator who injects it. Keep the key OUT of the state repo — the state repo // operator who injects it. Keep the key OUT of the state repo — the state repo
// holds ciphertext, never the identity that opens it. // holds ciphertext, never the identity that opens it.
export function keyFileFor(envName: string): string { 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; if (injected) return injected;
const standing = join(homedir(), ".config", "cast", `age-${envName}.key`); const standing = join(homedir(), ".config", "cast", `age-${envName}.key`);
if (existsSync(standing)) return standing; if (existsSync(standing)) return standing;
throw new Error( 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( export function secretsFileFor(
stateDir: string, stateDir: string,
repoShortName: string, repoShortName: string,

View file

@ -107,6 +107,32 @@ describe("keyFileFor", () => {
expect(keyFileFor("prod")).toBe("/tmp/prod.key"); expect(keyFileFor("prod")).toBe("/tmp/prod.key");
Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_PROD"); 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", () => { it("falls back to a standing key on disk when one exists", () => {
const home = process.env.HOME; const home = process.env.HOME;
const dir = mkdtempSync(join(tmpdir(), "cast-home-")); const dir = mkdtempSync(join(tmpdir(), "cast-home-"));