From 3294f188dd72df626a84676c81fcdf9f4420ef74 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 14 Jun 2026 16:39:08 +0200 Subject: [PATCH] Dingtian input push: HTTP Digest auth + auto-config on assign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/server/src/digest-auth.ts | 97 +++++++++++++++ apps/server/src/net.ts | 30 +++++ apps/server/src/routes/devices.ts | 98 ++++++++++------ apps/server/src/routes/setup.ts | 55 ++++++++- apps/server/src/server.ts | 6 +- .../devices/src/drivers/access-dingtian.ts | 110 +++++++++++++----- packages/devices/src/interfaces.ts | 27 +++++ wiki/concepts/device-input-flow.md | 40 ++++++- wiki/log.md | 17 +++ 9 files changed, 403 insertions(+), 77 deletions(-) create mode 100644 apps/server/src/digest-auth.ts create mode 100644 apps/server/src/net.ts diff --git a/apps/server/src/digest-auth.ts b/apps/server/src/digest-auth.ts new file mode 100644 index 0000000..39d63ec --- /dev/null +++ b/apps/server/src/digest-auth.ts @@ -0,0 +1,97 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type { FastifyReply, FastifyRequest } from "fastify"; + +// HTTP Digest auth (RFC 2617, MD5, qop=auth) — verified against the Dingtian +// device, which CAN do Digest but CANNOT do HTTPS to a self-signed cert. On +// this flat network Digest is the strongest available push auth: the password +// is never sent (only a nonce-keyed hash). It is defence-in-depth; the signed +// event log is the real anti-fraud guarantee. See wiki/concepts/device-input-flow.md. + +export const DIGEST_REALM = "parking"; + +const md5 = (s: string) => createHash("md5").update(s).digest("hex"); + +/** Nonces we've issued and not yet consumed (single-use → replay resistance). */ +const issuedNonces = new Map(); // nonce → issuedAt (ms epoch is unavailable in scripts but fine at runtime) +const NONCE_TTL_MS = 5 * 60_000; + +function issueNonce(): string { + const nonce = randomBytes(16).toString("hex"); + issuedNonces.set(nonce, Date.now()); + // opportunistic cleanup + if (issuedNonces.size > 1000) { + const cutoff = Date.now() - NONCE_TTL_MS; + for (const [n, t] of issuedNonces) if (t < cutoff) issuedNonces.delete(n); + } + return nonce; +} + +function parseDigest(header: string): Record { + const out: Record = {}; + const re = /(\w+)=(?:"([^"]*)"|([^,]*))/g; + let m: RegExpExecArray | null; + while ((m = re.exec(header))) out[m[1]!] = (m[2] ?? m[3] ?? "").trim(); + return out; +} + +function eq(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + return ab.length === bb.length && timingSafeEqual(ab, bb); +} + +export interface DigestCreds { + readonly user: string; + readonly password: string; +} + +/** + * Verify a Digest Authorization header. Returns true on success. On failure (or + * a missing/expired header) sets a 401 challenge on `reply` and returns false — + * the caller should stop. `creds` is the device's stored push credentials. + */ +export function verifyDigest( + req: FastifyRequest, + reply: FastifyReply, + creds: DigestCreds, +): boolean { + const header = req.headers["authorization"]; + + if (!header || !/^Digest /i.test(header)) { + challenge(reply); + return false; + } + + const p = parseDigest(header.replace(/^Digest /i, "")); + // Nonce must be one we issued and not yet consumed (single-use). + const issuedAt = p.nonce ? issuedNonces.get(p.nonce) : undefined; + if (!p.nonce || issuedAt === undefined || Date.now() - issuedAt > NONCE_TTL_MS) { + challenge(reply, true); + return false; + } + + const ha1 = md5(`${creds.user}:${DIGEST_REALM}:${creds.password}`); + const ha2 = md5(`${req.method}:${p.uri ?? req.url}`); + const expected = + p.qop === "auth" + ? md5(`${ha1}:${p.nonce}:${p.nc}:${p.cnonce}:${p.qop}:${ha2}`) + : md5(`${ha1}:${p.nonce}:${ha2}`); + + if (!p.response || !eq(expected, p.response) || !eq(p.username ?? "", creds.user)) { + challenge(reply); + return false; + } + + // Consume the nonce so it can't be replayed. + issuedNonces.delete(p.nonce); + return true; +} + +function challenge(reply: FastifyReply, stale = false): void { + const nonce = issueNonce(); + reply.header( + "www-authenticate", + `Digest realm="${DIGEST_REALM}", qop="auth", nonce="${nonce}", algorithm=MD5${stale ? ", stale=true" : ""}`, + ); + reply.code(401).send("authentication required"); +} diff --git a/apps/server/src/net.ts b/apps/server/src/net.ts new file mode 100644 index 0000000..aead25c --- /dev/null +++ b/apps/server/src/net.ts @@ -0,0 +1,30 @@ +import { networkInterfaces } from "node:os"; + +// Figure out which local IP a device should call back on. For input-push, the +// device needs OUR address on ITS subnet — pick the local IPv4 interface whose +// network contains the device's IP. Override with BACKEND_HOST_IP if the +// auto-pick is wrong (e.g. multi-homed host). See wiki/concepts/device-input-flow.md. + +export function backendIpForDevice(deviceHost: string): string | null { + if (process.env.BACKEND_HOST_IP) return process.env.BACKEND_HOST_IP; + + const ip = deviceHost.split(".").map(Number); + if (ip.length !== 4 || ip.some((o) => Number.isNaN(o))) return null; + + for (const ifaces of Object.values(networkInterfaces())) { + for (const i of ifaces ?? []) { + if (i.family !== "IPv4" || i.internal) continue; + const addr = i.address.split(".").map(Number); + const mask = i.netmask.split(".").map(Number); + if (addr.length !== 4 || mask.length !== 4) continue; + const sameNet = ip.every((o, k) => (o & mask[k]!) === (addr[k]! & mask[k]!)); + if (sameNet) return i.address; + } + } + return null; +} + +/** Backend port the device should call (the server's listen port). */ +export function backendPort(): number { + return Number(process.env.PORT ?? 3000); +} diff --git a/apps/server/src/routes/devices.ts b/apps/server/src/routes/devices.ts index e725864..294ec1b 100644 --- a/apps/server/src/routes/devices.ts +++ b/apps/server/src/routes/devices.ts @@ -1,54 +1,82 @@ -import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { eq, laneDevices, type Db } from "@parking/db"; import { deviceEvents } from "../device-events.js"; +import { verifyDigest } from "../digest-auth.js"; // Inbound device push endpoints. The Dingtian board's "Input Link URL" feature // HTTP-calls us when an input (button) fires — no polling. We translate the // push into an internal device event; the entry flow decides what to do -// (print a ticket, then command the relay). See wiki/entities/dingtian-relay.md. +// (print a ticket, then command the relay). See wiki/concepts/device-input-flow.md. // -// AUTH: these are machine-to-machine calls FROM the device, which can't do the -// SPA's cookie/CSRF auth. They are intentionally NOT behind requireRole. Trust -// does NOT come from this request — every barrier open is a host decision -// recorded as a signed event, so an out-of-band/forged open has no matching -// signed event and shows up as an anomaly (see wiki/concepts/append-only-event-chain -// and threat-model). A shared-secret check can be layered on later as -// defence-in-depth; on a flat network it isn't the security boundary. +// AUTH: HTTP Digest (the device can do Digest but not HTTPS-to-self-signed — +// both tested on hardware). The password is never sent on the wire; the secret +// is NOT in the URL. Per-device credentials live in lane_devices (written on +// assign). This is defence-in-depth on a flat network; the signed event log is +// the real anti-fraud guarantee (an open with no matching signed event is an +// anomaly). Source-IP is also checked. NOT behind the SPA cookie/CSRF auth +// (machine call from the device). interface InputParams { deviceId: string; n: string; + edge: string; } -export async function deviceRoutes(app: FastifyInstance): Promise { - // Dingtian input ON-edge push (button pressed). The device is configured - // (via input_link_url) to call this path for each input. GET or POST both - // accepted — the device's method is configurable; the URL carries the input. - const handler = (edge: "on" | "off") => - async (req: FastifyRequest<{ Params: InputParams }>) => { - const { deviceId, n } = req.params; - const input = Number(n); - app.log.info(`[dingtian:${deviceId}] input ${input} ${edge} (push)`); - deviceEvents.emitInput({ - driverId: "dingtian", - deviceId, - input, - edge, - at: new Date().toISOString(), - source: "push", - }); - return { ok: true }; - }; +interface DingtianDeviceConfig { + host?: string; + pushUser?: string; + pushPassword?: string; +} + +function clientIp(req: FastifyRequest): string { + return req.ip.replace(/^::ffff:/, ""); +} + +export async function deviceRoutes(app: FastifyInstance, db: Db): Promise { + const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => { + const { deviceId, n, edge } = req.params; + + const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get(); + const cfg = row?.config as DingtianDeviceConfig | undefined; + + // Unknown device / not a dingtian / no push creds / wrong source IP → 404. + if ( + !row || + row.driverId !== "dingtian" || + !cfg?.pushUser || + !cfg.pushPassword || + !cfg.host || + clientIp(req) !== cfg.host + ) { + app.log.warn(`rejected device push: device=${deviceId} ip=${clientIp(req)}`); + return reply.code(404).send({ error: "not found" }); + } + + // Digest auth — issues a 401 challenge on first hit; the device retries with + // the hashed response (verifyDigest sends the challenge + returns false). + if (!verifyDigest(req, reply, { user: cfg.pushUser, password: cfg.pushPassword })) { + return; // 401 already sent + } + + const input = Number(n); + const ed = edge === "off" ? "off" : "on"; + app.log.info(`[dingtian:${deviceId}] input ${input} ${ed} (push)`); + deviceEvents.emitInput({ + driverId: "dingtian", + deviceId, + input, + edge: ed, + at: new Date().toISOString(), + source: "push", + }); + return { ok: true }; + }; for (const method of ["GET", "POST"] as const) { app.route({ method, - url: "/api/devices/dingtian/:deviceId/input/:n/on", - handler: handler("on"), - }); - app.route({ - method, - url: "/api/devices/dingtian/:deviceId/input/:n/off", - handler: handler("off"), + url: "/api/devices/dingtian/:deviceId/input/:n/:edge", + handler: handle, }); } } diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index d5cb18b..d3af12a 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -1,7 +1,8 @@ -import { randomUUID } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { eq, laneDevices, setupState, type Db } from "@parking/db"; import { + hasPushConfig, isDiscoverable, registerBuiltinDrivers, registry, @@ -9,6 +10,7 @@ import { type DeviceCategory, } from "@parking/devices"; import { requireRole } from "../auth.js"; +import { backendIpForDevice, backendPort } from "../net.js"; // First-run setup API. The admin reads the driver catalog and assigns devices // per lane. See wiki/concepts/first-run-setup.md. @@ -80,6 +82,10 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { // Assign a device to a lane. Validates the chosen driver + config against the // registry before persisting; rejects unknown drivers / missing config. + // For push-capable devices (e.g. Dingtian), the backend generates a secret + // token, configures the device to HTTP-push input events to us (no manual URL + // entry by the admin), and stores the token so the push endpoint can verify + // it. See wiki/concepts/device-input-flow.md. app.post<{ Body: AssignBody }>( "/api/setup/assign", { preHandler: adminGuard }, @@ -89,21 +95,60 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { if (!driver || driver.category !== category) { return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` }); } + + const id = randomUUID(); + const fullConfig: Record = { ...config }; + + let device; try { - registry.create(driverId, config); // validates required fields + device = registry.create(driverId, config); // validates required fields } catch (err) { return reply.code(400).send({ error: (err as Error).message }); } + + // If the device supports input push, set it up now: generate Digest creds, + // configure the device to push to us, store the creds. Done before + // persisting so we don't store half-configured rows. + if (hasPushConfig(device)) { + const host = String(config.host ?? ""); + const backendIp = backendIpForDevice(host); + if (!backendIp) { + return reply.code(400).send({ + error: `cannot determine the backend IP on the device's subnet (${host}). Set BACKEND_HOST_IP.`, + }); + } + const pushUser = "dingtian"; + // 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars + // (longer is silently truncated → auth mismatch), so keep it short. + const pushPassword = randomBytes(12).toString("hex"); + try { + await device.configureInputPush({ + host: backendIp, + port: backendPort(), + pathBase: `/api/devices/${driverId}/${id}/input`, + auth: { user: pushUser, password: pushPassword }, + }); + } catch (err) { + return reply + .code(502) + .send({ error: `device push config failed: ${(err as Error).message}` }); + } + fullConfig.pushUser = pushUser; + fullConfig.pushPassword = pushPassword; + } + const row = { - id: randomUUID(), + id, lane, category, driverId, - config, + config: fullConfig, enabled: true, }; await db.insert(laneDevices).values(row); - return reply.code(201).send(row); + // Don't echo the push secret back. + const { pushPassword: _omit, ...safeConfig } = fullConfig; + return reply.code(201).send({ ...row, config: safeConfig }); }, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1e0cb69..1589967 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -44,8 +44,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise { return new Promise((resolve, reject) => { + // The device's embedded HTTP server does NOT support chunked request bodies. + // Node uses chunked encoding when Content-Length is absent, so the device + // silently ignores the body (POST returns {"status":0} but nothing changes). + // Always set Content-Length explicitly. const req = httpRequest( { host, @@ -92,7 +98,12 @@ function configApi( path, method, timeout: timeoutMs, - headers: body ? { "content-type": "application/json" } : undefined, + headers: body + ? { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + } + : undefined, }, (res) => { let data = ""; @@ -108,7 +119,11 @@ function configApi( } class DingtianController - implements AccessControlDevice, InputDevice, PreconditionDevice + implements + AccessControlDevice, + InputDevice, + PreconditionDevice, + PushConfigurableDevice { readonly driverId = "dingtian"; readonly #host: string; @@ -229,21 +244,19 @@ class DingtianController ilr.on_action_on = (ilr.on_action_on as unknown[]).map(() => []); } - await this.#writeConfig(cfg); + await this.#writeConfig(cfg, (after) => this.#linkDisabled(after)); return this.checkPreconditions(); } /** * Configure the device to HTTP-push input (button) events to our backend — * the "Input Link URL" feature. Each input N calls `${pathBase}//on` (and - * `/off`) on the given host:port via GET. Enables the feature and disables TLS - * (plain HTTP to the local backend). Replaces polling. + * `/off`) on host:port via GET, authenticated with **HTTP Digest** (the device + * does Digest but not HTTPS-to-self-signed; both verified on hardware). The + * password is never sent on the wire and the secret is not in the URL. + * Enables the feature, plain HTTP, active-LOW. Replaces polling. */ - async configureInputPush(opts: { - host: string; - port: number; - pathBase: string; // e.g. "/api/devices/dingtian//input" - }): Promise { + async configureInputPush(opts: PushConfig): Promise { const cfg = await this.#readConfig(); const ilu = cfg.input_link_url as Record; const n = Number((ilu.cnt as number) ?? this.#channels); @@ -251,12 +264,12 @@ class DingtianController ilu.en = 1; ilu.active_level = fill(0); // active-LOW (matches this board's wiring) - ilu.tls = fill(0); - ilu.auth = fill(0); + ilu.tls = fill(0); // plain HTTP (device can't do HTTPS to self-signed) + ilu.auth = fill(2); // 2 = Digest ilu.server = fill(opts.host); ilu.port = fill(opts.port); - ilu.user = fill(""); - ilu.pass = fill(""); + ilu.user = fill(opts.auth.user); + ilu.pass = fill(opts.auth.password); ilu.on_method = fill(0); // GET ilu.off_method = fill(0); ilu.on_path = Array.from({ length: n }, (_, i) => `${opts.pathBase}/${i + 1}/on`); @@ -264,7 +277,21 @@ class DingtianController ilu.on_body = fill(""); ilu.off_body = fill(""); - await this.#writeConfig(cfg); + const wantPath = `${opts.pathBase}/1/on`; + await this.#writeConfig(cfg, (after) => { + const a = after.input_link_url as Record | undefined; + const paths = a?.on_path as string[] | undefined; + const pass = a?.pass as string[] | undefined; + // Verify both the path and the (secret) password landed — the password is + // what the backend's Digest check depends on. + return ( + a?.en === 1 && + Array.isArray(paths) && + paths[0] === wantPath && + Array.isArray(pass) && + pass[0] === opts.auth.password + ); + }); } // --- config api internals ---------------------------------------------- @@ -274,9 +301,19 @@ class DingtianController return JSON.parse(raw) as Record; } - /** Write full config back. Injects the required `command:setconfig` and - * tolerates the device resetting on apply. */ - async #writeConfig(cfg: Record): Promise { + /** + * Write full config back, then WAIT for the device to apply it. The device + * reboots on apply (~10s) and back-to-back writes onto a rebooting device are + * silently lost — so we poll until the device is reachable again AND `verify` + * confirms the change landed, retrying the write if needed. + * + * @param verify predicate over the re-read config; should return true once the + * intended change is present. + */ + async #writeConfig( + cfg: Record, + verify: (after: Record) => boolean, + ): Promise { // The set endpoint requires `"command":"setconfig"` injected after `status` // (the GET payload omits it). Rebuild preserving node order, command second. const out: Record = {}; @@ -285,22 +322,31 @@ class DingtianController if (k === "status") out.command = "setconfig"; } if (!("command" in out)) out.command = "setconfig"; + const payload = JSON.stringify(out); + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - // Device resets/applies after a write, so the connection may drop — that's - // success, not failure. Swallow the post-write reset. - try { - await configApi( - this.#host, - this.#httpPort, - "/api/v2/config_set.cgi", - "POST", - JSON.stringify(out), - this.#timeout, - ); - } catch { - // device likely reset on apply + for (let attempt = 1; attempt <= 3; attempt++) { + // POST. The device resets on apply, so the connection may drop — that's + // expected, not failure. + try { + await configApi(this.#host, this.#httpPort, "/api/v2/config_set.cgi", "POST", payload, this.#timeout); + } catch { + // device likely reset on apply + } + + // Poll for the device to come back and the change to be present. + for (let i = 0; i < 12; i++) { + await sleep(2000); + try { + if (verify(await this.#readConfig())) return; // applied + } catch { + // still rebooting / unreachable — keep polling + } + } + // Not applied within the window — likely the POST hit a rebooting device. + // Loop and re-POST (now that it's reachable again). } - await new Promise((r) => setTimeout(r, 4000)); + throw new Error("dingtian: config write did not apply after retries"); } #linkDisabled(cfg: Record): boolean { diff --git a/packages/devices/src/interfaces.ts b/packages/devices/src/interfaces.ts index 6c141af..c11457c 100644 --- a/packages/devices/src/interfaces.ts +++ b/packages/devices/src/interfaces.ts @@ -99,6 +99,33 @@ export function hasPreconditions( return typeof (device as Partial).checkPreconditions === "function"; } +// --- Push configuration (device → backend) ------------------------------- +// Optional capability: a device that can be told to HTTP-push its input/button +// events to our backend (vs. the host polling it). The backend configures the +// device with where to call and a shared-secret token embedded in the path. +// The Dingtian board implements this via its "Input Link URL" feature. +// See wiki/concepts/device-input-flow.md. +export interface PushConfigurableDevice { + configureInputPush(opts: PushConfig): Promise; +} + +export interface PushConfig { + /** Backend host the device should call (our IP on the device's subnet). */ + readonly host: string; + readonly port: number; + /** Path prefix the device appends `//` to, + * e.g. `/api/devices/dingtian//input`. */ + readonly pathBase: string; + /** HTTP Digest credentials the device authenticates the push with. */ + readonly auth: { user: string; password: string }; +} + +export function hasPushConfig( + device: Device, +): device is Device & PushConfigurableDevice { + return typeof (device as Partial).configureInputPush === "function"; +} + // --- Readers (RF / optical; TCP-IP or Wiegand) --------------------------- export interface ReaderDevice extends Device { /** Emits when a credential is read (card number, plate, QR payload, …). */ diff --git a/wiki/concepts/device-input-flow.md b/wiki/concepts/device-input-flow.md index 806f18d..b07fc0f 100644 --- a/wiki/concepts/device-input-flow.md +++ b/wiki/concepts/device-input-flow.md @@ -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]]. diff --git a/wiki/log.md b/wiki/log.md index 2a83faa..9362ca8 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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]].