Files
parking_solution/wiki/concepts/append-only-event-chain.md
T
julian d0536da3d7 feat(desktop): Tauri v2 kiosk shell — maximized window, prod right-click block, auto-update + code-signing
Add apps/desktop, a thin Tauri v2 shell wrapping the SAME @parking/web SPA so
the desktop and browser UIs never drift: dev loads the Vite dev server (HMR),
prod bundles the web app's dist/. No business logic in the shell (device/auth/
ledger stay in @parking/server); deny-by-default capabilities.

apps/web (single UI source of truth):
- lib/origin.ts: centralize the backend origin (API_BASE/apiUrl/wsUrl from
  VITE_API_BASE); no-op in the browser, lets the desktop build target Fastify.
- lib/kiosk.ts: block the right-click context menu in PROD only (dev keeps it +
  devtools).
- lib/desktop-updater.ts: prompt-on-update auto-update (no-op in browser/offline)
  → downloadAndInstall + relaunch; i18n update.* keys (sq+en).
- .env.production: VITE_API_BASE wired to the Fastify origin for the bundle.

Desktop:
- window starts maximized (not fullscreen — operator keeps OS access).
- auto-update via tauri-plugin-updater + -process; self-hosted endpoint is a
  PLACEHOLDER to fill in. Updater keypair: pubkey embedded in tauri.conf.json;
  private key + password kept OUTSIDE the repo (~/.parking-updater-keys) and as
  TAURI_SIGNING_* build secrets.
- Turbo build is a no-op; the real signed bundle is `pnpm --filter
  @parking/desktop bundle` (verified → .deb/.rpm/.AppImage + .sig signatures).

Verified: cargo check clean; turbo run build lint 14/14 green; i18n parity holds;
no key/sig/bundle artifacts in the repo.

Wiki (security + desktop analysis recorded alongside):
- new concepts/tpm.md (TPM 2.0: how it works, sealed-LUKS auto-unlock + non-
  extractable signing key, limits — live-root, bus-sniff — TPM-vs-ATECC608 by
  platform).
- new decisions/desktop-shell-tauri.md (Tauri v2 over Electron; best-case Ubuntu
  26.04 LTS, worst-case Windows+WSL → kiosk browser; full as-built).
- pull-the-disk attack trace on append-only-event-chain; ATECC608 not-in-a-PC
  caveat; cross-links from disk-os-hardening / threat-model.
- open-questions #11 (appliance WebKitGTK), #12 (TPM hardening impl), #13
  (startup verifyChain self-check); index/overview/log/standing-decisions.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 12:21:49 +02:00

8.3 KiB

type, tags, sources, updated
type tags sources updated
concept
parking
security
integrity
parking-system-architecture
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, 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 a non-extractable key in a secure element (atecc608 on embedded, or the host tpm on a PC appliance) gives property (3) above. Until that is wired, the chain detects tampering by outsiders and accidental corruption, but an operator (or anyone who pulls the SSD and reads the .env) has the HMAC key and could edit a row and re-sign the whole chain undetectably. This is the central reason #6 matters.

Pull-the-disk attack (traced 2026-06-21). Removing the SSD, editing parking.sqlite on another machine, and rebooting: any blind edit/delete/reorder breaks the chain and verifyChain() pinpoints it (bad signature / index gap / prevHash mismatch / unknown keyId). But two gaps: (a) nothing runs verifyChain() at startup today — the tamper is detectable but undetected until something invokes verification (wire a boot-time self-check that at least logs/flags a signed alarm — fail-open on exit still governs; this is open-questions #13); and (b) with the software signer the key is on the same disk, so the attacker can re-sign and pass verification — only a secure-element key (tpm/atecc608) closes that. tpm LUKS additionally stops the disk mounting off-host at all.

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 (table split done)

The split above is implemented: raw Dingtian input (button) pushes are device telemetry in device_events (unsigned, prunable), keyed to the firing devices instance. Only the business vehicle_entry the press drives is signed into ledger_events. The signed events carry no lane — the pool-of-spaces model has none (dropped 2026-06-16; see entry-exit-points), and the canonical form bumped sw-hmac-v1 → sw-hmac-v2 accordingly.

⚠️ 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'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.