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
8.3 KiB
type, tags, sources, updated
| type | tags | sources | updated | ||||
|---|---|---|---|---|---|---|---|
| concept |
|
|
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:
- Append-only event model. Entry/exit events are never edited or deleted, only appended. A "void" is itself a recorded event, not an erasure.
- Tamper-evident chaining. Each event stores the hash of the previous event (a hash chain). Reordering or deleting breaks the chain visibly.
- 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-gradebarrier_open_command/barrier_open_observedandanomaly. This is the anti-fraud record that reconciliation runs against; sessions/tariff/occupancy are projections over it. (This is the table formerly calledevents.)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 signedvehicle_entryin the ledger once a ticket prints and the barrier is commanded. (This supersedes the earlier "every device event lands in the chain" framing and theinput_received-as-signed-event approach — see device-input-flow.)
Implementation (apps/server)
Implementation-derived. The schema (
packages/dbevents) and types (packages/sharedParkingEvent) predate this; the writer/signer are new.
EventLog(apps/server/src/event-log.ts) is the append primitive.append()reads the latest row, setsindex = 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
EventLogalso guards it with an in-process async lock — otherwise two near- simultaneous events could claim the sameindexor chain off a staleprevHash. 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 rowidis excluded; chain identity isindex+ content. verifyChain()walks oldest→newest, recomputing hashes + signatures. Catches tampered content (bad signature), reordering / a deleted row (indexgap), and aprevHashmismatch. Exposed atGET /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.sqliteon another machine, and rebooting: any blind edit/delete/reorder breaks the chain andverifyChain()pinpoints it (bad signature / index gap / prevHash mismatch / unknown keyId). But two gaps: (a) nothing runsverifyChain()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;identitycarries 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_pwbinary 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_commandpath 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.