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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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-"));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue