fix(firmware): tolerate mangled PEM in BF_FIRMWARE_SIGNING_KEY env

Coolify / docker compose env injection routinely strips real newlines or
wraps in quotes, causing createPrivateKey to throw ERR_OSSL_UNSUPPORTED
and crashing the server before it can even start.

tryParsePrivateKey now attempts: literal, \n→LF, CRLF→LF, quote-stripped,
base64-decoded, and single-line PEM re-wrapped to 64-col. On total
failure, logs a clear warning and falls back to on-disk / generated key
instead of crashing.
This commit is contained in:
Mitchell R 2026-05-18 22:47:07 +02:00
parent d242f0eb12
commit 8082571b03
No known key found for this signature in database

View file

@ -139,12 +139,23 @@ function loadOrCreateKeyPair(
log: FirmwareLog, log: FirmwareLog,
): FirmwareKeyPair { ): FirmwareKeyPair {
// Env override for cloud / k8s — full private key PEM in a single var. // Env override for cloud / k8s — full private key PEM in a single var.
// Coolify / shell env vars frequently mangle newlines (escaped `\n` instead
// of real LF, CRLF, or wrapping quotes). Try multiple normalisations before
// giving up and falling through to the on-disk / generated path.
const envKey = process.env["BF_FIRMWARE_SIGNING_KEY"]; const envKey = process.env["BF_FIRMWARE_SIGNING_KEY"];
if (envKey && envKey.trim().length > 0) { if (envKey && envKey.trim().length > 0) {
const priv = createPrivateKey({ key: envKey, format: "pem" }); const parsed = tryParsePrivateKey(envKey);
const pub = createPublicKey(priv).export({ format: "pem", type: "spki" }); if (parsed) {
const pub = createPublicKey(parsed).export({ format: "pem", type: "spki" });
log.info("firmware: signing key loaded from BF_FIRMWARE_SIGNING_KEY env"); log.info("firmware: signing key loaded from BF_FIRMWARE_SIGNING_KEY env");
return { privateKey: priv, publicKeyPem: String(pub) }; return { privateKey: parsed, publicKeyPem: String(pub) };
}
log.warn(
"firmware: BF_FIRMWARE_SIGNING_KEY set but could not be parsed as PEM " +
"(checked literal, unescaped \\n, CRLF→LF, base64-wrapped). Falling " +
"back to on-disk key / fresh generation. Existing kiosks will need a " +
"re-pair if the public key changes.",
);
} }
if (existsSync(privPath) && existsSync(pubPath)) { if (existsSync(privPath) && existsSync(pubPath)) {
@ -163,6 +174,49 @@ function loadOrCreateKeyPair(
return { privateKey, publicKeyPem: pubPem }; return { privateKey, publicKeyPem: pubPem };
} }
/**
* Try several normalisations of an env-supplied PEM string. Coolify / docker
* compose env passing routinely strips real newlines or wraps in quotes.
*/
function tryParsePrivateKey(raw: string): KeyObject | null {
const candidates: string[] = [];
candidates.push(raw);
// \n / \r\n escape sequences → real newlines.
if (raw.includes("\\n")) {
candidates.push(raw.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n"));
}
// CRLF → LF
if (raw.includes("\r")) candidates.push(raw.replace(/\r\n?/g, "\n"));
// Strip surrounding single / double quotes.
const m = raw.match(/^["'](.*)["']$/s);
if (m && m[1]) candidates.push(m[1]);
// Base64-encoded entire PEM (some platforms recommend this for safety).
if (/^[A-Za-z0-9+/=\s]+$/.test(raw.trim())) {
try {
const decoded = Buffer.from(raw.trim(), "base64").toString("utf8");
if (decoded.includes("BEGIN")) candidates.push(decoded);
} catch { /* ignore */ }
}
// Recover from "BEGIN PRIVATE KEY-----<body>-----END PRIVATE KEY" with no
// internal line breaks: re-inject 64-char-wide line breaks around the body.
if (/-----BEGIN [^-]+-----.*-----END [^-]+-----/.test(raw) && !raw.includes("\n")) {
const pemMatch = raw.match(/-----BEGIN ([^-]+)-----(.*)-----END \1-----/s);
if (pemMatch) {
const header = pemMatch[1]!;
const body = pemMatch[2]!.replace(/\s+/g, "");
const wrapped = body.match(/.{1,64}/g)?.join("\n") ?? body;
candidates.push(`-----BEGIN ${header}-----\n${wrapped}\n-----END ${header}-----\n`);
}
}
for (const c of candidates) {
try {
return createPrivateKey({ key: c, format: "pem" });
} catch { /* try next */ }
}
return null;
}
/** /**
* Standalone verifier used by anyone with just the public key (kiosk-side * Standalone verifier used by anyone with just the public key (kiosk-side
* equivalent of `verifyBlob` lives in Rust this is for server-side checks * equivalent of `verifyBlob` lives in Rust this is for server-side checks