"""Authentication and abuse controls for the public demo server.""" from __future__ import annotations import argparse import base64 import getpass import hashlib import hmac import json import os import secrets import time from collections import defaultdict, deque from dataclasses import dataclass from threading import RLock from urllib.parse import urlsplit PASSWORD_SCHEME = "pbkdf2_sha256" PASSWORD_ITERATIONS = 600_000 def is_json_content_type(value: str) -> bool: """Return whether an HTTP Content-Type is JSON, allowing normal parameters.""" media_type = value.split(";", 1)[0].strip().casefold() return media_type == "application/json" def origin_matches_host(origin: str, host: str, scheme: str) -> bool: """Compare a browser Origin with the request's externally visible origin. Host names are case-insensitive and default ports are normalized, but aliases are intentionally not: ``localhost`` and ``127.0.0.1`` are different origins. """ normalized_scheme = scheme.strip().casefold() if normalized_scheme not in {"http", "https"} or not origin or not host: return False try: candidate = urlsplit(origin.strip()) expected = urlsplit(f"{normalized_scheme}://{host.strip()}") if ( candidate.scheme.casefold() != normalized_scheme or not candidate.netloc or candidate.path or candidate.query or candidate.fragment or candidate.username is not None or candidate.password is not None or not expected.netloc or expected.path or expected.query or expected.fragment or expected.username is not None or expected.password is not None ): return False candidate_host = candidate.hostname expected_host = expected.hostname if candidate_host is None or expected_host is None: return False default_port = 443 if normalized_scheme == "https" else 80 candidate_port = candidate.port or default_port expected_port = expected.port or default_port except (UnicodeError, ValueError): return False return ( candidate_host.casefold() == expected_host.casefold() and candidate_port == expected_port ) def _b64encode(value: bytes) -> str: return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") def _b64decode(value: str) -> bytes: return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) def hash_password( password: str, *, salt: bytes | None = None, iterations: int = PASSWORD_ITERATIONS, ) -> str: if len(password) < 12: raise ValueError("demo password must contain at least 12 characters") salt = salt or secrets.token_bytes(16) digest = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, iterations, ) return f"{PASSWORD_SCHEME}${iterations}${_b64encode(salt)}${_b64encode(digest)}" def verify_password(password: str, encoded: str) -> bool: try: scheme, iteration_text, salt_text, digest_text = encoded.split("$", 3) if scheme != PASSWORD_SCHEME: return False iterations = int(iteration_text) if not 100_000 <= iterations <= 2_000_000: return False expected = _b64decode(digest_text) actual = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), _b64decode(salt_text), iterations, ) return hmac.compare_digest(actual, expected) except (ValueError, TypeError): return False def _env_bool(name: str, default: bool = False) -> bool: value = os.getenv(name) if value is None: return default return value.strip().lower() in {"1", "true", "yes", "on"} @dataclass(frozen=True) class AuthConfig: enabled: bool username: str password_hash: str session_secret: bytes session_ttl_seconds: int login_attempts: int login_window_seconds: int requests_per_minute: int trust_proxy_headers: bool cookie_name: str = "cognitive_agent_session" @classmethod def from_env(cls) -> AuthConfig: enabled = _env_bool("DEMO_AUTH_ENABLED") username = os.getenv("DEMO_AUTH_USERNAME", "").strip() password_hash = os.getenv("DEMO_AUTH_PASSWORD_HASH", "").strip() secret_text = os.getenv("DEMO_AUTH_SESSION_SECRET", "").strip() if enabled: if not username or len(username) > 64: raise RuntimeError("DEMO_AUTH_USERNAME must contain 1-64 characters") if not password_hash.startswith(f"{PASSWORD_SCHEME}$"): raise RuntimeError("DEMO_AUTH_PASSWORD_HASH is missing or unsupported") if len(secret_text) < 43: raise RuntimeError("DEMO_AUTH_SESSION_SECRET must contain at least 43 characters") return cls( enabled=enabled, username=username, password_hash=password_hash, session_secret=secret_text.encode("utf-8"), session_ttl_seconds=max( 300, min(int(os.getenv("DEMO_AUTH_SESSION_TTL_SECONDS", "28800")), 604800), ), login_attempts=max( 1, min(int(os.getenv("DEMO_AUTH_LOGIN_ATTEMPTS", "5")), 20), ), login_window_seconds=max( 60, min(int(os.getenv("DEMO_AUTH_LOGIN_WINDOW_SECONDS", "300")), 3600), ), requests_per_minute=max( 1, min(int(os.getenv("DEMO_REQUESTS_PER_MINUTE", "10")), 120), ), trust_proxy_headers=_env_bool("DEMO_TRUST_PROXY_HEADERS"), ) class SessionCodec: def __init__(self, config: AuthConfig) -> None: self.config = config def issue(self, username: str, now: int | None = None) -> str: now = int(time.time()) if now is None else now payload = { "sub": username, "iat": now, "exp": now + self.config.session_ttl_seconds, "nonce": secrets.token_urlsafe(12), } encoded = _b64encode( json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") ) signature = _b64encode( hmac.new(self.config.session_secret, encoded.encode("ascii"), hashlib.sha256).digest() ) return f"{encoded}.{signature}" def verify(self, token: str, now: int | None = None) -> str | None: if not token or len(token) > 2048: return None try: encoded, signature = token.split(".", 1) expected = _b64encode( hmac.new( self.config.session_secret, encoded.encode("ascii"), hashlib.sha256, ).digest() ) if not hmac.compare_digest(signature, expected): return None payload = json.loads(_b64decode(encoded)) current_time = int(time.time()) if now is None else now if payload.get("sub") != self.config.username: return None if not int(payload.get("iat", 0)) <= current_time < int(payload.get("exp", 0)): return None return str(payload["sub"]) except (ValueError, TypeError, json.JSONDecodeError): return None class SlidingWindowLimiter: def __init__(self, limit: int, window_seconds: int) -> None: self.limit = limit self.window_seconds = window_seconds self._events: dict[str, deque[float]] = defaultdict(deque) self._lock = RLock() def retry_after(self, key: str, now: float | None = None) -> int: current = time.monotonic() if now is None else now with self._lock: events = self._events[key] cutoff = current - self.window_seconds while events and events[0] <= cutoff: events.popleft() if len(events) < self.limit: return 0 return max(1, int(self.window_seconds - (current - events[0]) + 0.999)) def record(self, key: str, now: float | None = None) -> None: current = time.monotonic() if now is None else now with self._lock: self._events[key].append(current) def clear(self, key: str) -> None: with self._lock: self._events.pop(key, None) class AuthRuntime: def __init__(self, config: AuthConfig) -> None: self.config = config self.sessions = SessionCodec(config) self.login_failures = SlidingWindowLimiter( config.login_attempts, config.login_window_seconds, ) self.api_requests = SlidingWindowLimiter(config.requests_per_minute, 60) def main() -> None: parser = argparse.ArgumentParser(description="Cognitive Agent authentication utilities") parser.add_argument("command", choices=["hash-password", "generate-secret"]) args = parser.parse_args() if args.command == "generate-secret": print(secrets.token_urlsafe(48)) return password = getpass.getpass("Demo password: ") confirmation = getpass.getpass("Confirm password: ") if password != confirmation: raise SystemExit("Passwords do not match") print(hash_password(password)) if __name__ == "__main__": main()