Dingtian: close password-less string-protocol relay-fire hole

The string protocol (UDP 60001) has no password field but can fire relays
("11" = relay 1 on), bypassing relay_pw entirely. Proven on hardware: an
unauthenticated packet opened a relay. harden() had left it enabled "for
status reads".

- #status() now reads via the authenticated binary command (relay cmd 0x00)
  instead of the string protocol, so the string protocol is no longer needed.
- harden() disables the string protocol (udp2.p=255). BEST-EFFORT: firmware
  V3.6J's config API silently refuses to disable udp2 (the device web UI can),
  so it's not part of the blocking verify -- harden() re-checks and returns a
  warning instead of throwing. After a web-UI disable, the attack is dead and
  binary control/status still work (verified on hardware).
- HardenResult gains an optional `warnings[]`; the assign route surfaces them
  to the admin and logs them.
- Corrected the false comment claiming relay_pw stops an attacker (it is
  defence-in-depth on plaintext UDP, not a boundary).
- Thread localAddress through the driver's UDP/HTTP calls so a multi-homed
  host sources device traffic from the device-facing NIC.
- Device web login (webUser/webPassword) is no longer redacted from setup
  state -- it's an operational credential for the admin-only device area;
  pushPassword/relayPassword stay machine-only.

Wiki: document the vuln + fix, the firmware caveat, and the out-of-band
actuation gap (the log captures host actions only; reconciliation vs. an
independent witness is the real control and is not yet built).
This commit is contained in:
2026-06-15 11:29:55 +02:00
parent add5fc0166
commit 7db5cfa0e4
6 changed files with 270 additions and 79 deletions
+61
View File
@@ -24,3 +24,64 @@ It only becomes trustworthy as an external fraud control when paired with [[reco
against an authority the operator can't alter. Every device event — including those ingested
from the [[uhppote-controller]] via [[event-log-ingestion]] — should land in this host-side
chain.
## 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.
### What currently feeds the log
Dingtian **input (button) pushes** → bus → `input_received` events (see [[device-input-flow]],
[[dingtian-relay]]). These are recorded faithfully as raw inputs, **not** as `vehicle_entry` —
the richer entry event waits for the entry flow (ticket print + barrier command). Device→lane
mapping is still a TODO (logged with `lane: 0`).
### ⚠️ 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 [[lpr-camera]]; payment/Z-report). **A physical open with no matching
signed command is the fraud signal.** 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.