diff --git a/server/src/shared/firmware.ts b/server/src/shared/firmware.ts index 8d8e44a..46ed2f6 100644 --- a/server/src/shared/firmware.ts +++ b/server/src/shared/firmware.ts @@ -139,12 +139,23 @@ function loadOrCreateKeyPair( log: FirmwareLog, ): FirmwareKeyPair { // 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"]; if (envKey && envKey.trim().length > 0) { - const priv = createPrivateKey({ key: envKey, format: "pem" }); - const pub = createPublicKey(priv).export({ format: "pem", type: "spki" }); - log.info("firmware: signing key loaded from BF_FIRMWARE_SIGNING_KEY env"); - return { privateKey: priv, publicKeyPem: String(pub) }; + const parsed = tryParsePrivateKey(envKey); + if (parsed) { + const pub = createPublicKey(parsed).export({ format: "pem", type: "spki" }); + log.info("firmware: signing key loaded from BF_FIRMWARE_SIGNING_KEY env"); + 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)) { @@ -163,6 +174,49 @@ function loadOrCreateKeyPair( 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----------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 * equivalent of `verifyBlob` lives in Rust — this is for server-side checks