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.
160 lines
6.2 KiB
JavaScript
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(', ')}`;
|
|
}
|