d0536da3d7
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
133 lines
8.3 KiB
Markdown
133 lines
8.3 KiB
Markdown
---
|
|
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 a non-extractable key in a secure element
|
|
> ([[atecc608]] on embedded, or the host **[[tpm|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|TPM-sealed]] 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|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.
|