# backend/auth/webauthn_mfa.py # WebAuthn support for MFA (ROADMAP #64): security keys / platform biometrics. # Thin wrapper over the `webauthn` library with an in-memory challenge store. # # Credentials are persisted in users.json under "webauthn_credentials": # [{ "credential_id": , "public_key": , "sign_count": int, # "transports": [...], "label": str, "registered_at": iso }] from __future__ import annotations import logging import os import secrets import time from typing import Any from webauthn import ( generate_authentication_options, generate_registration_options, options_to_json, verify_authentication_response, verify_registration_response, ) from webauthn.helpers import ( base64url_to_bytes, bytes_to_base64url, parse_authentication_credential_json, parse_registration_credential_json, ) from webauthn.helpers.structs import ( AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement, ) logger = logging.getLogger("obsigate.auth.webauthn") # Challenge lifetime: clients have 3 minutes to complete the ceremony. CHALLENGE_TTL_SECONDS = 180 # In-memory pending challenges: key -> (challenge_bytes, expires_at) _pending: dict[str, tuple[bytes, float]] = {} def rp_id() -> str: return os.environ.get("OBSIGATE_WEBAUTHN_RP_ID", "localhost") def rp_name() -> str: return os.environ.get("OBSIGATE_WEBAUTHN_RP_NAME", "ObsiGate") def expected_origins() -> list[str]: raw = os.environ.get("OBSIGATE_WEBAUTHN_ORIGINS", "http://localhost") return [o.strip() for o in raw.split(",") if o.strip()] def _prune_expired() -> None: now = time.time() for key in [k for k, (_, exp) in _pending.items() if exp < now]: _pending.pop(key, None) def _store_challenge(key: str) -> bytes: _prune_expired() challenge = secrets.token_bytes(32) _pending[key] = (challenge, time.time() + CHALLENGE_TTL_SECONDS) return challenge def _take_challenge(key: str) -> bytes | None: """Pop a challenge (single-use). Returns None if missing/expired.""" _prune_expired() entry = _pending.pop(key, None) return entry[0] if entry else None def clear_pending(username: str) -> None: """Drop all pending challenges for a user (e.g. after enable/disable).""" for key in [k for k in _pending if k.startswith(f"{username}:")]: _pending.pop(key, None) # ── Registration (enrol a key in settings) ───────────────────────────── def begin_registration(username: str, display_name: str) -> dict: options = generate_registration_options( rp_id=rp_id(), rp_name=rp_name(), user_name=username, user_display_name=display_name or username, challenge=_store_challenge(f"{username}:register"), authenticator_selection=AuthenticatorSelectionCriteria( resident_key=ResidentKeyRequirement.PREFERRED, user_verification=UserVerificationRequirement.PREFERRED, ), ) return _finalize_options(options) def complete_registration(username: str, credential_json: dict[str, Any], label: str = "") -> dict: challenge = _take_challenge(f"{username}:register") if challenge is None: raise ValueError("Session d'enregistrement expirée — recommencez") credential = parse_registration_credential_json(credential_json) verification = verify_registration_response( credential=credential, expected_challenge=challenge, expected_rp_id=rp_id(), expected_origin=expected_origins(), ) transports = credential.response.transports or [] label = (label or str(credential_json.get("label") or "")).strip() or "Security key" record = { "credential_id": bytes_to_base64url(verification.credential_id), "public_key": bytes_to_base64url(verification.credential_public_key), "sign_count": int(verification.sign_count), "transports": [str(t) for t in transports], "label": label[:60], } return record # ── Authentication (assertion at login) ──────────────────────────────── def begin_authentication(username: str, credentials: list[dict]) -> dict | None: if not credentials: return None from webauthn.helpers.structs import PublicKeyCredentialDescriptor allow = [ PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in credentials ] options = generate_authentication_options( rp_id=rp_id(), challenge=_store_challenge(f"{username}:login"), allow_credentials=allow, ) return _finalize_options(options) def complete_authentication( username: str, credential_json: dict[str, Any], stored: dict, ) -> int: """Verify an assertion. Returns the new sign_count. Raises ValueError on failure.""" challenge = _take_challenge(f"{username}:login") if challenge is None: raise ValueError("Session expirée — rechargez la page") credential = parse_authentication_credential_json(credential_json) verification = verify_authentication_response( credential=credential, expected_challenge=challenge, expected_rp_id=rp_id(), expected_origin=expected_origins(), credential_public_key=base64url_to_bytes(stored["public_key"]), credential_current_sign_count=int(stored.get("sign_count", 0)), ) return int(verification.new_sign_count) def credentials_for_api(credentials: list[dict]) -> list[dict]: """Sanitized view for the settings UI (no public keys).""" return [ { "credential_id": c.get("credential_id"), "label": c.get("label", "Security key"), "transports": c.get("transports", []), "registered_at": c.get("registered_at"), } for c in credentials ] def _finalize_options(options) -> dict: import json return json.loads(options_to_json(options))