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:
@@ -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.
|
||||
|
||||
@@ -79,17 +79,42 @@ the relay via UDP. See [[device-input-flow]] for the full path + trust model.
|
||||
> real path** — lower latency, and it can be authenticated (the device supports Basic/Digest +
|
||||
> HTTPS on the push), unlike the open UDP control direction.
|
||||
|
||||
### What it pushes vs. doesn't (logging)
|
||||
|
||||
- **Inputs (buttons): YES, pushed.** Input changes are HTTP-pushed via `input_link_url` and now
|
||||
land in the host's signed [[append-only-event-chain]] as `input_received` events (bus →
|
||||
`EventLog`). That is the audit trail for "a button fired."
|
||||
- **Relay / barrier opens: NO push, no log.** The device has **no event log of its own** and does
|
||||
not report when a relay fires — relay control is one-way UDP that the *host* initiates. So
|
||||
"the barrier opened" is not something to scrape from the device. The host records what it
|
||||
*commanded* (a future `barrier_open_command` event); a relay open with **no matching signed
|
||||
host event is itself the anomaly** to alarm on ([[threat-model]]). Do not treat the Dingtian as
|
||||
a log source — it is a dumb relay+input board; the host is the source of truth.
|
||||
|
||||
## Hardening (`harden()`) — and why HTTP auth is not a boundary here
|
||||
|
||||
On assign the driver runs `harden()` (the [[device-registry|HardenableDevice]] capability):
|
||||
1. **`relay_pw`** — set a random relay password so binary relay commands (UDP 60000) need it.
|
||||
2. **Disable unused channels** — set `p:255` on rs485/can/tcp×2/mqtt; keep only UDP1 binary
|
||||
(relay control) + UDP2 string (status read).
|
||||
2. **Disable EVERY other channel** — set `p:255` on the string protocol (udp2), rs485, can,
|
||||
tcp×2, mqtt; keep **only** UDP1 binary, which carries `relay_pw` for both control AND status.
|
||||
3. **Rotate the `admin`/`admin` web login** — `GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>&`
|
||||
(response `&0&…&` = success, verified on hardware). The new password is stored back in
|
||||
config (`webUser`/`webPassword`) so a re-run can rotate again (the device checks the *old*
|
||||
creds). This step is **best-effort** — a failure logs and does not fail the assign.
|
||||
|
||||
> ⚠️ **The string protocol (udp2) is a password-less relay-fire path — the original `harden()`
|
||||
> left it ENABLED "for status reads", which was a real hole.** The Dingtian string protocol has
|
||||
> NO password field and can fire relays (`"11"` = relay 1 on, `"21"` = off, `"11*"` = jog).
|
||||
> **Proven on hardware**: sending `"11"` to UDP 60001 with no credentials opened relay 1,
|
||||
> completely bypassing `relay_pw`. Fixes: (a) status reads moved to the **authenticated binary
|
||||
> read** (relay command `0x00`) so the string protocol is no longer needed; (b) `harden()` now
|
||||
> sets `udp2.p=255` to disable it. **Firmware caveat (V3.6J):** the CONFIG API silently refuses
|
||||
> to disable udp2 — it accepts the write, reboots, and clamps it back — even though the device's
|
||||
> **web UI can** disable it. So the udp2 disable is **best-effort + warns** (it is NOT part of the
|
||||
> blocking verify); if it doesn't stick, `harden()` returns a warning telling the admin to flip
|
||||
> UDP2 off in the device web UI. Verified: after the web-UI disable, the `"11"` attack gets no
|
||||
> reply and the relay stays off, while authenticated binary control/status still work.
|
||||
|
||||
> ⚠️ **The device CGI API is UNAUTHENTICATED.** Verified on hardware: `GET /api/v2/config.cgi`,
|
||||
> `/`, and even `/userset.cgi` all return **200 with no credentials**. The `admin`/`admin` login
|
||||
> gates only the interactive **browser UI** — the CGI control plane (read/write full config, fire
|
||||
|
||||
+36
@@ -238,3 +238,39 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
||||
- Verified via Fastify inject: 2 printers assigned to one lane -> both listed, no secret leak,
|
||||
delete -> 204, delete unknown -> 404, count drops to 1. Full repo typechecks (8/8).
|
||||
- Updated [[first-run-setup]].
|
||||
|
||||
## [2026-06-15] ingest | Append-only signed event log (Dingtian input pushes persist)
|
||||
- Q: does the Dingtian push events? -> inputs YES (input_link_url), relay opens NO (device keeps
|
||||
no log). Host is the source of truth; a relay open w/o matching signed event is the anomaly.
|
||||
- Implemented EventLog (apps/server/event-log.ts): serialized append, monotonic index, prevHash
|
||||
chain, signature; verifyChain() detects tamper/reorder/delete. Read: GET /api/events;
|
||||
integrity: GET /api/events/verify (admin).
|
||||
- Signer abstraction (packages/shared) over the ATECC608; SoftwareSigner (HMAC, EVENT_SIGNING_KEY)
|
||||
shipped now since chip wiring is open-question #6. Caveat documented: software signer is
|
||||
tamper-evident but NOT unforgeable-by-owner.
|
||||
- Wired bus -> log: Dingtian input pushes become input_received events (lane mapping TODO).
|
||||
- Added ParkingEventType 'input_received'.
|
||||
- Verified via inject: push w/o digest -> 401; pushes -> 2 signed+chained events; verify -> ok;
|
||||
direct DB tamper -> verifyChain catches at the right index; deleted row -> index gap. 5 concurrent
|
||||
appends -> indices 1..5 intact. Full repo typechecks.
|
||||
- Updated [[append-only-event-chain]], [[dingtian-relay]].
|
||||
|
||||
## [2026-06-15] ingest | Event log + Dingtian string-protocol security fix
|
||||
- Append-only signed event log shipped (EventLog, Signer abstraction over ATECC608 w/ SoftwareSigner
|
||||
HMAC; GET /api/events + /api/events/verify). Dingtian input pushes persist as input_received.
|
||||
Verified on hardware: shorting I1-I4 -> 8 signed+chained events, verifyChain ok.
|
||||
- SECURITY (verified on hardware): the password-less string protocol (udp2) can fire relays
|
||||
("11" -> relay1 on) with NO auth, bypassing relay_pw. Fixes: status reads moved to authenticated
|
||||
binary read (cmd 0x00); harden() disables udp2 BEST-EFFORT (firmware V3.6J config API refuses,
|
||||
but web UI works) and returns a warning instead of throwing. After web-UI disable, the "11" attack
|
||||
is dead and binary control/status still work.
|
||||
- GAP (user-identified): event log captures host-originated actions only; out-of-band relay
|
||||
actuation (sniffed relay_pw, string protocol, ip_watchdog) produces NO event — proven on hardware.
|
||||
Real control is reconciliation vs. an independent witness; witness+reconciliation NOT yet built.
|
||||
- Device web login (webUser/webPassword) now un-redacted in setup state (admin-only device area);
|
||||
pushPassword/relayPassword stay machine-only.
|
||||
- harden() warnings surfaced via the assign response.
|
||||
- localAddress threaded through the Dingtian driver (device-facing-IP foundation; multi-homed hosts).
|
||||
- INCIDENT: probing default.cgi factory-reset the bench device (now at 192.168.1.100, defaults).
|
||||
Re-provisioning is the ADMIN's job via First-run setup (app must not hardcode site IPs).
|
||||
- Updated [[append-only-event-chain]], [[dingtian-relay]].
|
||||
|
||||
Reference in New Issue
Block a user