727c62da90
- site_config gains optional park identity (park_name, operator_name, nius, address, phone, email); additive Drizzle migration 0001. GET/PUT /api/site-config read/write the full config (PUT partial patch, admin only); SiteSettings + SetupWizard expose the fields. - renderTicket() prints an Albanian header sourced from site_config, the all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits, and a lost-ticket footer. CP852 codepage so ë/ç render. - Widen the Code128 module width 2->3 and height 80->100 dots so the short-range "Simple" QR/barcode reader decodes reliably (was barely reading at module width 2 on the 80mm head). See wiki/concepts/site-metadata.md and ticket-encoding.md.
88 lines
3.9 KiB
TypeScript
88 lines
3.9 KiB
TypeScript
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;
|
|
}
|
|
}
|