diff --git a/server/src/shared/firmware.ts b/server/src/shared/firmware.ts index 46ed2f6..82d62c1 100644 --- a/server/src/shared/firmware.ts +++ b/server/src/shared/firmware.ts @@ -150,11 +150,17 @@ function loadOrCreateKeyPair( log.info("firmware: signing key loaded from BF_FIRMWARE_SIGNING_KEY env"); return { privateKey: parsed, publicKeyPem: String(pub) }; } + // Diagnostic dump so the operator can spot common pitfalls (smart quotes, + // wrong key type, base64-of-binary instead of base64-of-PEM, etc). + const head = envKey.slice(0, 60).replace(/\n/g, "\\n"); + const tail = envKey.slice(-40).replace(/\n/g, "\\n"); + const hexFirst = Array.from(envKey.slice(0, 8)) + .map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")) + .join(" "); 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.", + `firmware: BF_FIRMWARE_SIGNING_KEY (${String(envKey.length)} chars) failed PEM parse. ` + + `head="${head}" tail="${tail}" hex0..7=${hexFirst}. ` + + `Falling back to on-disk key / fresh generation.`, ); } @@ -176,36 +182,58 @@ function loadOrCreateKeyPair( /** * Try several normalisations of an env-supplied PEM string. Coolify / docker - * compose env passing routinely strips real newlines or wraps in quotes. + * compose env passing routinely strips real newlines, wraps in quotes, + * injects smart-quote unicode, drops BOMs in front, or doubles up escapes. */ function tryParsePrivateKey(raw: string): KeyObject | null { const candidates: string[] = []; - candidates.push(raw); + + // Always start with a "cleaned" baseline: strip BOM + smart quotes → + // ASCII quotes + trim. Most env-injection quirks land here. + const cleaned = raw + .replace(/^/, "") + .replace(/[“”]/g, '"') + .replace(/[‘’]/g, "'") + .trim(); + + candidates.push(cleaned); + // \n / \r\n escape sequences → real newlines. - if (raw.includes("\\n")) { - candidates.push(raw.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n")); + if (cleaned.includes("\\n")) { + candidates.push(cleaned.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n")); + } + // CRLF → LF. + if (cleaned.includes("\r")) candidates.push(cleaned.replace(/\r\n?/g, "\n")); + // Strip surrounding single / double quotes (one or more layers). + let unq = cleaned; + while (/^["'](.*)["']$/s.test(unq)) { + const m = unq.match(/^["'](.*)["']$/s)!; + unq = m[1]!; + } + if (unq !== cleaned) candidates.push(unq); + // Combination: stripped + escape-decoded. + if (unq.includes("\\n")) { + candidates.push(unq.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())) { + if (/^[A-Za-z0-9+/=\s]+$/.test(cleaned)) { try { - const decoded = Buffer.from(raw.trim(), "base64").toString("utf8"); + const decoded = Buffer.from(cleaned.replace(/\s+/g, ""), "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 variant of [cleaned, unq]) { + if (/-----BEGIN [^-]+-----.*-----END [^-]+-----/.test(variant) + && !variant.includes("\n")) { + const pemMatch = variant.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`); + } } }