| """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) |