Signing key path: absolute only, and no error carries the path

Soul S3: GC_ACCESS_SIGNING_PRIVATE_KEY_PATH is now the only way the signing
key gets in, and its errors printed the path: the missing-file message
interpolated it, and fs errors from the read and the bootstrap mkdir carried
it in their message and properties. An operator moving off the deleted inline
PEM input who binds the key text to _PATH would have the private key written
to the journal.

The path now takes the secret reader's rules. loadConfig refuses a relative
value, which PEM text always is. Every signing-key failure is a fresh error
naming only the variable and a code, with no cause and no properties.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 14:40:07 +02:00
commit e091bb3fd1
2 changed files with 40 additions and 6 deletions

View file

@ -332,8 +332,16 @@ export function loadConfig(
config.odinCultMeshUri = odinCultMeshUri;
}
if (env.GC_ACCESS_SIGNING_PRIVATE_KEY_PATH) {
config.signingPrivateKeyPath = env.GC_ACCESS_SIGNING_PRIVATE_KEY_PATH;
// The signing key path follows the secret reader's rules: it must be
// absolute, and no error repeats it. An operator moving off the deleted
// inline PEM input who binds the key text here would otherwise have the
// private key printed back into the journal.
const signingPrivateKeyPath = env.GC_ACCESS_SIGNING_PRIVATE_KEY_PATH;
if (signingPrivateKeyPath) {
if (!path.isAbsolute(signingPrivateKeyPath)) {
throw new Error("GC_ACCESS_SIGNING_PRIVATE_KEY_PATH is not an absolute path.");
}
config.signingPrivateKeyPath = signingPrivateKeyPath;
}
if (env.GC_ACCESS_SIGNING_KEY_ID) {

View file

@ -56,8 +56,30 @@ function exportPrivateKeyPem(privateKey: KeyObject): string {
return typeof exported === "string" ? exported : exported.toString("utf8");
}
const SIGNING_KEY_VARIABLE = "GC_ACCESS_SIGNING_PRIVATE_KEY_PATH";
/**
* Every signing-key failure names only the variable and an error code. It
* carries no path, no file contents and no cause, because Node prints an
* error's cause and properties along with its message.
*/
function signingKeyError(failure: string, error: unknown): Error {
const code = (error as NodeJS.ErrnoException | undefined)?.code ?? "error";
return new Error(`${SIGNING_KEY_VARIABLE} ${failure} (${code}).`);
}
function loadPrivateKeyFromPath(path: string): KeyObject {
return createPrivateKey(readFileSync(path, "utf8"));
let pem: string;
try {
pem = readFileSync(path, "utf8");
} catch (error) {
throw signingKeyError("could not be read", error);
}
try {
return createPrivateKey(pem);
} catch (error) {
throw signingKeyError("does not hold a usable private key", error);
}
}
function createOrLoadPrivateKeyFromPath(config: HeimdallConfig): {
@ -81,12 +103,16 @@ function createOrLoadPrivateKeyFromPath(config: HeimdallConfig): {
if (!config.bootstrapSigningPrivateKeyOnMissing) {
throw new Error(
`Signing key file '${path}' does not exist. Set GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP=1 to create it on first boot.`
`${SIGNING_KEY_VARIABLE} names no file (ENOENT). Set GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP=1 to create it on first boot.`
);
}
const privateKey = generateKeyPairSync("ed25519").privateKey;
mkdirSync(dirname(path), { recursive: true });
try {
mkdirSync(dirname(path), { recursive: true });
} catch (error) {
throw signingKeyError("could not be created", error);
}
try {
writeFileSync(path, exportPrivateKeyPem(privateKey), {
encoding: "utf8",
@ -99,7 +125,7 @@ function createOrLoadPrivateKeyFromPath(config: HeimdallConfig): {
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
throw error;
throw signingKeyError("could not be created", error);
}
return {