"""WebAuthn passkey support for Vacuum Wall. Uses the Duo Labs webauthn library (v3) to handle the FIDO2/WebAuthn ceremony: registration, authentication, and credential management. Credential data is stored in the database webauthn_creds table. Configuration comes from config/auth/config.json (webauthn section). """ from __future__ import annotations import base64 import json import logging 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.cose import COSEAlgorithmIdentifier from webauthn.helpers.structs import ( AttestationConveyancePreference, AuthenticatorSelectionCriteria, PublicKeyCredentialDescriptor, ResidentKeyRequirement, UserVerificationRequirement, ) from lib.auth import AUTH_CONFIG_PATH from lib.common import load_json from lib.db import ( Q_DELETE_WEBAUTHN, Q_INSERT_WEBAUTHN, Q_SELECT_WEBAUTHN_COUNTS, Q_SELECT_WEBAUTHN_ID, Q_SELECT_WEBAUTHN_USER, Q_UPDATE_WEBAUTHN_SIGN_COUNT, get_db, ) logger = logging.getLogger(__name__) # Crypto algorithms we support _SUPPORTED_ALGS = [ COSEAlgorithmIdentifier.ECDSA_SHA_256, COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384, COSEAlgorithmIdentifier.ECDSA_SHA_512, COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512, COSEAlgorithmIdentifier.EDDSA, ] # b64url helpers def b64u_encode(b: bytes) -> str: return base64.urlsafe_b64encode(b).decode("ascii").rstrip("=") def b64u_decode(s: str) -> bytes: padding = 4 - len(s) % 4 if padding != 4: s += "=" * padding return base64.urlsafe_b64decode(s) def _get_webauthn_config() -> dict[str, Any]: """Load WebAuthn configuration from auth config.""" raw = load_json(AUTH_CONFIG_PATH) return raw.get("webauthn", {}) def is_enabled() -> bool: """Return whether WebAuthn is enabled in config.""" cfg = _get_webauthn_config() return cfg.get("enabled", True) def get_rp_name() -> str: """Return the Relying Party name from config.""" return _get_webauthn_config().get("rp_name", "Vacuum Wall") def get_management_domains() -> list[str]: """Return domain names eligible for WebAuthn. Delegates to lib.nginx.get_management_domains() to read the live proxy config and discover which domains serve the management UI. """ from lib.nginx import get_management_domains as _resolve return _resolve() def is_domain_valid(domain: str) -> bool: """Check if *domain* is eligible for WebAuthn.""" return domain in get_management_domains() def check_webauthn_config() -> None: """Validate WebAuthn config at startup.""" enabled = is_enabled() if not enabled: logger.info("WebAuthn is disabled in config") return try: domains = get_management_domains() if not domains: logger.warning( "WebAuthn is enabled but no management domains are configured. " "Add a domain with backend 'webui' to nginx config, or disable WebAuthn." ) else: logger.info("WebAuthn enabled for domains: %s", domains) except Exception as exc: logger.error("Failed to resolve management domains for WebAuthn: %s", exc) # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- def create_registration_options( username: str, origin: str, rp_id: str, ) -> dict[str, Any]: """Create WebAuthn registration options for a new credential. Args: username: The user registering the credential. origin: WebAuthn origin (must match the request origin). rp_id: Relying Party ID (must match the request domain). Returns a dict serializable to JSON, matching the format expected by ``navigator.credentials.create()``. """ # Load existing credential IDs to exclude db = get_db() rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,)) exclude_credentials = [ PublicKeyCredentialDescriptor(id=b64u_decode(row["credential_id"])) for row in rows ] # Pad username to >= 8 bytes (required for user_id) user_id = username.encode("utf-8") if len(user_id) < 8: user_id = user_id + b"\x00" * (8 - len(user_id)) options = generate_registration_options( rp_id=rp_id, rp_name=get_rp_name(), user_name=username, user_display_name=username, user_id=user_id, attestation=AttestationConveyancePreference.NONE, authenticator_selection=AuthenticatorSelectionCriteria( resident_key=ResidentKeyRequirement.PREFERRED, user_verification=UserVerificationRequirement.PREFERRED, ), supported_pub_key_algs=_SUPPORTED_ALGS, exclude_credentials=exclude_credentials, ) # Serialize using the library's built-in function return json.loads(options_to_json(options)) def verify_registration( username: str, credential_response: dict[str, Any], registration_options: dict[str, Any], credential_name: str = "", origin: str = "", rp_id: str = "", ) -> dict[str, Any]: """Verify a registration response and persist the credential. Args: username: The user registering the credential. credential_response: Browser response from ``credentials.create()``. registration_options: The options dict from ``create_registration_options``. credential_name: Optional human-readable label. origin: WebAuthn origin for verification. rp_id: Relying Party ID for verification. Returns: Dict with ``id``, ``name``, ``transports``, ``sign_count``. """ challenge = b64u_decode(registration_options["challenge"]) # The library accepts the credential response as a JSON-serializable dict # We pass it directly — the library handles the parsing col = verify_registration_response( credential=credential_response, expected_challenge=challenge, expected_origin=origin, expected_rp_id=rp_id, require_user_verification=False, ) new_cred = col.credential new_credential_id = b64u_encode(new_cred.id) new_public_key = b64u_encode(new_cred.public_key) new_sign_count = new_cred.sign_count transports = [] if hasattr(new_cred.response, "transports") and new_cred.response.transports: transports = [str(t) for t in new_cred.response.transports] if not transports: transports_raw = credential_response.get("response", {}).get("transports", []) transports = [ t for t in transports_raw if t in ( "internal", "hybrid", "nfc", "ble", "usb", "smart-card", ) ] or ["internal"] db = get_db() db.run( Q_INSERT_WEBAUTHN, ( username, new_credential_id, new_public_key, new_sign_count, credential_name, json.dumps(transports), ), ) logger.info( "WebAuthn credential registered for %s: %s", username, credential_name or new_credential_id[:16], ) return { "id": new_credential_id, "name": credential_name, "transports": transports, "sign_count": new_sign_count, } # --------------------------------------------------------------------------- # Authentication # --------------------------------------------------------------------------- def create_authentication_options( username: str, rp_id: str, ) -> dict[str, Any] | None: """Create authentication options for a user. Args: username: The user authenticating. rp_id: Relying Party ID (must match the request domain). Returns a dict serializable to JSON (for ``navigator.credentials.get()``), or ``None`` if the user has no registered credentials. """ db = get_db() rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,)) if not rows: return None allow_credentials = [ PublicKeyCredentialDescriptor(id=b64u_decode(row["credential_id"])) for row in rows ] options = generate_authentication_options( rp_id=rp_id, allow_credentials=allow_credentials, user_verification=UserVerificationRequirement.PREFERRED, ) return json.loads(options_to_json(options)) def verify_authentication( username: str, assertion_response: dict[str, Any], auth_options: dict[str, Any], origin: str = "", rp_id: str = "", ) -> bool: """Verify an authentication assertion. Args: username: The user authenticating. assertion_response: Browser response from ``credentials.get()``. auth_options: The options dict from ``create_authentication_options``. origin: WebAuthn origin for verification. rp_id: Relying Party ID for verification. Returns: True on successful verification. Raises: ValueError: On verification failure. """ challenge = b64u_decode(auth_options["challenge"]) cred_id_str = assertion_response["id"] # Load credential from DB db = get_db() cred_rows = db.query(Q_SELECT_WEBAUTHN_ID, (cred_id_str,)) if not cred_rows: raise ValueError("Credential not found") cred_row = cred_rows[0] if cred_row["username"] != username: raise ValueError("Credential not found") public_key = b64u_decode(cred_row["public_key"]) old_sign_count = cred_row["sign_count"] col = verify_authentication_response( credential=assertion_response, expected_challenge=challenge, expected_origin=origin, expected_rp_id=rp_id, credential_public_key=public_key, credential_current_sign_count=old_sign_count, require_user_verification=False, ) new_sign_count = col.credential_sign_count if new_sign_count > old_sign_count: db.run( Q_UPDATE_WEBAUTHN_SIGN_COUNT, (new_sign_count, cred_id_str), ) logger.info("WebAuthn assertion verified for %s", username) return True # --------------------------------------------------------------------------- # Credential management # --------------------------------------------------------------------------- def list_credentials(username: str) -> list[dict[str, Any]]: """List all registered credentials for a user.""" db = get_db() rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,)) result = [] for row in rows: transports_str = row.get("transports", "[]") try: transports = json.loads(transports_str) except (json.JSONDecodeError, TypeError): transports = [] result.append( { "id": row["credential_id"], "name": row.get("name") or "", "transports": transports, "sign_count": row.get("sign_count", 0), } ) return result def remove_credential(username: str, credential_id: str) -> bool: """Remove a credential. Raises: ValueError: If credential not found or doesn't belong to user. """ db = get_db() rows = db.query(Q_SELECT_WEBAUTHN_ID, (credential_id,)) if not rows: raise ValueError(f"Credential {credential_id!r} not found") if rows[0]["username"] != username: raise ValueError(f"Credential {credential_id!r} not found") db.run(Q_DELETE_WEBAUTHN, (credential_id,)) logger.info("WebAuthn credential removed: %s, %s", username, credential_id) return True def get_all_credential_counts() -> dict[str, int]: """Return credential counts for all users. Returns: Dict mapping usernames to credential counts. """ db = get_db() rows = db.query(Q_SELECT_WEBAUTHN_COUNTS, ()) return {row["username"]: row["cred_count"] for row in rows}