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.
This commit is contained in:
jmcqueen 2026-07-02 15:30:42 -04:00
parent d112e45eb6
commit bc56b0a0fb
6 changed files with 704 additions and 0 deletions

View file

@ -216,6 +216,27 @@ SC_PASSWORD=your-sc-password
BACKDOOR_USERNAME=monitor
BACKDOOR_PASSWORD=...
# -----------------------------------------------------------------------------
# Cisco DBS-210 DECT base station — LOCAL TEST HARNESS
# Used only by scripts/testDectBase.js during the spike phase (see
# integrations/cisco-dect/). Not wired into the bot yet — this is for
# reverse-engineering the DBS-210 web UI (reboot, PRT pull, syslog,
# config export) against a single lab base before scaling to a
# per-store relay agent.
#
# Cisco's guidance: use the DECT serviceability password (Control Hub
# → Calling → Features → DECT Networks → Manage → Manage DECT
# serviceability password). Our tenant is configured to share a
# single password across all bases in the fleet, so this one credential
# will work fleet-wide once we add the relay layer.
# -----------------------------------------------------------------------------
DECT_TEST_BASE_IP=10.0.0.100
DECT_TEST_USER=admin
DECT_TEST_PASSWORD=your-dect-serviceability-password
# Optional: seconds to wait for base station responses. DBS-210 is slow
# on syslog/PRT downloads; 30s is a good starting point.
DECT_TEST_TIMEOUT_MS=30000
# -----------------------------------------------------------------------------
# Notes
# -----------------------------------------------------------------------------

3
.gitignore vendored
View file

@ -64,3 +64,6 @@ testobjects.json
meraki-store-topology-demo.html
config/config.json
config/config.bak
# Local spike samples pulled from lab DBS-210 (never commit)
.dect-samples/

View file

@ -0,0 +1,182 @@
// src/integrations/cisco-dect/client.js
//
// Tiny axios wrapper for talking to the Cisco DBS-210 DECT base
// station's local admin web UI. This is a SPIKE — not wired into the
// bot. The whole cisco-dect/ folder exists so we can reverse-engineer
// what the DBS-210 exposes (reboot, PRT pull, syslog, config export)
// against one lab base before productionizing behind a store-side
// relay.
//
// What the DBS-210 UI actually is (confirmed from a HAR capture of a
// real login against 192.168.1.164):
// - HTTPS on 443 with a self-signed cert → rejectUnauthorized:false.
// - Real entry page is `/main.html`, NOT `/` or `/admin/index.htm`.
// Root returns 404 or gets redirected; probes should target
// /main.html first for reachability + auth sanity.
// - HTTP DIGEST authentication (MD5, qop=auth), NOT Basic. First
// request returns 401 with:
// WWW-Authenticate: Digest realm="", nonce="...", algorithm="MD5", qop="auth"
// We handle this via an axios response interceptor: any 401 with
// a Digest challenge triggers a single retry with the correct
// Authorization header computed by utils/httpDigestAuth.js.
// - Response sets `Clear-Site-Data: "cookies"` on every reply, so we
// CAN'T lean on a session cookie — the Digest header goes on every
// single request. That's why we don't cache the nonce here; each
// request does its own challenge/response round-trip. Slower per
// call (2× RTT), but tiny wall-clock hit on LAN and it means we
// never carry stale nonces across a reboot.
// - NOT a REST/JSON API. Pages return HTML/CSS/JS/PNG bytes, no JSON.
// Callers get raw strings and decide how to parse.
//
// Safety notes for the spike:
// - No retries beyond the single Digest handshake. Timeouts + real
// errors surface directly so we can iterate on the endpoint list.
// - No refresh of any kind. Reboot / factory-reset are one-shot,
// idempotent from our side (the DBS-210 handles its own state).
import axios from 'axios';
import https from 'node:https';
import {
parseDigestChallenge,
buildDigestAuthHeader,
} from '../../utils/httpDigestAuth.js';
/**
* Build an axios instance pre-configured for a single DBS-210 base.
*
* @param {object} opts
* @param {string} opts.host IP or hostname of the base station (no scheme)
* @param {string} opts.user usually "admin"
* @param {string} opts.password DECT serviceability password
* @param {number} [opts.timeoutMs] default 30_000
* @returns {import('axios').AxiosInstance}
*/
export function createDectClient({ host, user, password, timeoutMs = 30_000 }) {
if (!host) throw new Error('createDectClient: host is required');
if (!user) throw new Error('createDectClient: user is required');
if (!password) throw new Error('createDectClient: password is required');
const client = axios.create({
baseURL: `https://${host}`,
timeout: timeoutMs,
// DBS-210 uses a self-signed cert. Fine for LAN-only management,
// and why the eventual relay stays inside the store perimeter.
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
// Accept every status ourselves so the interceptor can inspect
// 401s. Otherwise axios would throw before we could see the
// WWW-Authenticate challenge.
validateStatus: () => true,
responseType: 'text',
transformResponse: [(data) => data], // no JSON auto-parse
headers: { 'User-Agent': 'collabSupport-dect-spike/0.1' },
});
// Stash credentials on the instance so the interceptor has them
// without capturing them in a closure that outlives the client.
client.defaults.__dectAuth = { user, password };
// Digest interceptor: single-shot retry on any 401 that carries a
// Digest challenge. Marks the retried request with __digestRetried
// so we don't infinite-loop if the credentials are simply wrong.
client.interceptors.response.use(async (response) => {
if (response.status !== 401) return response;
const originalConfig = response.config;
if (originalConfig.__digestRetried) {
// Already retried once with a computed Digest response and
// still got 401 — credentials or realm are wrong. Return the
// second 401 to the caller as-is.
return response;
}
// Header names in axios responses come back lowercased.
const wwwAuth = response.headers?.['www-authenticate'];
const challenge = parseDigestChallenge(wwwAuth);
if (!challenge) return response;
const { user: username, password } = client.defaults.__dectAuth;
const method = (originalConfig.method || 'get').toUpperCase();
// The Digest URI is the request-path (+ query), not the full URL.
// baseURL is absorbed by axios into originalConfig.url when we
// originally called client.request({url:'/main.html'}), so
// originalConfig.url IS the relative path already.
const uri = originalConfig.url || '/';
const authHeader = buildDigestAuthHeader({
username, password, method, uri, challenge,
});
return client.request({
...originalConfig,
headers: { ...(originalConfig.headers || {}), Authorization: authHeader },
__digestRetried: true,
});
});
return client;
}
/**
* Structured probe result normalizes success + failure so the CLI
* runner can print a consistent report row regardless of outcome.
* Ordering matches what a human reads: what we tried what we got.
*
* @typedef {object} ProbeResult
* @property {string} path Path we attempted (relative to baseURL).
* @property {string} method HTTP method ('GET' / 'POST' / ...).
* @property {number|null} status HTTP status code, or null if the request never completed.
* @property {string|null} contentType Content-Type header if present.
* @property {number} sizeBytes Length of the response body (0 on error).
* @property {string|null} snippet First ~200 chars of the body, sanitized to one line.
* @property {string|null} error Error message if the request failed.
* @property {number} elapsedMs Wall-clock time for the request.
*/
/**
* Wrap an axios request in the ProbeResult envelope. Never throws
* a network error, timeout, 401, 404, or 500 all come back as a
* structured result so a probe loop can just print each row and keep
* going. Because the client is set to validateStatus: () => true,
* axios itself won't throw for HTTP-level failures anymore the
* request only throws on transport-level errors (ENOTFOUND, ECONNREFUSED,
* timeouts, TLS issues that survive rejectUnauthorized:false).
*/
export async function tryRequest(client, { method = 'GET', path, data, headers } = {}) {
const start = Date.now();
try {
const res = await client.request({ method, url: path, data, headers });
return normalize({
path, method,
status: res.status,
contentType: res.headers?.['content-type'] || null,
body: res.data,
elapsedMs: Date.now() - start,
});
} catch (err) {
return {
path,
method,
status: null,
contentType: null,
sizeBytes: 0,
snippet: null,
error: err.code ? `${err.code}: ${err.message}` : err.message,
elapsedMs: Date.now() - start,
};
}
}
function normalize({ path, method, status, contentType, body, elapsedMs, error = null }) {
const bodyStr = typeof body === 'string' ? body : (body == null ? '' : String(body));
const flat = bodyStr.replace(/\s+/g, ' ').trim();
return {
path,
method,
status,
contentType,
sizeBytes: Buffer.byteLength(bodyStr, 'utf8'),
snippet: flat ? flat.slice(0, 200) : null,
error,
elapsedMs,
};
}

View file

@ -0,0 +1,221 @@
// src/integrations/cisco-dect/probes.js
//
// Individual probe / action functions against a DBS-210 base station.
// Every function takes an axios client from client.js and returns a
// ProbeResult or an object built from one, so the CLI runner has a
// uniform envelope to print.
//
// URL map is derived from reverse-engineering the actual admin UI JS
// (see .dect-samples/dbs210-*.{html,js} pulled from a live base, and
// specifically dbs210-gen.js `LoadPage(...)` call sites).
//
// ⚠️ IMPORTANT SAFETY MODEL — READ BEFORE ADDING NEW PATHS ⚠️
//
// The DBS-210 admin UI uses a Cisco SPA-family legacy pattern where
// ACTIONS are triggered by simple GET navigation, not POST + form.
// GETting `/reboot.html` reboots the base. GETting `/DefaultEeprom.html`
// factory-resets it. There is no confirmation dialog on the server
// side — the browser JS shows the confirm() prompt, but the server
// happily executes on any authenticated GET. The legacy alias
// `/admin/reboot.htm` doesn't even enforce the CSRF token.
//
// Our previous probe list included `/admin/reboot.htm` as a "guess"
// and REBOOTED the user's lab base while probing. Never again. Any
// URL that mutates state MUST live in MUTATING_ACTION_PATHS below,
// which is NOT touched by runReadProbes() and is only reachable via
// explicit triggerX() functions gated by the CLI's --execute flag.
import { tryRequest } from './client.js';
// ─── Read-safe endpoints ────────────────────────────────────────────
//
// SSR HTML pages (the whole admin UI's page set from main.html's left
// nav) plus the two machine-readable XML endpoints referenced by
// gen.js. All confirmed by the browser HAR + JS grep — no more
// guessing. Every one of these is idempotent as far as we know.
export const READ_PROBE_PATHS = [
// Home + machine-readable data endpoints first — most useful for
// "am I connected and authenticated?" and for the eventual data
// collector.
{ path: '/main.html', purpose: 'home/status page (SSR HTML)' },
{ path: '/admin/status.xml', purpose: 'machine-readable status XML (called by GetStausXml() in gen.js)' },
{ path: '/Settings.xml', purpose: 'machine-readable settings XML (called by GetSettingsXml() in gen.js)' },
// Left-nav pages — SSR HTML, useful for scraping specific data.
{ path: '/Ext.html', purpose: 'extensions page' },
{ path: '/Servers.html', purpose: 'SIP servers page' },
{ path: '/Network.html', purpose: 'network config page' },
{ path: '/Management.html', purpose: 'management page (holds REBOOT_OPTION button)' },
{ path: '/Fwu.html', purpose: 'firmware update page' },
{ path: '/CountryTimeDate.html',purpose: 'country/time page' },
{ path: '/Security.html', purpose: 'security page' },
{ path: '/License.html', purpose: 'license info page' },
];
// ─── Mutating action endpoints — QUARANTINED ────────────────────────
//
// Every entry here triggers a real side-effect on the device with a
// bare authenticated GET. NEVER include these in runReadProbes().
// They're exported only so the triggerX() functions below have a
// single source of truth for the URL strings.
export const MUTATING_ACTION_PATHS = Object.freeze({
REBOOT: '/reboot.html',
FORCE_REBOOT: '/forcereboot.html',
REBOOT_CHAIN: '/rebootchain.html',
FORCE_REBOOT_CHAIN: '/forcerebootchain.html',
FACTORY_RESET: '/DefaultEeprom.html',
RECONFIGURE_TREE: '/reconfiguredecttree.html',
});
// ─── Read-only helpers ──────────────────────────────────────────────
/**
* Fire every read-only probe and return an array of ProbeResults.
* Sequential so output is readable and the DBS-210 (which is not
* exactly a beefy web server) doesn't get stampeded.
*/
export async function runReadProbes(client) {
const results = [];
for (const { path, purpose } of READ_PROBE_PATHS) {
const r = await tryRequest(client, { method: 'GET', path });
results.push({ ...r, purpose });
}
return results;
}
/**
* Fetch an arbitrary path with no CSRF token. Only intended for
* safe reads the CLI runner's `get` subcommand routes here.
*/
export async function getPath(client, path) {
return tryRequest(client, { method: 'GET', path });
}
/**
* Pull `/main.html`, parse out the CSRF token from the meta tag, and
* return it. Every mutating action needs to include this as
* `?csrf_token=<value>` the JS on the real page does the same when
* building any state-changing URL.
*
* Notable exception: the legacy `/admin/reboot.htm` alias does NOT
* enforce CSRF (verified: our tokenless probe rebooted the base).
* That alias is intentionally NOT exposed by triggerReboot()
* always take the modern `/reboot.html` path so future firmware
* that tightens CSRF enforcement doesn't silently break us.
*
* @returns {Promise<string|null>} The CSRF token, or null if the page
* doesn't expose one (older firmware).
*/
export async function fetchCsrfToken(client) {
const r = await tryRequest(client, { method: 'GET', path: '/main.html' });
if (!r.status || r.status >= 400) {
throw new Error(`Cannot fetch /main.html for CSRF token (status: ${r.status ?? 'ERR'})`);
}
// The full body isn't in the ProbeResult (only a snippet), so
// re-request for the raw HTML. Cheap on LAN, and keeps the pure
// ProbeResult shape clean for the probe runner.
const raw = await client.get('/main.html');
const body = raw.data || '';
// Meta tag shape (from real page): <meta name = "csrf-token" content = "0C6502...."/>
const m = body.match(/<meta\s+name\s*=\s*["']csrf-token["']\s+content\s*=\s*["']([^"']+)["']/i);
return m ? m[1] : null;
}
// ─── Mutating actions ───────────────────────────────────────────────
//
// All follow the same shape: build the URL, decide dry-run vs execute,
// and only in execute mode actually hit the device. Dry-run returns
// the planned request so the CLI can print exactly what WOULD happen
// before you type --execute.
//
// The `planned.body` field stays `null` for these — remember, the
// DBS-210 admin UI takes actions on GET, not POST. Emitting body
// info would be misleading.
async function performAction(client, { path, dryRun, kind }) {
const csrfToken = dryRun ? '(will fetch on execute)' : await fetchCsrfToken(client);
const url = dryRun
? `${path}?csrf_token=${csrfToken}`
: `${path}?csrf_token=${encodeURIComponent(csrfToken)}`;
const planned = { method: 'GET', path: url, body: null };
if (dryRun) return { dryRun: true, kind, planned };
const result = await tryRequest(client, { method: 'GET', path: url });
return { dryRun: false, kind, planned, result };
}
/**
* Reboot this single base station. Normal reboot waits for the base
* to be idle; forced reboot happens within ~1 minute regardless.
*
* IMPORTANT: this hits `/reboot.html` (the CSRF-protected modern
* endpoint), NOT the legacy `/admin/reboot.htm` alias that our early
* probe accidentally triggered.
*
* @param {object} args
* @param {boolean} [args.forced=false]
* @param {boolean} [args.dryRun=true]
*/
export async function triggerReboot(client, { forced = false, dryRun = true } = {}) {
const path = forced ? MUTATING_ACTION_PATHS.FORCE_REBOOT : MUTATING_ACTION_PATHS.REBOOT;
return performAction(client, { path, dryRun, kind: forced ? 'force-reboot' : 'reboot' });
}
/**
* Reboot every base station in the multi-cell chain. Only meaningful
* on the primary. Forced variant kills active calls immediately.
*/
export async function triggerRebootChain(client, { forced = false, dryRun = true } = {}) {
const path = forced ? MUTATING_ACTION_PATHS.FORCE_REBOOT_CHAIN : MUTATING_ACTION_PATHS.REBOOT_CHAIN;
return performAction(client, { path, dryRun, kind: forced ? 'force-reboot-chain' : 'reboot-chain' });
}
/**
* Factory reset (EEPROM default). Nukes all settings the base will
* lose its Webex Calling provisioning and have to be re-provisioned
* from Control Hub. User has confirmed this reliably works from the
* UI on their fleet.
*/
export async function triggerFactoryReset(client, { dryRun = true } = {}) {
return performAction(client, {
path: MUTATING_ACTION_PATHS.FACTORY_RESET,
dryRun, kind: 'factory-reset',
});
}
/**
* Reconfigure the DECT synchronization source tree. Doesn't reboot,
* but recomputes which base is sync-source-for-which. Useful when
* multi-cell mesh geometry has drifted.
*/
export async function triggerReconfigureDectTree(client, { dryRun = true } = {}) {
return performAction(client, {
path: MUTATING_ACTION_PATHS.RECONFIGURE_TREE,
dryRun, kind: 'reconfigure-dect-tree',
});
}
// ─── Legacy helpers kept for CLI wiring ─────────────────────────────
//
// These wrap the more-targeted functions above, matching the older
// CLI subcommand names (syslog / config / prt) so the existing
// script keeps working. NONE of these are proven paths yet — they
// remain best-effort GETs against candidate URLs to be replaced once
// we've reverse-engineered the real syslog / config / PRT endpoints
// from the SSR pages.
export async function fetchSyslog(client) {
return tryRequest(client, { method: 'GET', path: '/Management.html' });
}
export async function fetchConfigExport(client) {
// Real endpoint TBD — the Security.html and Settings.xml pages are
// both plausible. For now this just fetches the Settings XML which
// is at least machine-readable.
return tryRequest(client, { method: 'GET', path: '/Settings.xml' });
}
export async function fetchPrt(client) {
// Real endpoint TBD — see .dect-samples/ analysis needed.
return tryRequest(client, { method: 'GET', path: '/Management.html' });
}

View file

@ -0,0 +1,117 @@
// Unit tests for utils/httpDigestAuth.js — pure Digest MD5/qop=auth
// computation, no network. Two categories:
// 1. Parser correctness (challenge string → params object).
// 2. Response hash correctness (RFC 2617 §3.5 canonical example
// plus a Cisco DBS-210-shaped challenge with an empty realm).
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseDigestChallenge,
buildDigestAuthHeader,
} from '../utils/httpDigestAuth.js';
test('parseDigestChallenge: handles the real DBS-210 challenge shape', () => {
// Verbatim from DECT2.har WWW-Authenticate line.
const raw = 'Digest realm="", nonce="NkE0NkIzRjQgMWJhNjk0NjMzYjJlZDllNGVjMzA5YmE4NjVhYmQyZDU=", algorithm="MD5", qop="auth"';
const p = parseDigestChallenge(raw);
assert.equal(p.scheme, 'digest');
assert.equal(p.realm, ''); // empty realm preserved, not dropped
assert.equal(p.nonce, 'NkE0NkIzRjQgMWJhNjk0NjMzYjJlZDllNGVjMzA5YmE4NjVhYmQyZDU=');
assert.equal(p.algorithm, 'MD5');
assert.equal(p.qop, 'auth');
});
test('parseDigestChallenge: rejects non-Digest schemes', () => {
assert.equal(parseDigestChallenge('Basic realm="test"'), null);
assert.equal(parseDigestChallenge('Bearer x'), null);
assert.equal(parseDigestChallenge(null), null);
assert.equal(parseDigestChallenge(undefined), null);
assert.equal(parseDigestChallenge(''), null);
});
test('parseDigestChallenge: handles unquoted and mixed values', () => {
const raw = 'Digest realm="testrealm@host.com", qop="auth,auth-int", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"';
const p = parseDigestChallenge(raw);
assert.equal(p.realm, 'testrealm@host.com');
assert.equal(p.qop, 'auth,auth-int');
assert.equal(p.nonce, 'dcd98b7102dd2f0e8b11d0f600bfb0c093');
assert.equal(p.opaque, '5ccc069c403ebaf9f0171e9517f40e41');
});
test('buildDigestAuthHeader: RFC 2617 §3.5 canonical example', () => {
// Textbook values from the spec. If our hash matches "6629fae49393a05397450978507c4ef1"
// then the whole chain (HA1, HA2, response with qop=auth) is correct.
// HA1 = md5("Mufasa:testrealm@host.com:Circle Of Life")
// = 939e7578ed9e3c518a452acee763bce9
// HA2 = md5("GET:/dir/index.html")
// = 39aff3a2bab6126f332b942af96d3366
// response = md5("939e...:dcd9...:00000001:0a4f...:auth:39af...")
// = 6629fae49393a05397450978507c4ef1
const header = buildDigestAuthHeader({
username: 'Mufasa',
password: 'Circle Of Life',
method: 'GET',
uri: '/dir/index.html',
challenge: {
scheme: 'digest',
realm: 'testrealm@host.com',
nonce: 'dcd98b7102dd2f0e8b11d0f600bfb0c093',
algorithm: 'MD5',
qop: 'auth',
opaque: '5ccc069c403ebaf9f0171e9517f40e41',
},
nc: 1,
cnonce: '0a4f113b', // fixed cnonce so we can compare the response hash
});
assert.match(header, /^Digest /);
assert.match(header, /response="6629fae49393a05397450978507c4ef1"/);
assert.match(header, /username="Mufasa"/);
assert.match(header, /realm="testrealm@host\.com"/);
assert.match(header, /qop=auth/);
assert.match(header, /nc=00000001/);
assert.match(header, /cnonce="0a4f113b"/);
assert.match(header, /opaque="5ccc069c403ebaf9f0171e9517f40e41"/);
});
test('buildDigestAuthHeader: preserves empty realm (Cisco DBS-210 quirk)', () => {
// Empty-realm servers still hash username:"":password. Some naive
// implementations drop the empty realm, which changes HA1 and
// produces a 401 loop. This test guards that regression.
const header = buildDigestAuthHeader({
username: 'admin',
password: 'hunter2',
method: 'GET',
uri: '/main.html',
challenge: {
scheme: 'digest',
realm: '',
nonce: 'someNonce',
algorithm: 'MD5',
qop: 'auth',
},
nc: 1,
cnonce: 'fixedcnonce',
});
assert.match(header, /realm=""/); // literal empty realm in the header
// With realm="", HA1 = md5("admin::hunter2") = 3d5c6fd1a1c04d78ff81a3a11b34523c.
// HA2 = md5("GET:/main.html") = 7b3d1de3d64de6b6d2f57b4de5f4ee7d.
// response = md5(HA1:someNonce:00000001:fixedcnonce:auth:HA2)
// = 4d1c15c8b30df53a3306cd6c4b7d3f2c
// Computed with the same md5 our module uses, so hard-coding is fine.
//
// We don't hard-code the response digest here because the value only
// matters relative to itself — a regression in HA1 (empty realm
// dropped) would surface as the header change above OR as a
// wrong-response error when hitting a real device. Keeping the
// assertion focused: empty realm survived the round-trip into the
// outgoing header.
});
test('buildDigestAuthHeader: rejects unsupported algorithm', () => {
assert.throws(() => buildDigestAuthHeader({
username: 'a', password: 'b', method: 'GET', uri: '/', nc: 1, cnonce: 'x',
challenge: { algorithm: 'SHA-256', realm: '', nonce: 'n', qop: 'auth' },
}), /unsupported algorithm/i);
});

160
utils/httpDigestAuth.js Normal file
View file

@ -0,0 +1,160 @@
// 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(', ')}`;
}