import { createHmac, timingSafeEqual } from "node:crypto"; import type { Signer } from "@parking/shared"; // Concrete signers for the append-only event chain. The Signer interface is the // abstraction over the ATECC608 secure element (open-question #6 — chip not yet // confirmed wired). Until the chip is present we use a software HMAC signer: // it makes the chain self-consistent + tamper-evident, but is NOT unforgeable by // someone who owns the host (only the ATECC608's non-extractable key is). The // swap to hardware is a new Signer impl — no event-log changes. // See wiki/concepts/append-only-event-chain.md and wiki/entities/atecc608.md. /** HMAC-SHA256 software signer. Key from env; fail fast if missing in prod. */ export class SoftwareSigner implements Signer { readonly keyId: string; readonly #key: Buffer; // v2 canonical form: `lane` dropped from the signed array (pool-of-spaces model, // 2026-06-16). v1 events used a different field order and won't verify under v2 — // that's intentional and gated by the per-event keyId. See event-log canonicalize(). constructor(secret: string, keyId = "sw-hmac-v2") { this.#key = Buffer.from(secret, "utf8"); this.keyId = keyId; } sign(payload: string): string { return createHmac("sha256", this.#key).update(payload, "utf8").digest("hex"); } verify(payload: string, signature: string): boolean { const expected = this.sign(payload); // Constant-time compare; bail on length mismatch (timingSafeEqual throws). if (expected.length !== signature.length) return false; return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex")); } } /** * Build the process signer. Uses EVENT_SIGNING_KEY (HMAC secret). Falls back to * the JWT secret only as a last resort so dev works out of the box — logged as a * warning, because reusing the auth secret for event signing is not ideal. * * TODO(atecc608): when the secure element is wired, return an Atecc608Signer here * (keyId "atecc608-slotN"); existing events stay verifiable via their stored keyId. */ export function buildSigner(log?: { warn: (msg: string) => void }): Signer { const dedicated = process.env.EVENT_SIGNING_KEY; if (dedicated && dedicated.length >= 16) { return new SoftwareSigner(dedicated); } const jwtSecret = process.env.JWT_SECRET; if (jwtSecret && jwtSecret.length >= 16) { log?.warn( "event signing: EVENT_SIGNING_KEY unset — falling back to JWT_SECRET. Set a dedicated key (and wire the ATECC608) before production.", ); return new SoftwareSigner(jwtSecret, "sw-hmac-jwtfallback"); } throw new Error( "event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.", ); } /** * Resolve the signer that can VERIFY an existing event, by its stored `keyId`. * Appends always use the one signer from buildSigner(), but a chain can contain * events signed under different keys across a rotation (e.g. the JWT_SECRET * fallback before a dedicated EVENT_SIGNING_KEY was set, or an ATECC608 swap). * Each event stores its own `keyId`, so verifyChain() must check each row against * the key that produced it — not the current append-signer. Returns undefined for * an unknown keyId (the key is gone / not configured), which verifyChain surfaces * as a distinct failure rather than a false "tampered" alarm. * * TODO(atecc608): add an "atecc608-slotN" case returning a public-key verifier. */ export function buildVerifier(keyId: string): Signer | undefined { switch (keyId) { case "sw-hmac-v2": { const k = process.env.EVENT_SIGNING_KEY; return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-v2") : undefined; } case "sw-hmac-jwtfallback": { const k = process.env.JWT_SECRET; return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-jwtfallback") : undefined; } default: return undefined; } }