diff --git a/apps/server/src/event-log.ts b/apps/server/src/event-log.ts new file mode 100644 index 0000000..0784eef --- /dev/null +++ b/apps/server/src/event-log.ts @@ -0,0 +1,147 @@ +import { createHash, randomUUID } from "node:crypto"; +import { desc, events, type Db, type EventRow } from "@parking/db"; +import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared"; + +// The append-only, hash-chained, signed event log — the system's core anti-fraud +// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device +// events are NEVER edited or deleted; a correction/void is a new appended row. +// +// Integrity rules enforced here: +// - monotonic `index` (prev + 1; the unique constraint is the backstop), +// - `prevHash` = hash of the previous row's canonical form (genesis = null), +// - `signature` = signer.sign(canonical) over a STABLE field ordering, +// - appends are SERIALIZED: read-prev -> compute-hash -> insert must not +// interleave, or two events could claim the same index / chain off a stale +// prev. SQLite is single-writer, but the read+compute+insert is multi-step, +// so we guard it with an in-process async lock as well. + +export interface AppendInput { + readonly type: ParkingEventType; + readonly lane: number; + readonly direction?: Direction | null; + readonly source?: IdentitySource | null; + readonly identity?: string | null; + /** Event time (ISO-8601). Defaults to now. */ + readonly occurredAt?: string; +} + +/** + * Canonical serialization of an event's signed/hashed content. Order is FIXED + * and explicit — the hash chain and signatures depend on byte-stable output, so + * this must never change for already-written events (versioned via keyId if it + * ever must). The volatile DB row id is deliberately excluded; identity in the + * chain is `index` + content. + */ +export function canonicalize(e: { + index: number; + type: string; + direction: string | null; + lane: number; + source: string | null; + identity: string | null; + occurredAt: string; + prevHash: string | null; +}): string { + return JSON.stringify([ + e.index, + e.type, + e.direction ?? null, + e.lane, + e.source ?? null, + e.identity ?? null, + e.occurredAt, + e.prevHash ?? null, + ]); +} + +/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */ +export function hashEvent(canonical: string): string { + return createHash("sha256").update(canonical, "utf8").digest("hex"); +} + +export class EventLog { + readonly #db: Db; + readonly #signer: Signer; + /** Serialize appends: each waits for the previous to finish. */ + #tail: Promise = Promise.resolve(); + + constructor(db: Db, signer: Signer) { + this.#db = db; + this.#signer = signer; + } + + /** Append one event to the chain. Returns the persisted row. Serialized. */ + append(input: AppendInput): Promise { + const run = this.#tail.then(() => this.#appendNow(input)); + // Keep the chain going even if one append rejects (don't wedge the lock). + this.#tail = run.catch(() => undefined); + return run; + } + + #appendNow(input: AppendInput): EventRow { + const prev = this.#db + .select() + .from(events) + .orderBy(desc(events.index)) + .limit(1) + .get(); + + const index = (prev?.index ?? 0) + 1; + const prevHash = prev ? hashEvent(canonicalize(prev)) : null; + const occurredAt = input.occurredAt ?? new Date().toISOString(); + + const canonical = canonicalize({ + index, + type: input.type, + direction: input.direction ?? null, + lane: input.lane, + source: input.source ?? null, + identity: input.identity ?? null, + occurredAt, + prevHash, + }); + + const row = { + id: randomUUID(), + index, + type: input.type, + direction: input.direction ?? null, + lane: input.lane, + source: input.source ?? null, + identity: input.identity ?? null, + occurredAt, + prevHash, + signature: this.#signer.sign(canonical), + }; + + this.#db.insert(events).values(row).run(); + return row as EventRow; + } + + /** + * Walk the chain oldest→newest and recompute hashes + signatures. Returns the + * first detected break, or { ok: true }. This is what reconciliation and an + * integrity self-check call. Catches: tampered content, reordering, a deleted + * row (index gap), and a forged/invalid signature. + */ + verifyChain(): { ok: true } | { ok: false; index: number; reason: string } { + const rows = this.#db.select().from(events).orderBy(events.index).all(); + let expectedIndex = 1; + let prevHash: string | null = null; + for (const row of rows) { + if (row.index !== expectedIndex) { + return { ok: false, index: row.index, reason: `index gap: expected ${expectedIndex}` }; + } + if ((row.prevHash ?? null) !== prevHash) { + return { ok: false, index: row.index, reason: "prevHash does not match chain" }; + } + const canonical = canonicalize(row); + if (!this.#signer.verify(canonical, row.signature)) { + return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" }; + } + prevHash = hashEvent(canonical); + expectedIndex += 1; + } + return { ok: true }; + } +} diff --git a/apps/server/src/routes/events.ts b/apps/server/src/routes/events.ts new file mode 100644 index 0000000..09ac71a --- /dev/null +++ b/apps/server/src/routes/events.ts @@ -0,0 +1,38 @@ +import type { FastifyInstance } from "fastify"; +import { desc, events, type Db } from "@parking/db"; +import { requireRole } from "../auth.js"; +import type { EventLog } from "../event-log.js"; + +// Read access to the append-only signed event log. NO write/update/delete routes +// exist by design — events are only ever appended internally (entry flow, device +// pushes). Corrections are new appended events, never edits. See +// wiki/concepts/append-only-event-chain.md. + +export async function eventRoutes( + app: FastifyInstance, + db: Db, + eventLog: EventLog, +): Promise { + // Any authenticated role may read the log (it's the audit trail). + const guard = requireRole("admin", "operator", "cashier", "readonly"); + + // Recent events, newest first. `limit` caps the page (default 100, max 1000). + app.get<{ Querystring: { limit?: string } }>( + "/api/events", + { preHandler: guard }, + async (req) => { + const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000); + const rows = db.select().from(events).orderBy(desc(events.index)).limit(limit).all(); + return { events: rows }; + }, + ); + + // Integrity self-check: walk the chain and verify hashes + signatures. Admin- + // only (it's an audit action). Returns the first break, or ok. This is what a + // reconciliation job / "is the log intact?" check calls. + app.get( + "/api/events/verify", + { preHandler: requireRole("admin") }, + async () => eventLog.verifyChain(), + ); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index dc53770..24bad88 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -3,9 +3,13 @@ import jwt from "@fastify/jwt"; import Fastify, { type FastifyInstance } from "fastify"; import { createDb, type Db } from "@parking/db"; import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js"; +import { deviceEvents } from "./device-events.js"; +import { EventLog } from "./event-log.js"; import { PrinterMonitor } from "./printer-monitor.js"; +import { buildSigner } from "./signer.js"; import { authRoutes } from "./routes/auth.js"; import { deviceRoutes } from "./routes/devices.js"; +import { eventRoutes } from "./routes/events.js"; import { printerRoutes } from "./routes/printers.js"; import { setupRoutes } from "./routes/setup.js"; @@ -59,7 +63,27 @@ export async function buildServer(opts: BuildOptions = {}): Promise printerMonitor.start()); app.addHook("onClose", async () => printerMonitor.stop()); - // TODO: entry flow (input event → signed event → print → relay), event-log routes. + // Append-only signed event log. Subscribe device pushes (e.g. Dingtian button + // presses) into the hash-chained, signed `events` table — the anti-fraud audit + // trail. The device is NOT trusted; the host record is the source of truth, and + // a relay open with no matching signed event is itself the anomaly. We record + // the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that + // comes with the full entry flow). See wiki/concepts/append-only-event-chain.md. + const eventLog = new EventLog(db, buildSigner(app.log)); + await eventRoutes(app, db, eventLog); + const unsubscribeInput = deviceEvents.onInput((e) => { + eventLog + .append({ + type: "input_received", + lane: 0, // lane mapping is a TODO — device->lane lookup arrives with setup/lane wiring + identity: `${e.driverId}:${e.deviceId} input:${e.input}/${e.edge}`, + occurredAt: e.at, + }) + .catch((err) => app.log.error(`event-log append failed: ${(err as Error).message}`)); + }); + app.addHook("onClose", async () => unsubscribeInput()); + + // TODO: entry flow (input event → signed event → print → relay); map device→lane. return app; } diff --git a/apps/server/src/signer.ts b/apps/server/src/signer.ts new file mode 100644 index 0000000..6ac4ac5 --- /dev/null +++ b/apps/server/src/signer.ts @@ -0,0 +1,57 @@ +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; + + constructor(secret: string, keyId = "sw-hmac-v1") { + 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.", + ); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f4204e6..930cdfd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -34,6 +34,10 @@ export interface ParkingEvent { } export type ParkingEventType = + // A raw device input (e.g. a Dingtian button press) was received and recorded. + // NOT a confirmed entry — the richer `vehicle_entry` is appended later by the + // entry flow once a ticket prints and the barrier is commanded. + | "input_received" | "vehicle_entry" | "vehicle_exit" | "void" @@ -48,3 +52,26 @@ export const ROLES: readonly Role[] = [ "cashier", "readonly", ] as const; + +/** + * Signs the canonical bytes of an event for the append-only chain. This is the + * abstraction over the [[atecc608]] secure element: the real, non-extractable + * hardware key is ONE implementation. Whether the chip is wired is still + * open-question #6, so the server ships a software signer in the meantime — + * same interface, swappable with no business-logic change (the device-adapter + * philosophy applied to signing). See wiki/concepts/append-only-event-chain.md. + * + * IMPORTANT: a software signer makes the chain self-consistent and detectably + * tamper-evident, but NOT unforgeable by someone who owns the machine — only the + * ATECC608 provides that. Don't conflate the two. + */ +export interface Signer { + /** Stable id of the signer/key (e.g. "sw-hmac-v1", "atecc608-slot0"). Stored + * alongside events so verification knows which key to check against. */ + readonly keyId: string; + /** Sign the canonical payload; returns a hex signature. */ + sign(payload: string): string; + /** Verify a signature over the payload (software signers can; the ATECC608 + * verifies via its public key). */ + verify(payload: string, signature: string): boolean; +}