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
+97
View File
@@ -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<string, number>(); // 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<string, string> {
const out: Record<string, string> = {};
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");
}
+30
View File
@@ -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);
}
+63 -35
View File
@@ -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<void> {
// 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<void> {
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,
});
}
}
+50 -5
View File
@@ -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<void> {
// 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<void> {
if (!driver || driver.category !== category) {
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
}
const id = randomUUID();
const fullConfig: Record<string, unknown> = { ...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 });
},
);
+4 -2
View File
@@ -44,8 +44,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// catalog at first-run. See wiki/concepts/first-run-setup.md.
await setupRoutes(app, db);
// Inbound device pushes (e.g. Dingtian Input Link URL → button events).
await deviceRoutes(app);
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from
// the device's lane_devices config (written on assign).
await deviceRoutes(app, db);
// TODO: entry flow (input event → signed event → print → relay), event-log routes.