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");
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-----<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 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`);
}
}
}