diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a1a93..3231cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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_` is now settable for every environment name** + (#102) — `` 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 diff --git a/README.md b/README.md index 4f24bc8..06b9359 100644 --- a/README.md +++ b/README.md @@ -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_` — injected for this invocation -2. `~/.config/cast/age-.key` — a standing key on this machine +1. `$CAST_AGE_KEY_FILE_` — injected for this invocation. `` 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-.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 diff --git a/src/secrets.ts b/src/secrets.ts index 6556d6f..ec82f90 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -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, diff --git a/test/secrets.test.ts b/test/secrets.test.ts index a436552..da908f5 100644 --- a/test/secrets.test.ts +++ b/test/secrets.test.ts @@ -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-"));