import { createHash, randomUUID } from "node:crypto"; import { desc, ledgerEvents, type Db, type LedgerEventRow } from "@parking/db"; import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, 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: LedgerEventType; readonly direction?: Direction | null; readonly source?: IdentitySource | null; readonly identity?: string | null; /** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */ readonly payload?: LedgerPayload | 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; source: string | null; identity: string | null; payload: Record | null; occurredAt: string; prevHash: string | null; }): string { return JSON.stringify([ e.index, e.type, e.direction ?? null, e.source ?? null, e.identity ?? null, // Payload is part of the signed form so business data is tamper-evident. // Serialize with sorted keys for byte-stability (object key order must not // change a signature). null when the event type carries no payload. canonicalPayload(e.payload), e.occurredAt, e.prevHash ?? null, ]); } /** Deterministic (key-sorted, recursive) JSON for the payload slot. */ function canonicalPayload(p: Record | null | undefined): unknown { if (p == null) return null; const sort = (v: unknown): unknown => { if (Array.isArray(v)) return v.map(sort); if (v && typeof v === "object") { return Object.keys(v as Record) .sort() .reduce>((o, k) => { o[k] = sort((v as Record)[k]); return o; }, {}); } return v; }; return sort(p); } /** 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"); } /** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier). * Returns undefined when the key that signed an event is not available. */ export type SignerResolver = (keyId: string) => Signer | undefined; export class EventLog { readonly #db: Db; readonly #signer: Signer; /** Picks the verifying signer per event keyId; lets a chain span key rotations * (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for * callers that don't pass one (single-key chains, tests). */ readonly #resolveVerifier: SignerResolver; /** Optional read-side notification, fired AFTER a row is durably inserted. Used * to fan the event out to live booth clients (WS). It is best-effort and must * NOT influence the append/sign/chain path — a throwing/absent sink is ignored. */ readonly #onAppended?: (row: LedgerEventRow) => void; /** Serialize appends: each waits for the previous to finish. */ #tail: Promise = Promise.resolve(); constructor( db: Db, signer: Signer, resolveVerifier?: SignerResolver, onAppended?: (row: LedgerEventRow) => void, ) { this.#db = db; this.#signer = signer; this.#resolveVerifier = resolveVerifier ?? (() => signer); this.#onAppended = onAppended; } /** 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); // Read-side notification, AFTER the row is durably written. Wrapped so a // failing sink can never reject the append or break the chain lock above. return run.then((row) => { try { this.#onAppended?.(row); } catch { // best-effort fan-out only — swallow. } return row; }); } #appendNow(input: AppendInput): LedgerEventRow { const prev = this.#db .select() .from(ledgerEvents) .orderBy(desc(ledgerEvents.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 payload = input.payload ?? null; const canonical = canonicalize({ index, type: input.type, direction: input.direction ?? null, source: input.source ?? null, identity: input.identity ?? null, payload, occurredAt, prevHash, }); const row = { id: randomUUID(), index, type: input.type, direction: input.direction ?? null, source: input.source ?? null, identity: input.identity ?? null, payload, occurredAt, prevHash, signature: this.#signer.sign(canonical), keyId: this.#signer.keyId, }; this.#db.insert(ledgerEvents).values(row).run(); return row as LedgerEventRow; } /** * 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), a forged/invalid signature, and an event signed under a key * that is no longer configured. * * Each row is verified against the signer for ITS OWN `keyId`, not the current * append signer — so a chain that spans a key rotation (e.g. early events under * the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still * verifies end to end. See signer.buildVerifier. */ verifyChain(): { ok: true } | { ok: false; index: number; reason: string } { const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.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 verifier = this.#resolveVerifier(row.keyId); if (!verifier) { return { ok: false, index: row.index, reason: `no signer for keyId "${row.keyId}" (key not configured)`, }; } const canonical = canonicalize(row); if (!verifier.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 }; } }