// src/utils/httpDigestAuth.js // // HTTP Digest Authentication (RFC 7616, and the older RFC 2617 flavour // that most embedded devices still speak). Pure, dependency-free — no // network calls, no state. Feed it the parsed WWW-Authenticate params // plus the credentials and it hands back the `Authorization: Digest ...` // header value. // // Why we need this: axios' built-in `auth: {username, password}` only // speaks Basic. The Cisco DBS-210 DECT base station's admin UI (and // most Cisco small-business voice devices, they're all cousins of the // Sipura SPA family) rejects Basic and challenges with // WWW-Authenticate: Digest realm="", nonce="...", algorithm="MD5", qop="auth" // so every request needs a fresh Digest hash. The DBS-210 also sets // `Clear-Site-Data: "cookies"` on every response, so we can't fall // back on a session cookie either — the Digest header goes on every // single call. // // Cisco quirk we handle explicitly: the realm can be an EMPTY string. // The Digest spec allows this, but some libraries silently drop empty // realms which corrupts HA1. We preserve `realm` exactly as sent. import { createHash, randomBytes } from 'node:crypto'; const md5 = (s) => createHash('md5').update(s, 'utf8').digest('hex'); /** * Parse the value of a `WWW-Authenticate: Digest ...` header into * a plain object. Handles quoted values with commas inside them and * unquoted tokens like `algorithm=MD5`. * * Example input: * Digest realm="", nonce="abc123", algorithm="MD5", qop="auth" * Example output: * { scheme: 'digest', realm: '', nonce: 'abc123', algorithm: 'MD5', qop: 'auth' } * * @param {string} headerValue Full value of the WWW-Authenticate header. * @returns {object|null} Parsed params, or null if not a Digest challenge. */ export function parseDigestChallenge(headerValue) { if (typeof headerValue !== 'string') return null; const trimmed = headerValue.trim(); const schemeMatch = trimmed.match(/^([A-Za-z]+)\s+/); if (!schemeMatch || schemeMatch[1].toLowerCase() !== 'digest') return null; const rest = trimmed.slice(schemeMatch[0].length); // Tokenizer: walk char-by-char so we don't split inside quoted strings. const params = { scheme: 'digest' }; let i = 0; const len = rest.length; while (i < len) { // skip whitespace + commas between params while (i < len && (rest[i] === ' ' || rest[i] === ',')) i++; if (i >= len) break; // read key up to '=' const keyStart = i; while (i < len && rest[i] !== '=') i++; const key = rest.slice(keyStart, i).trim().toLowerCase(); if (i >= len) break; i++; // skip '=' // read value: quoted or unquoted let value; if (rest[i] === '"') { i++; // skip opening quote const valStart = i; while (i < len && rest[i] !== '"') { // very light escape handling for \" inside the value if (rest[i] === '\\' && i + 1 < len) i++; i++; } value = rest.slice(valStart, i); if (rest[i] === '"') i++; // skip closing quote } else { const valStart = i; while (i < len && rest[i] !== ',' && rest[i] !== ' ') i++; value = rest.slice(valStart, i); } params[key] = value; } return params; } /** * Compute the `Authorization: Digest ...` header value for a given * request, challenge, and credentials. Implements MD5 with qop=auth * (the flavour the DBS-210 uses); MD5-sess and qop=auth-int are * not supported because we don't need them and adding them without a * device that speaks them would be untested code. * * @param {object} args * @param {string} args.username * @param {string} args.password * @param {string} args.method HTTP method, e.g. 'GET', 'POST' * @param {string} args.uri Request-URI (path + query), NOT the full URL * @param {object} args.challenge Parsed WWW-Authenticate params * @param {number} [args.nc] Nonce count; each new request against * the same nonce should increment this. * Default 1 (fine for the "one-shot per * request" pattern we use). * @param {string} [args.cnonce] Client nonce. Randomly generated if omitted. * @returns {string} Value for the `Authorization` header. */ export function buildDigestAuthHeader({ username, password, method, uri, challenge, nc = 1, cnonce, }) { if (!challenge || typeof challenge !== 'object') { throw new Error('buildDigestAuthHeader: challenge is required'); } const algorithm = (challenge.algorithm || 'MD5').toUpperCase(); if (algorithm !== 'MD5') { throw new Error(`buildDigestAuthHeader: unsupported algorithm "${algorithm}"`); } // Split qop by comma; server may advertise "auth,auth-int". We pick // 'auth' always (the DBS-210 only lists 'auth' anyway). const qopList = (challenge.qop || '') .split(',') .map((s) => s.trim().toLowerCase()) .filter(Boolean); const useQop = qopList.includes('auth') ? 'auth' : (qopList[0] || null); const realm = challenge.realm ?? ''; // preserve empty realm exactly const nonce = challenge.nonce || ''; const opaque = challenge.opaque; const ncHex = String(nc).padStart(8, '0'); const cnonceStr = cnonce || randomBytes(8).toString('hex'); const HA1 = md5(`${username}:${realm}:${password}`); const HA2 = md5(`${method.toUpperCase()}:${uri}`); let response; if (useQop) { response = md5(`${HA1}:${nonce}:${ncHex}:${cnonceStr}:${useQop}:${HA2}`); } else { // Legacy RFC 2069 fallback (no qop). DBS-210 always sends qop=auth // so this branch is defensive-only, not exercised in practice. response = md5(`${HA1}:${nonce}:${HA2}`); } // Build the header. Ordering doesn't matter to servers, but grouping // matches what most reference implementations emit so it's easy to // eyeball in a packet capture. const parts = [ `username="${username}"`, `realm="${realm}"`, `nonce="${nonce}"`, `uri="${uri}"`, `algorithm=${algorithm}`, `response="${response}"`, ]; if (useQop) { parts.push(`qop=${useQop}`, `nc=${ncHex}`, `cnonce="${cnonceStr}"`); } if (opaque !== undefined) { parts.push(`opaque="${opaque}"`); } return `Digest ${parts.join(', ')}`; }