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:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user