blob: b0acf0fc8ad5b213e50621ee1c2d9b6bc1ad2dfa [file]
"""Minimal hand-rolled RFC 2617 Digest auth client.
curl's own `--digest` handles the challenge/response handshake transparently,
which is no good for testing edge cases (tampered nonces, replayed
nonce-counts, wrong realms, bad algorithm tokens, ...). This module lets
tests parse a WWW-Authenticate challenge, compute the expected response by
hand, and build a (possibly deliberately broken) Authorization header.
mod_auth_digest here only implements qop="auth" (see modules/aaa/mod_auth_digest.c
Open Issues: "MD5-sess and auth-int are not yet implemented"), so this client
only implements the qop=auth request-digest/response-auth formulas from
RFC 2617 section 3.2.2.
"""
import hashlib
import re
from dataclasses import dataclass
from typing import Dict, List, Optional
_PARAM_RE = re.compile(r'(\w+)=(?:"([^"]*)"|([^\s,]+))\s*,?\s*')
def _md5hex(s: str) -> str:
return hashlib.md5(s.encode('utf-8')).hexdigest()
def parse_params(value: str) -> Dict[str, str]:
"""Parse a comma-separated key=value / key="value" list, as used by
both WWW-Authenticate and Authentication-Info header values."""
params = {}
for m in _PARAM_RE.finditer(value):
key = m.group(1)
val = m.group(2) if m.group(2) is not None else m.group(3)
params[key.lower()] = val
return params
@dataclass
class DigestChallenge:
realm: Optional[str]
nonce: Optional[str]
algorithm: Optional[str] = None
opaque: Optional[str] = None
domain: Optional[str] = None
qop: Optional[str] = None
stale: bool = False
raw: str = ""
@staticmethod
def parse(www_authenticate: str) -> 'DigestChallenge':
assert www_authenticate.startswith("Digest "), \
f"not a Digest challenge: {www_authenticate}"
params = parse_params(www_authenticate[len("Digest "):])
return DigestChallenge(
realm=params.get('realm'),
nonce=params.get('nonce'),
algorithm=params.get('algorithm'),
opaque=params.get('opaque'),
domain=params.get('domain'),
qop=params.get('qop'),
stale=params.get('stale', '').lower() == 'true',
raw=www_authenticate,
)
def domain_list(self) -> List[str]:
return self.domain.split() if self.domain else []
def ha1(username: str, realm: str, password: str) -> str:
return _md5hex(f"{username}:{realm}:{password}")
def ha2(method: str, uri: str) -> str:
return _md5hex(f"{method}:{uri}")
def request_digest(ha1_hex: str, nonce: str, nc: str, cnonce: str,
qop: str, ha2_hex: str) -> str:
return _md5hex(f"{ha1_hex}:{nonce}:{nc}:{cnonce}:{qop}:{ha2_hex}")
def rspauth_digest(ha1_hex: str, nonce: str, nc: str, cnonce: str,
qop: str, uri: str) -> str:
"""Authentication-Info's rspauth uses A2 = ':' + uri (no method)."""
ha2_hex = _md5hex(f":{uri}")
return _md5hex(f"{ha1_hex}:{nonce}:{nc}:{cnonce}:{qop}:{ha2_hex}")
def build_authorization(username: str, challenge: DigestChallenge, password: str,
method: str, uri: str, nc: str = "00000001",
cnonce: str = "0a4f113b3c2e7a1d", qop: Optional[str] = "auth",
realm: Optional[str] = None, nonce_val: Optional[str] = None,
algorithm: Optional[str] = None, response: Optional[str] = None,
opaque: Optional[str] = None, include_opaque: bool = True,
include_qop_fields: bool = True, extra: Optional[List[str]] = None
) -> str:
"""Build a Digest Authorization header value.
By default this builds a *correct* response for the given challenge and
credentials. Any of realm=/nonce_val=/algorithm=/response=/opaque= can be
overridden to construct deliberately invalid headers, and qop=None with
include_qop_fields=False builds a legacy RFC 2069-style header (no qop,
cnonce, or nc) to prove that path is rejected.
"""
eff_realm = challenge.realm if realm is None else realm
eff_nonce = challenge.nonce if nonce_val is None else nonce_val
if response is None:
h1 = ha1(username, eff_realm, password)
h2 = ha2(method, uri)
if qop:
response = request_digest(h1, eff_nonce, nc, cnonce, qop, h2)
else:
# legacy RFC 2069: MD5(HA1:nonce:HA2), no qop/cnonce/nc
response = _md5hex(f"{h1}:{eff_nonce}:{h2}")
parts = [
f'username="{username}"',
f'realm="{eff_realm}"',
f'nonce="{eff_nonce}"',
f'uri="{uri}"',
f'response="{response}"',
]
if algorithm is not None:
parts.append(f'algorithm={algorithm}')
if qop and include_qop_fields:
parts.append(f'qop={qop}')
parts.append(f'nc={nc}')
parts.append(f'cnonce="{cnonce}"')
eff_opaque = challenge.opaque if (opaque is None and include_opaque) else opaque
if eff_opaque:
parts.append(f'opaque="{eff_opaque}"')
if extra:
parts.extend(extra)
return "Digest " + ", ".join(parts)