/** * Firmware signing + storage helpers. * * Server holds an Ed25519 keypair used to sign kiosk binaries during upload. * Kiosks verify the signature before swapping. The private key never leaves * the server. The public key is shipped to kiosks via the bundle response * (so it can rotate without re-pairing) AND embedded in the binary at build * time as a fallback for first-boot. * * Key file lives at `${dataDir}/firmware-signing.key` (private, 0600) and * `${dataDir}/firmware-signing.pub` (public, 0644). Both PEM-encoded. * If env var BF_FIRMWARE_SIGNING_KEY is set (PEM string), it overrides the * file — convenient for cloud deploys where the key comes from a secret * manager. * * Storage for the firmware blobs themselves is `${dataDir}/firmware/`, one * file per release, named `.bin`. Hashing on insert dedupes binaries * across version/arch metadata changes. */ import { createHash, generateKeyPairSync, sign as cryptoSign, verify as cryptoVerify, createPrivateKey, createPublicKey, type KeyObject, } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { writeFile, readFile, unlink, rename } from "node:fs/promises"; import { dirname, join } from "node:path"; export interface FirmwareKeyPair { privateKey: KeyObject; publicKeyPem: string; } export interface FirmwareApi { /** Base64url of Ed25519(sha256(bytes)). Returns `{sha256, signature}`. */ signBlob(bytes: Buffer): { sha256: string; signature: string }; /** Verify a signature against bytes. Used by tests + admin re-verify. */ verifyBlob(bytes: Buffer, signature: string): boolean; /** PEM-encoded SPKI public key for export to kiosks/bundle. */ publicKeyPem(): string; /** Persist an uploaded firmware blob to disk and return absolute path. */ storeBlob(bytes: Buffer, sha256: string): Promise; /** Read a stored firmware blob by absolute path (re-checks sha256). */ readBlob(path: string, expectedSha256: string): Promise; /** Delete a stored firmware blob (yank cleanup). */ removeBlob(path: string): Promise; /** Directory holding all firmware blobs. */ firmwareDir(): string; } export interface FirmwareConfig { /** Server data dir (same as secrets dataDir, typically /var/lib/betterframe). */ dataDir: string; } export interface FirmwareLog { info(msg: string): void; warn(msg: string): void; } export function initFirmware(config: FirmwareConfig, log: FirmwareLog): FirmwareApi { const keyDir = config.dataDir; const privPath = join(keyDir, "firmware-signing.key"); const pubPath = join(keyDir, "firmware-signing.pub"); const firmwareDir = join(config.dataDir, "firmware"); if (!existsSync(firmwareDir)) { mkdirSync(firmwareDir, { recursive: true, mode: 0o755 }); } let keyPair = loadOrCreateKeyPair(keyDir, privPath, pubPath, log); function signBlob(bytes: Buffer): { sha256: string; signature: string } { const sha256 = createHash("sha256").update(bytes).digest("hex"); // Sign the sha256 hex digest rather than the raw bytes — smaller, faster // verify, and matches what we ship as the integrity metadata anyway. const sig = cryptoSign(null, Buffer.from(sha256, "utf8"), keyPair.privateKey); return { sha256, signature: sig.toString("base64url") }; } function verifyBlob(bytes: Buffer, signature: string): boolean { const sha256 = createHash("sha256").update(bytes).digest("hex"); const pub = createPublicKey(keyPair.publicKeyPem); return cryptoVerify( null, Buffer.from(sha256, "utf8"), pub, Buffer.from(signature, "base64url"), ); } async function storeBlob(bytes: Buffer, sha256: string): Promise { const path = join(firmwareDir, `${sha256}.bin`); // Atomic write: write to .tmp then rename. Avoids partial files if the // server is killed mid-upload. const tmp = `${path}.tmp`; await writeFile(tmp, bytes, { mode: 0o644 }); await rename(tmp, path); return path; } async function readBlob(path: string, expectedSha256: string): Promise { const buf = await readFile(path); const got = createHash("sha256").update(buf).digest("hex"); if (got !== expectedSha256) { throw new Error(`firmware sha256 mismatch on ${path}: expected ${expectedSha256}, got ${got}`); } return buf; } async function removeBlob(path: string): Promise { try { await unlink(path); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code !== "ENOENT") throw err; } } return { signBlob, verifyBlob, publicKeyPem: () => keyPair.publicKeyPem, storeBlob, readBlob, removeBlob, firmwareDir: () => firmwareDir, }; } function loadOrCreateKeyPair( keyDir: string, privPath: string, pubPath: string, 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 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) }; } // 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 (${String(envKey.length)} chars) failed PEM parse. ` + `head="${head}" tail="${tail}" hex0..7=${hexFirst}. ` + `Falling back to on-disk key / fresh generation.`, ); } if (existsSync(privPath) && existsSync(pubPath)) { const priv = createPrivateKey({ key: readFileSync(privPath), format: "pem" }); const pub = readFileSync(pubPath, "utf8"); return { privateKey: priv, publicKeyPem: pub }; } log.warn("firmware: generating new Ed25519 signing keypair (no existing key found)"); if (!existsSync(keyDir)) mkdirSync(keyDir, { recursive: true, mode: 0o755 }); const { privateKey, publicKey } = generateKeyPairSync("ed25519"); const privPem = String(privateKey.export({ format: "pem", type: "pkcs8" })); const pubPem = String(publicKey.export({ format: "pem", type: "spki" })); writeFileSync(privPath, privPem, { mode: 0o600 }); writeFileSync(pubPath, pubPem, { mode: 0o644 }); return { privateKey, publicKeyPem: pubPem }; } /** * Try several normalisations of an env-supplied PEM string. Coolify / docker * 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[] = []; // 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 (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")); } // Base64-encoded entire PEM (some platforms recommend this for safety). if (/^[A-Za-z0-9+/=\s]+$/.test(cleaned)) { try { 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. 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`); } } } 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 * during upload re-verification). */ export function verifyDetached( publicKeyPem: string, sha256: string, signature: string, ): boolean { const pub = createPublicKey(publicKeyPem); return cryptoVerify( null, Buffer.from(sha256, "utf8"), pub, Buffer.from(signature, "base64url"), ); } // path helper export so admin routes can serve relative paths cleanly export function blobFilenameFromHash(sha256: string): string { return `${sha256}.bin`; } export { dirname };