--- type: concept tags: [parking, security, integrity] sources: [parking-system-architecture] updated: 2026-06-15 --- # Append-Only Event Chain The core integrity mechanism against operator fraud (see [[threat-model]]). (See [[parking-system-architecture]] §3.) Three layered properties: 1. **Append-only event model.** Entry/exit events are never edited or deleted, only appended. A "void" is itself a **recorded event**, not an erasure. 2. **Tamper-evident chaining.** Each event stores the **hash of the previous event** (a hash chain). Reordering or deleting **breaks the chain visibly**. 3. **Hardware-backed signing.** The **[[atecc608]]** secure element signs each event with a non-extractable key. This is what makes the chain **unforgeable** rather than merely self-consistent — someone who owns the machine still cannot forge a valid entry. It only becomes trustworthy as an external fraud control when paired with [[reconciliation]] against an authority the operator can't alter. ## Two event streams — the signed ledger vs. device telemetry (decision 2026-06-15) These are **different concerns and live in different tables**: - **`ledger_events`** — this signed, hash-chained, [[atecc608]]-signed **business ledger**: `vehicle_entry` / `vehicle_exit` / `payment` / `void` / `shift_z_report`, plus the witness-grade `barrier_open_command` / `barrier_open_observed` and `anomaly`. This is the anti-fraud record that [[reconciliation]] runs against; sessions/[[tariff]]/occupancy are projections over it. (This is the table formerly called `events`.) - **`device_events`** — **unsigned operational telemetry**: relay fired, printer paper-out, camera offline, reader read, raw input edges. High-volume, churny, **not** anti-fraud; may rotate/prune. Keeping it out of the signed chain keeps the ledger small and high-value. > A raw button press is **device telemetry**, not a business fact. It lands in `device_events`; the > entry flow then mints a **signed `vehicle_entry`** in the ledger once a ticket prints and the > barrier is commanded. (This supersedes the earlier "every device event lands in the chain" framing > and the `input_received`-as-signed-event approach — see [[device-input-flow]].) ## Implementation (apps/server) > Implementation-derived. The schema (`packages/db` `events`) and types > (`packages/shared` `ParkingEvent`) predate this; the writer/signer are new. - **`EventLog`** (`apps/server/src/event-log.ts`) is the append primitive. `append()` reads the latest row, sets `index = prev + 1`, `prevHash = sha256(canonical(prev))` (genesis = null), signs the canonical form, and inserts. There are **no update/delete paths**. - **Serialized appends.** SQLite is single-writer, but read-prev → compute-hash → insert is multi-step, so `EventLog` also guards it with an in-process async lock — otherwise two near- simultaneous events could claim the same `index` or chain off a stale `prevHash`. Verified: 5 concurrent appends produced indices 1..5 with an intact chain. - **Canonical form** is a fixed-order JSON array (`index,type,direction,lane,source,identity, occurredAt,prevHash`) — byte-stable, since the chain + signatures depend on it. The volatile row `id` is excluded; chain identity is `index` + content. - **`verifyChain()`** walks oldest→newest, recomputing hashes + signatures. Catches tampered content (bad signature), reordering / a deleted row (`index` gap), and a `prevHash` mismatch. Exposed at `GET /api/events/verify` (admin). Read access to the log: `GET /api/events`. ### The `Signer` abstraction (software now, ATECC608 later) Signing goes through a **`Signer`** interface (`packages/shared`) — the abstraction over the [[atecc608]]. Because the chip being wired is still [[open-questions|open-question #6]], the server ships a **`SoftwareSigner`** (HMAC-SHA256, key from `EVENT_SIGNING_KEY`). Swapping to the secure element is a new `Signer` impl with no `EventLog` change; each event stores its `keyId` so old events stay verifiable. > ⚠️ The software signer makes the chain **self-consistent + tamper-evident**, but **not > unforgeable by someone who owns the host** — only the ATECC608's non-extractable key gives > property (3) above. Until the chip is wired, the chain detects tampering by *outsiders* and > *accidental* corruption, but an operator with the signing key + DB access could re-sign a > forged chain. This is the central reason #6 matters. ### Business-layer event types (the ledger) The [[parking-session]] domain folds over these **signed ledger** events: - `vehicle_entry` / `vehicle_exit` — a stay's endpoints; `identity` carries the ticket id or plate. - `payment` — a settled fee at the pay station, referencing the session it pays for (amount in integer minor units; see [[tariff]]). Making "paid" a signed event — not a mutable row — is the whole point: an operator can't forge it or silently delete it. - `void` — a correction / lost-ticket write-off; like every other void here it is an **appended event, never an erasure**. - `shift_z_report` — the signed per-[[shift]] takings summary. A session is a **projection** over this chain, never a mutable table — the same anti-fraud reason the chain exists. See [[parking-session]]. ### ⚠️ As-built vs. the table split (pending) The current code records Dingtian **input (button) pushes** as `input_received` rows **in the signed chain** (with `lane` resolved via the `LaneMap`, `source` null, device provenance in `identity`). Per the 2026-06-15 split (above), a raw button press is **device telemetry** and belongs in **`device_events`**, *not* the signed ledger — only the business `vehicle_entry` it drives gets signed. So `input_received`-in-the-ledger is **transitional**; the pending refactor moves raw inputs to `device_events` and renames the chain table to `ledger_events`. (`LaneMap` lane-resolution and the "never stamp `lane: 0` for an unmapped device" rule carry over to whichever stream records the event.) ### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only The event log records what the **host** did (inputs it received, opens it commanded). It is **blind to out-of-band relay actuation** — anything that fires a relay without going through the host. **Proven on hardware**: a binary relay command sent directly to the device with the (sniffable) `relay_pw` fired a relay and produced **zero** events. Out-of-band paths include: - the **password-less string protocol** (until disabled — see [[dingtian-relay]]), - a **sniffed/replayed `relay_pw`** binary command (plaintext UDP — relay control is defence-in-depth, **not** a boundary), - the device's own **`ip_watchdog`** (auto-toggles a relay on ping-failure — must stay disabled), - a future **`barrier_open_command`** path is host-side and *would* log; these bypass it. So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** — the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an **independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input → which DOES push + log; the [[opencv-anpr-service|vision service]]'s plate **and vehicle** read; payment/Z-report). **A physical open with no matching signed command is the fraud signal** — and, with vehicle verification, **a plate that enters/exits on a different car** is too (the plate-spoofing case). Both the witness sources and the reconciliation logic are **NOT yet built** — this is the main open gap. Prevention (VLAN isolation so the attacker can't reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop.