import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; export function decryptSecrets( file: string, keyFile: string, ): Record { const out = execFileSync("age", ["-d", "-i", keyFile, file], { encoding: "utf8", }); const secrets: Record = {}; for (const raw of out.split("\n")) { const line = raw.trim(); if (line === "" || line.startsWith("#")) continue; const eq = line.indexOf("="); if (eq === -1) throw new Error("age store: malformed line (expected KEY=value)"); secrets[line.slice(0, eq)] = line.slice(eq + 1); } return secrets; } // The age identity for an environment, resolved without cast knowing anything // about your environment names: // // 1. $CAST_AGE_KEY_FILE_ — injected for this invocation // 2. ~/.config/cast/age-.key — a standing key on this machine // // This is the whole mechanism behind attended vs unattended applies: an // environment whose key you never leave on disk can only be applied by an // 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()}`]; 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}`, ); } export function secretsFileFor( stateDir: string, repoShortName: string, envName: string, ): string { return join(stateDir, "secrets", `${repoShortName}.${envName}.env.age`); }