BetterFrame/server/src/shared/firmware.ts
Mitchell R 6473f0fc95
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.
2026-05-18 22:52:35 +02:00

272 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 `<sha256>.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<string>;
/** Read a stored firmware blob by absolute path (re-checks sha256). */
readBlob(path: string, expectedSha256: string): Promise<Buffer>;
/** Delete a stored firmware blob (yank cleanup). */
removeBlob(path: string): Promise<void>;
/** 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<string> {
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<Buffer> {
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<void> {
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-----<body>-----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 };