collabSupport/utils/httpDigestAuth.js
jmcqueen bc56b0a0fb Add Cisco DBS-210 DECT base spike (HTTP Digest client + safe probes)
Spike scaffolding for reverse-engineering the local admin UI on a
Cisco DBS-210 DECT base station. Not wired into the bot yet -- the
plan is a status.xml data-collector next, then a per-store relay
that fronts these calls over a websocket back to the bot.

- utils/httpDigestAuth.js: dependency-free HTTP Digest MD5/qop=auth
  header builder + WWW-Authenticate parser. Preserves empty realm,
  which the DBS-210 sends and which most libs silently drop.
- integrations/cisco-dect/client.js: axios wrapper with self-signed
  TLS bypass and a single-shot Digest challenge/response interceptor.
- integrations/cisco-dect/probes.js: verified-safe read paths only in
  READ_PROBE_PATHS. Every mutating path is quarantined in the
  MUTATING_ACTION_PATHS map and exposed only via explicit trigger
  helpers (reboot/force-reboot/reboot-chain/factory-reset/reconfigure-
  tree) that fetch and attach the CSRF token from /main.html. The
  legacy /admin/reboot.htm alias -- which triggered a real reboot
  during our first blind probe -- is intentionally NOT reachable.
- tests/httpDigestAuth.test.js: 6 unit tests, including the RFC 2617
  canonical example and the DBS-210 empty-realm quirk.
- .env.example: adds DECT_TEST_BASE_IP / _USER / _PASSWORD /
  _TIMEOUT_MS for the local test harness (script itself lives under
  scripts/, which stays gitignored).
- .gitignore: adds .dect-samples/ so lab captures don't leak.
2026-07-02 15:30:42 -04:00

160 lines
6.2 KiB
JavaScript

// 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(', ')}`;
}