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:
commit
ffaa850723
4 changed files with 56 additions and 4 deletions
10
CHANGELOG.md
10
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_<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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-"));
|
||||
|
|
|
|||
Loading…
Reference in a new issue