Dingtian input push: HTTP Digest auth + auto-config on assign

Secure the device→backend input push, and configure it automatically when the
admin assigns the device (no manual URL/secret entry).

Auth — HTTP Digest (chosen by hardware testing: the device can't push to a
self-signed HTTPS backend, but does Digest correctly; a URL token is sniffable/
logged):
- digest-auth.ts: MD5 qop=auth challenge/verify, single-use nonces (replay
  resistance). Password never crosses the wire.
- push route: Digest + source-IP allowlist; per-device pushUser/pushPassword from
  lane_devices. Still not behind the SPA cookie/CSRF auth (machine call). The
  signed event log remains the real anti-fraud guarantee.

Auto-config on assign:
- setup assign: for push-capable devices, generate Digest creds, call
  configureInputPush to write them + the push URLs to the device, store the creds
  (password not echoed back). net.ts derives the backend IP on the device's
  subnet (BACKEND_HOST_IP override).
- driver configureInputPush sets auth=2 + creds; PushConfig carries the creds.
- removed the earlier URL-token approach.

Two hard-won device-write bugs fixed in the driver:
- configApi now sets an explicit Content-Length — the device silently ignores
  chunked request bodies (Node's default without Content-Length), so every config
  write looked successful ({"status":0}) but did nothing. This was the root cause
  of the session's "writes don't apply" mystery.
- #writeConfig polls until the change is verified, retrying (the device reboots on
  apply; back-to-back writes were lost). The `pass` field caps at 31 chars, so the
  generated password is 24 hex chars.

Verified on hardware: assign auto-configures the device; all 4 inputs then push
with Digest auth, zero failures. wiki/device-input-flow updated.
This commit is contained in:
2026-06-14 16:39:08 +02:00
parent 23919164ee
commit 3294f188dd
9 changed files with 403 additions and 77 deletions
+37 -3
View File
@@ -42,12 +42,46 @@ the device or the network. Instead:
allows), there is **no matching signed event → a detectable anomaly**. The anti-fraud guarantee
is the **signed log**, not device/network auth.
- The inbound push endpoint is intentionally **not behind the SPA's cookie/CSRF auth** (it's a
machine call from the device). A **shared-secret / Basic-auth** on the push is available as
defence-in-depth (the device supports it) — worth adding, but it is *not* the security boundary.
machine call from the device). It is guarded by **HTTP Digest auth** + a **source-IP allowlist**
(defence-in-depth), but these are *not* the security boundary.
- This sharpens under the [[autonomous-direction|unmanned]] roadmap: with no operator, tamper
detection via the signed log matters more than perimeter auth.
## Push authentication — Digest (decided by hardware testing)
The secret must not be in the URL (sniffable, logged) and the password must not cross the wire in
the clear. We **empirically tested the device** to pick the strongest achievable option:
| Option | Device result |
| --- | --- |
| HTTPS (self-signed) | ❌ device won't push to a self-signed cert |
| **Digest auth** (`auth=2`) | ✅ **works** — full 401-nonce challenge/response |
| Basic auth | ✅ works (but password base64 on the wire) |
| URL token | rejected by design (visible in URL/logs) |
→ **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces
are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are
generated by the backend on **device assign**, written to the device's `input_link_url` config,
and stored in `lane_devices` — the admin never types a URL or secret. HTTPS would be stronger but
the device can't do it here; Digest + the signed log is the practical answer on a flat network.
See `apps/server/src/digest-auth.ts`.
## Dingtian config-write gotchas (cost a lot of debugging)
Writing the device's config API (`/api/v2/config_set.cgi`) has two non-obvious traps — both now
handled in the driver:
1. **Content-Length is mandatory.** The device's embedded HTTP server does **not** accept chunked
request bodies. Node uses chunked encoding when `Content-Length` is absent, so the device
silently ignores the body and returns `{"status":0}` anyway — the write looks successful but
nothing changes. Always set `Content-Length`.
2. **The `pass` field caps at 31 chars** (longer is silently truncated → Digest mismatch). The
generated push password is 24 hex chars (96 bits).
3. (Also: the device reboots on apply, so the driver writes then **polls until the change is
verified**, retrying — back-to-back writes onto a rebooting device are lost.)
## Status
Input push **verified on hardware** (all 4 inputs, real presses reaching the backend). The entry
Input push **verified on hardware** with Digest auth (all 4 inputs, real presses authenticated, no
failures). The entry
flow itself (signed event + ticket print + `pulseOpen`) is the next build — see [[dingtian-relay]].
+17
View File
@@ -145,3 +145,20 @@ model recorded in [[device-input-flow]]: flat network / no VLAN → backend is s
of truth, every open is a signed event (out-of-band open = anomaly); push endpoint
not behind cookie auth (machine call), shared-secret available as defence-in-depth.
Next: wire signed event + ticket print + pulseOpen.
## [2026-06-15] feature | Dingtian push auth via HTTP Digest (hardware-tested)
Secured the device→backend input push. Empirically tested auth options on the
device: HTTPS-to-self-signed FAILS, Basic works, **Digest works** → chose Digest
(MD5, qop=auth): password never on the wire, single-use nonces. Backend
digest-auth.ts (challenge/verify) + source-IP allowlist on the push route;
per-device pushUser/pushPassword generated on assign, written to the device and
stored in lane_devices (admin never types a URL/secret). Driver
configureInputPush now sets auth=2 + creds; the assign flow auto-configures the
device and persists the creds (net.ts derives the backend IP on the device's
subnet). Removed the earlier URL-token approach (token in URL is sniffable/logged).
TWO HARD-WON DEVICE BUGS fixed: (1) config_set requires an explicit Content-Length
— the device silently ignores chunked bodies (Node's default), which masqueraded
as "writes don't apply" all session; (2) the `pass` field caps at 31 chars →
use a 24-char password. Driver #writeConfig now polls-until-verified (device
reboots on apply). VERIFIED on hardware: assign auto-configures the device, then
all 4 inputs push with Digest auth, zero failures. Recorded in [[device-input-flow]].