fix(firmware): diagnostic dump + smart-quote / BOM / multi-quote handling

Adds aggressive normalisation to tryParsePrivateKey:
- Strip UTF-8 BOM
- Replace smart quotes (" " ' ') with ASCII
- Strip multiple layers of wrapping quotes
- Combine escape-unfold with quote-strip (env vars that quote AND escape)
- Strip whitespace inside base64 candidate before decode

On parse failure, dumps length + head/tail samples + first-byte hex so
the operator can spot exactly what shape the env var arrived in.
This commit is contained in:
Mitchell R 2026-05-18 22:52:35 +02:00
parent 936e6170a6
commit 6473f0fc95
No known key found for this signature in database

View file

@ -150,11 +150,17 @@ function loadOrCreateKeyPair(
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: parsed, publicKeyPem: String(pub) }; 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( log.warn(
"firmware: BF_FIRMWARE_SIGNING_KEY set but could not be parsed as PEM " + `firmware: BF_FIRMWARE_SIGNING_KEY (${String(envKey.length)} chars) failed PEM parse. ` +
"(checked literal, unescaped \\n, CRLF→LF, base64-wrapped). Falling " + `head="${head}" tail="${tail}" hex0..7=${hexFirst}. ` +
"back to on-disk key / fresh generation. Existing kiosks will need a " + `Falling back to on-disk key / fresh generation.`,
"re-pair if the public key changes.",
); );
} }
@ -176,31 +182,52 @@ function loadOrCreateKeyPair(
/** /**
* Try several normalisations of an env-supplied PEM string. Coolify / docker * 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 { function tryParsePrivateKey(raw: string): KeyObject | null {
const candidates: string[] = []; 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. // \n / \r\n escape sequences → real newlines.
if (raw.includes("\\n")) { if (cleaned.includes("\\n")) {
candidates.push(raw.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\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). // 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 { 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); if (decoded.includes("BEGIN")) candidates.push(decoded);
} catch { /* ignore */ } } catch { /* ignore */ }
} }
// Recover from "BEGIN PRIVATE KEY-----<body>-----END PRIVATE KEY" with no // Recover from "BEGIN PRIVATE KEY-----<body>-----END PRIVATE KEY" with no
// internal line breaks: re-inject 64-char-wide line breaks around the body. // internal line breaks: re-inject 64-char-wide line breaks around the body.
if (/-----BEGIN [^-]+-----.*-----END [^-]+-----/.test(raw) && !raw.includes("\n")) { for (const variant of [cleaned, unq]) {
const pemMatch = raw.match(/-----BEGIN ([^-]+)-----(.*)-----END \1-----/s); if (/-----BEGIN [^-]+-----.*-----END [^-]+-----/.test(variant)
&& !variant.includes("\n")) {
const pemMatch = variant.match(/-----BEGIN ([^-]+)-----(.*)-----END \1-----/s);
if (pemMatch) { if (pemMatch) {
const header = pemMatch[1]!; const header = pemMatch[1]!;
const body = pemMatch[2]!.replace(/\s+/g, ""); const body = pemMatch[2]!.replace(/\s+/g, "");
@ -208,6 +235,7 @@ function tryParsePrivateKey(raw: string): KeyObject | null {
candidates.push(`-----BEGIN ${header}-----\n${wrapped}\n-----END ${header}-----\n`); candidates.push(`-----BEGIN ${header}-----\n${wrapped}\n-----END ${header}-----\n`);
} }
} }
}
for (const c of candidates) { for (const c of candidates) {
try { try {