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"); }