import { createHash, randomBytes } from "node:crypto"; import { request as httpRequest } from "node:http"; import type { IncomingMessage } from "node:http"; // Client-side HTTP Digest auth (RFC 2617, MD5, qop=auth) for talking TO devices // that challenge with `WWW-Authenticate: Digest` — e.g. Hikvision ISAPI cameras. // (The server-side counterpart, which VERIFIES device→backend pushes, lives in // apps/server/src/digest-auth.ts.) Devices on the isolated VLAN can't present a // trusted TLS cert, so plain-HTTP Digest is the available auth: the password is // never on the wire, only a nonce-keyed hash. See wiki/concepts/network-isolation.md. const md5 = (s: string) => createHash("md5").update(s).digest("hex"); /** Parse a `WWW-Authenticate: Digest …` header into its k=v fields. */ function parseChallenge(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; } /** Build the `Authorization: Digest …` response value for a challenge. */ function buildAuthHeader( c: Record, user: string, password: string, method: string, uri: string, ): string { const realm = c.realm ?? ""; const nonce = c.nonce ?? ""; const qop = c.qop?.split(",")[0]?.trim(); // server may offer "auth,auth-int" const ha1 = md5(`${user}:${realm}:${password}`); const ha2 = md5(`${method}:${uri}`); const parts: string[] = [ `username="${user}"`, `realm="${realm}"`, `nonce="${nonce}"`, `uri="${uri}"`, ]; let response: string; if (qop === "auth") { const cnonce = randomBytes(8).toString("hex"); const nc = "00000001"; response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`); parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`); } else { // Legacy RFC 2069 (no qop) — Hikvision uses qop=auth, but be tolerant. response = md5(`${ha1}:${nonce}:${ha2}`); } parts.push(`response="${response}"`); if (c.opaque) parts.push(`opaque="${c.opaque}"`); return `Digest ${parts.join(", ")}`; } export interface DigestGetResult { readonly status: number; readonly contentType: string; readonly body: Buffer; } export interface DigestGetOptions { readonly host: string; readonly port: number; readonly path: string; readonly user: string; readonly password: string; readonly timeoutMs: number; /** Bind outbound to the device-facing NIC on a multi-homed host. */ readonly localAddress?: string; } /** digestGet + a method and optional body — for ISAPI configuration writes * (e.g. PUT /ISAPI/System/time). The digest handshake is method-aware (HA2 * hashes the method), so this generalisation is the real one, not a shortcut. */ export interface DigestRequestOptions extends DigestGetOptions { readonly method: "GET" | "PUT" | "POST"; readonly body?: Buffer | string; readonly contentType?: string; } function requestOnce( o: DigestRequestOptions, authHeader?: string, ): Promise<{ res: IncomingMessage; body: Buffer }> { return new Promise((resolve, reject) => { const payload = o.body == null ? null : Buffer.isBuffer(o.body) ? o.body : Buffer.from(o.body, "utf8"); const headers: Record = {}; if (authHeader) headers["authorization"] = authHeader; if (payload) { headers["content-type"] = o.contentType ?? "application/xml"; headers["content-length"] = String(payload.length); } const req = httpRequest( { host: o.host, port: o.port, path: o.path, method: o.method, timeout: o.timeoutMs, localAddress: o.localAddress, headers, }, (res) => { const chunks: Buffer[] = []; res.on("data", (c) => chunks.push(c as Buffer)); res.on("end", () => resolve({ res, body: Buffer.concat(chunks) })); }, ); req.on("error", reject); req.on("timeout", () => req.destroy(new Error(`digest ${o.method} timeout`))); if (payload) req.write(payload); req.end(); }); } /** * Request a resource with HTTP Digest auth. Does the standard two-shot handshake: * the first request (no Authorization) draws a 401 + challenge, the second * carries the computed response (the body is sent BOTH times — the challenge shot * needs the same request shape). If the server doesn't challenge (200 straight * away, or no auth required), the first response is returned as-is. */ export async function digestRequest(o: DigestRequestOptions): Promise { const first = await requestOnce(o); if (first.res.statusCode !== 401) { return { status: first.res.statusCode ?? 0, contentType: String(first.res.headers["content-type"] ?? ""), body: first.body, }; } const challengeHeader = String(first.res.headers["www-authenticate"] ?? ""); if (!/^digest/i.test(challengeHeader)) { // 401 but not Digest (e.g. Basic-only) — surface it; caller decides. return { status: 401, contentType: String(first.res.headers["content-type"] ?? ""), body: first.body, }; } const challenge = parseChallenge(challengeHeader); const auth = buildAuthHeader(challenge, o.user, o.password, o.method, o.path); const second = await requestOnce(o, auth); return { status: second.res.statusCode ?? 0, contentType: String(second.res.headers["content-type"] ?? ""), body: second.body, }; } /** GET with Digest auth (the original entry point; snapshots and status reads). */ export function digestGet(o: DigestGetOptions): Promise { return digestRequest({ ...o, method: "GET" }); }