identity and access management

Implementing Hardware Security Key Authentication

Implements FIDO2/WebAuthn hardware security key authentication including registration ceremonies, authentication flows, YubiKey enrollment, and passkey migration strategies. Builds a complete relying party server using the python-fido2 library that supports cross-platform authenticators, resident key (discoverable credential) workflows, and user verification policies. Activates for requests involving FIDO2 implementation, WebAuthn registration, hardware security key enrollment, YubiKey integration, or passkey migration from password-based authentication.

ctap2fido2hardware-security-keypasskeyspasswordless-authenticationwebauthnyubikey
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

When to Use

  • Deploying phishing-resistant multi-factor authentication (MFA) using FIDO2 hardware security keys for high-value accounts (administrators, developers, privileged users)
  • Building a WebAuthn relying party server that supports both roaming authenticators (USB/NFC security keys) and platform authenticators (Windows Hello, Touch ID, Android biometrics)
  • Migrating an existing password-based authentication system to support passkeys (discoverable credentials) as a primary or secondary authentication factor
  • Enrolling YubiKey devices for an organization's workforce, including PIN setup, credential registration, and backup key provisioning
  • Implementing passwordless authentication flows that comply with NIST SP 800-63B AAL3 (authenticator assurance level 3) requirements

Do not use without HTTPS in production (WebAuthn requires a secure origin), for systems where users cannot physically access a USB/NFC port, or as the sole authentication factor without a recovery mechanism for lost keys.

Prerequisites

  • Python 3.10+ with fido2 (python-fido2 >= 2.0.0), flask, and cryptography libraries installed
  • HTTPS-enabled web server (WebAuthn API requires secure context; localhost is exempt for development)
  • FIDO2-compatible hardware security key (YubiKey 5 Series, SoloKeys, Titan Security Key) or platform authenticator
  • Modern web browser supporting the WebAuthn API (Chrome 67+, Firefox 60+, Safari 14+, Edge 79+)
  • Understanding of public key cryptography, challenge-response protocols, and HTTP session management

Workflow

Step 1: Relying Party Server Configuration

Configure the WebAuthn relying party (RP) identity and server:

  • Define RP identity: Create a PublicKeyCredentialRpEntity with the relying party name (display name shown to users) and RP ID (the effective domain of the application). The RP ID must be a registrable domain suffix of the origin -- for example, example.com is valid for https://auth.example.com but other.com is not.
  • Initialize Fido2Server: Instantiate the Fido2Server class from the python-fido2 library with the RP entity. The server handles challenge generation, attestation verification, and assertion validation.
  • Configure attestation preference: Set the attestation conveyance preference to control whether the server requests proof of the authenticator's identity:
    • none: No attestation requested (simplest, recommended for most deployments)
    • indirect: Attestation may be provided but CA may anonymize it
    • direct: Full attestation chain from the authenticator's manufacturer
    • enterprise: Device-identifying attestation for managed environments
  • Session management: Configure server-side sessions to store WebAuthn state between the begin and complete phases of registration/authentication ceremonies. Use secure, httponly cookies with SameSite=Strict.
  • Credential storage: Design the database schema to store credential records: credential_id (binary), public_key (COSE key), sign_count (uint32 for clone detection), user_id, created_at, last_used, display_name, and transports (USB, NFC, BLE, internal).

Step 2: Registration Ceremony (Credential Creation)

Implement the WebAuthn registration flow to create new credentials:

  • Begin registration: Call server.register_begin() with the user entity (PublicKeyCredentialUserEntity containing user ID, username, and display name), the list of existing credentials for the user (to prevent duplicate registration), and options for user_verification and authenticator_attachment.
  • Authenticator selection criteria:
    • authenticator_attachment: cross-platform restricts to roaming authenticators (USB/NFC keys)
    • authenticator_attachment: platform restricts to built-in authenticators (Touch ID, Windows Hello)
    • Omitting this field allows both types
    • resident_key: required forces creation of a discoverable credential (passkey) stored on the authenticator
    • user_verification: required enforces PIN or biometric verification on the authenticator
  • Client-side ceremony: The browser calls navigator.credentials.create() with the options from the server. The authenticator generates a new key pair, stores the private key in its secure element, and returns the public key, credential ID, attestation object, and client data JSON.
  • Complete registration: Call server.register_complete() with the saved state and the client response. The server verifies the attestation signature, extracts the credential public key and ID, and returns AuthenticatorData containing the credential data to store.
  • Store credential: Persist the credential_data (contains credential_id, public_key as COSE key, and sign_count) to the database associated with the user account.

Step 3: Authentication Ceremony (Assertion)

Implement the WebAuthn authentication flow to verify credentials:

  • Begin authentication: Call server.authenticate_begin() with the list of registered credentials for the user (or omit for discoverable credential flows where the authenticator identifies the user). Set user_verification based on the assurance level required.
  • Client-side assertion: The browser calls navigator.credentials.get() with the server options. The authenticator locates the matching credential, performs user verification if required, increments the signature counter, and signs the challenge with the private key.
  • Complete authentication: Call server.authenticate_complete() with the saved state, registered credentials, and the client response. The server verifies the assertion signature against the stored public key and validates the signature counter has incremented (clone detection).
  • Update sign count: After successful authentication, update the stored sign_count for the credential. If the new sign count is not greater than the stored value, the key may have been cloned -- flag this as a security event.
  • Discoverable credential flow: For passwordless authentication, the user does not need to enter a username first. The authenticator presents all discoverable credentials for the RP ID, and the selected credential's userHandle identifies the user.

Step 4: YubiKey Enrollment and Management

Implement organizational YubiKey provisioning workflows:

  • PIN initialization: Before first use, a YubiKey requires a FIDO2 PIN (minimum 4 characters, 8 retries before lockout). Guide users through PIN setup using the Yubico Authenticator application or programmatically via the CTAP2 clientPin command.
  • Primary key enrollment: Register the user's primary YubiKey with their account. Store the credential with a user-friendly label (e.g., "USB-A YubiKey - Office") and the authenticator's AAGUID for device identification.
  • Backup key enrollment: Require users to register at least two security keys. The backup key should be stored separately (home, safety deposit box). Both keys must be registered to the same account so either can authenticate.
  • Key attestation verification: For enterprise deployments, verify the attestation certificate chain to confirm the key is a genuine YubiKey from Yubico. Compare the AAGUID against Yubico's published values to identify the exact model.
  • Key lifecycle management: Implement administrative functions to list a user's registered keys, revoke compromised keys, force re-enrollment, and audit key usage patterns (last authentication time, total authentications).

Step 5: Passkey Migration Strategy

Plan and execute migration from passwords to passkeys:

  • Phased rollout: Begin with voluntary passkey enrollment alongside existing password authentication. Track adoption metrics (percentage of users with passkeys, percentage of logins using passkeys vs. passwords).
  • Credential upgrade flow: When a user authenticates with a password, prompt them to register a passkey. Present the WebAuthn registration dialog immediately after successful password login to minimize friction.
  • Cross-device passkeys: Support synced passkeys (passkeys stored in platform credential managers like iCloud Keychain, Google Password Manager, or 1Password) for users who do not have hardware security keys. These provide phishing resistance without requiring dedicated hardware.
  • Account recovery: Design recovery flows for users who lose all their security keys:
    • Recovery codes generated at enrollment time (printed, stored in password manager)
    • Supervised re-enrollment by an administrator after identity verification
    • Temporary time-limited password login with mandatory key re-enrollment
    • Never allow recovery via email or SMS alone, as these defeat the phishing resistance
  • Password deprecation timeline: After passkey adoption exceeds the target threshold, enforce passkey-only authentication for high-privilege accounts first, then expand to all accounts. Maintain password as a fallback during the transition window.
  • Monitoring and metrics: Track registration success rates, authentication failure rates (wrong key, timeout, cancelled), mean time to authenticate, and the ratio of passkey to password logins.

Key Concepts

Term Definition
FIDO2 An umbrella term for the combination of the W3C WebAuthn API and the FIDO Alliance CTAP2 protocol, enabling passwordless and phishing-resistant authentication using public key cryptography
WebAuthn The W3C Web Authentication API that allows web applications to create and use public key credentials via navigator.credentials.create() (registration) and navigator.credentials.get() (authentication)
CTAP2 Client to Authenticator Protocol version 2; the protocol used by the browser (client) to communicate with external authenticators over USB, NFC, or BLE
Relying Party (RP) The web application or service that requests authentication; identified by its RP ID (a domain) and RP name (display string)
Discoverable Credential (Passkey) A credential stored on the authenticator that can be enumerated without the RP providing a credential ID, enabling username-less authentication flows
Attestation Cryptographic proof from the authenticator about its identity and properties; used by the RP to verify the authenticator model and manufacturer
AAGUID Authenticator Attestation Globally Unique Identifier; a 128-bit value identifying the authenticator model (e.g., all YubiKey 5 NFC devices share the same AAGUID)
Sign Count A monotonically increasing counter maintained by the authenticator and included in each assertion; used by the RP to detect cloned authenticators
User Verification (UV) Local authentication on the authenticator itself (PIN, fingerprint, face recognition) that proves the person holding the authenticator is the legitimate owner

Tools & Systems

  • python-fido2: Yubico's official Python library (v2.0+) providing Fido2Server for relying party implementation and CtapHidDevice/Fido2Client for direct authenticator communication over USB
  • YubiKey 5 Series: Yubico hardware security keys supporting FIDO2/CTAP2, U2F, PIV, OpenPGP, and OTP; available in USB-A, USB-C, NFC, and Nano form factors
  • py_webauthn: Duo Labs' Python WebAuthn library providing generate_registration_options(), verify_registration_response(), generate_authentication_options(), and verify_authentication_response() functions
  • Yubico Authenticator: Desktop and mobile application for managing YubiKey FIDO2 credentials, setting PINs, and viewing registered accounts
  • WebAuthn.io / demo.yubico.com: Online testing tools for verifying WebAuthn registration and authentication flows against real authenticators

Common Scenarios

Scenario: Deploying FIDO2 MFA for a Development Team

Context: A software company wants to replace TOTP-based MFA with hardware security keys for its 50-person development team. Developers have root access to production infrastructure and are high-value targets for phishing attacks. The company has standardized on YubiKey 5 NFC.

Approach:

  1. Provision YubiKey 5 NFC keys (2 per developer: primary + backup) and distribute in tamper-evident packaging with initial PIN setup instructions
  2. Deploy the WebAuthn relying party server integrated with the company's SSO (OAuth 2.0 / OpenID Connect) provider, configured with authenticator_attachment: cross-platform and user_verification: required
  3. Run enrollment sessions where each developer registers both keys to their account, with attestation verification confirming genuine YubiKey 5 NFC AAGUIDs
  4. Configure the SSO provider to require FIDO2 as the second factor for all developer accounts, with a 30-day grace period where TOTP remains available
  5. Implement a self-service portal for key management: view registered keys, register replacement keys, and report lost/stolen keys (which triggers immediate credential revocation and re-enrollment)
  6. After the grace period, disable TOTP for developer accounts. Monitor authentication logs for any fallback attempts and provide 1:1 support for remaining holdouts
  7. Achieve 100% FIDO2 adoption for the development team, reducing phishing risk to near-zero for production infrastructure access

Pitfalls:

  • Not requiring backup key enrollment, leading to account lockouts when a single key is lost
  • Setting user_verification: discouraged which allows anyone who physically possesses the key to authenticate without a PIN
  • Forgetting to validate the sign counter, missing cloned key attacks
  • Not supporting NFC for developers who primarily work from tablets or phones
  • Allowing TOTP as a permanent fallback, which undermines the phishing resistance of the FIDO2 deployment

Scenario: Implementing Passwordless Login for a Customer-Facing Application

Context: An e-commerce platform wants to offer passkey-based passwordless login to its 2 million users as an alternative to passwords, reducing account takeover from credential stuffing and phishing.

Approach:

  1. Implement WebAuthn with resident_key: required to create discoverable credentials that enable username-less login
  2. Support both platform authenticators (Touch ID, Windows Hello, Android biometrics) and roaming authenticators (security keys) by omitting authenticator_attachment
  3. Add a "Sign in with a passkey" button to the login page that triggers navigator.credentials.get() with an empty allowCredentials list, prompting the authenticator to present available passkeys
  4. After successful passkey creation, prompt users to create a passkey on a second device for redundancy
  5. Maintain password login as a fallback during the rollout period, with a persistent prompt encouraging passkey setup after each password login
  6. Track metrics: passkey registration rate (target 30% in first quarter), passkey vs. password login ratio, authentication failure rates, and account takeover incidents
  7. After 6 months, offer incentives (extended session duration, reduced CAPTCHA) for users who switch to passkey-only authentication

Pitfalls:

  • Not handling the case where a user's platform authenticator (e.g., laptop Touch ID) is unavailable and they need cross-device authentication via QR code
  • Assuming all users have biometric-capable devices; some will need to fall back to PIN-based verification
  • Not implementing proper account recovery for users who lose access to all registered passkeys
  • Ignoring browser compatibility gaps, particularly in older Safari versions on iOS

Output Format

## FIDO2 Deployment Report
 
**Application**: auth.example.com
**RP ID**: example.com
**Date**: 2026-03-19
 
### Enrollment Summary
- **Total Users**: 50
- **Users with Primary Key**: 50 (100%)
- **Users with Backup Key**: 47 (94%)
- **Authenticator Models**: YubiKey 5 NFC (48), YubiKey 5C NFC (2)
 
### Authentication Metrics (Last 30 Days)
- **Total Authentications**: 12,847
- **FIDO2 Authentications**: 12,203 (95.0%)
- **TOTP Fallback**: 644 (5.0%) -- grace period active
- **Mean Authentication Time**: 2.3 seconds
- **Authentication Failures**: 127 (0.99%)
  - User cancelled: 89
  - Timeout: 23
  - Invalid signature: 12
  - Sign count regression (possible clone): 3
 
### Security Events
- **Lost Key Reports**: 2
  - User A: primary key lost 2026-03-12, revoked, backup promoted, new backup enrolled
  - User B: backup key damaged 2026-03-15, revoked, replacement enrolled
 
### Credential Details
| User | Key Label | AAGUID | Registered | Last Used | Sign Count |
|------|-----------|--------|------------|-----------|------------|
| alice | YubiKey Primary | 2fc0579f... | 2026-02-15 | 2026-03-19 | 847 |
| alice | YubiKey Backup | 2fc0579f... | 2026-02-15 | 2026-03-01 | 12 |
| bob | YubiKey Primary | 2fc0579f... | 2026-02-16 | 2026-03-19 | 631 |
Source materials

References and resources

Everything below is rendered for inspection. Script files are read-only and never run.

References 1

api-reference.md6.3 KB

API Reference: FIDO2/WebAuthn Hardware Security Key Authentication Server

Overview

Implements a complete WebAuthn relying party server with registration/authentication ceremonies, YubiKey enrollment, credential management, recovery codes, and audit logging. Built on python-fido2 and Flask, supporting both roaming authenticators (USB/NFC security keys) and platform authenticators (Windows Hello, Touch ID).

Dependencies

Package Version Purpose
fido2 >=2.0.0 Yubico's python-fido2 library for WebAuthn relying party operations
flask >=2.3 HTTP server framework for API endpoints and session management
cryptography >=41.0 Cryptographic primitives used by python-fido2

Install with: pip install fido2 flask cryptography

CLI Usage

# Development server on localhost (no TLS needed)
python agent.py --rp-id localhost --rp-name "My App" --port 5000
 
# Production with strict security settings
python agent.py --rp-id auth.example.com --rp-name "Example Corp" \
    --user-verification required --attestation direct --db prod_keys.db
 
# Verbose logging for debugging
python agent.py --rp-id localhost --rp-name "Test" -v

Arguments

Argument Required Description
--rp-id No Relying Party ID -- must match the application domain (default: localhost)
--rp-name No Relying Party display name shown in authenticator prompts (default: FIDO2 Demo)
--host No Server bind address (default: localhost)
--port No Server port (default: 5000)
--db No SQLite database path for credentials and users (default: webauthn.db)
--attestation No Attestation preference: none, indirect, direct, enterprise (default: none)
--user-verification No UV requirement: required, preferred, discouraged (default: preferred)
-v, --verbose No Enable debug logging

API Endpoints

Registration

POST /api/register/begin

Start the WebAuthn registration ceremony.

Request body:

{
  "username": "alice",
  "display_name": "Alice Smith",
  "resident_key": true
}

Response: PublicKeyCredentialCreationOptions (JSON-serialized)

POST /api/register/complete

Complete registration with the authenticator's response.

Request body: Serialized PublicKeyCredential from navigator.credentials.create()

Response:

{
  "status": "OK",
  "recovery_codes": ["A1B2C3D4", "E5F6G7H8", ...],
  "message": "Save these recovery codes securely."
}

Authentication

POST /api/authenticate/begin

Start the WebAuthn authentication ceremony.

Request body:

{
  "username": "alice"
}

Omit username for discoverable credential (passwordless) flow.

Response: PublicKeyCredentialRequestOptions (JSON-serialized)

POST /api/authenticate/complete

Complete authentication with the authenticator's assertion.

Request body: Serialized PublicKeyCredential from navigator.credentials.get()

Response:

{
  "status": "OK",
  "username": "alice"
}

Key Management

GET /api/keys

List all registered security keys for the authenticated user.

POST /api/keys/<id>/revoke

Revoke a security key. Requires at least one remaining active credential.

PUT /api/keys/<id>/label

Update a key's display label.

Recovery

POST /api/recover

Authenticate using a recovery code when all keys are unavailable.

Request body:

{
  "username": "alice",
  "recovery_code": "A1B2C3D4"
}

Admin

GET /api/admin/stats

Deployment statistics: total users, credentials, backup key adoption, authentication metrics.

GET /api/admin/audit-log?limit=100

Authentication event audit trail with timestamps, usernames, event types, and IP addresses.

Key Functions

User Management

create_user(conn, username, display_name)

Creates a user with a cryptographically random 32-byte user handle.

get_user_by_username(conn, username) / get_user_by_handle(conn, user_handle)

User lookups by username (standard flow) or user handle (discoverable credential flow).

Credential Management

store_credential(conn, user_id, credential_id, public_key, sign_count, ...)

Persists a WebAuthn credential with AAGUID, label, transport hints, and discoverable flag.

get_user_credentials(conn, user_id)

Retrieves all active (non-revoked) credentials for a user.

revoke_credential(conn, credential_db_id, user_id)

Soft-revokes a credential. Prevents revocation of the last remaining credential.

update_sign_count(conn, credential_id, new_count)

Updates sign count and last-used timestamp after successful authentication. Sign count regression is logged as a security event (possible cloned key).

Recovery

generate_recovery_codes(conn, user_id, count=8)

Generates 8 one-time recovery codes (stored as SHA-256 hashes). Previous codes are invalidated.

verify_recovery_code(conn, user_id, code)

Verifies and consumes a recovery code (single-use).

Helpers

build_credential_descriptors(creds)

Converts stored credentials to PublicKeyCredentialDescriptor list for WebAuthn ceremonies.

reconstruct_credential_data(creds)

Rebuilds AttestedCredentialData objects from database records for python-fido2 verification.

Database Schema

Table Purpose
users User accounts with random user handles, usernames, and passkey-only flag
credentials WebAuthn credentials with public keys, sign counts, AAGUIDs, and revocation status
auth_events Audit log of all registration, authentication, revocation, and recovery events
recovery_codes Hashed one-time recovery codes with usage tracking

Security Features

Feature Implementation
Phishing resistance Origin binding via WebAuthn RP ID verification
Clone detection Sign count validation with regression warnings
Recovery codes SHA-256 hashed, single-use, auto-invalidated on regeneration
Session security Secure, HttpOnly, SameSite=Strict cookies
Key revocation Soft delete with minimum-one-key enforcement
Audit trail All auth events logged with IP, user agent, and timestamp
Attestation verification Configurable attestation preference for enterprise key verification

Scripts 1

agent.py37.9 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""FIDO2/WebAuthn Hardware Security Key Authentication Server.

Implements a complete WebAuthn relying party with registration ceremonies,
authentication flows, YubiKey enrollment management, and passkey support
using the python-fido2 library.

For authorized deployment and security testing only.
"""

import argparse
import hashlib
import json
import logging
import os
import secrets
import sqlite3
import sys
import time
from base64 import urlsafe_b64decode, urlsafe_b64encode
from datetime import datetime, timezone
from pathlib import Path

from flask import Flask, abort, jsonify, redirect, request, session, render_template_string

from fido2.server import Fido2Server
from fido2.webauthn import (
    AttestationConveyancePreference,
    AuthenticatorAttachment,
    AuthenticatorSelectionCriteria,
    PublicKeyCredentialDescriptor,
    PublicKeyCredentialRpEntity,
    PublicKeyCredentialUserEntity,
    ResidentKeyRequirement,
    UserVerificationRequirement,
)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Database layer
# ---------------------------------------------------------------------------

def init_database(db_path: str) -> sqlite3.Connection:
    """Initialize SQLite database for credential and user storage."""
    conn = sqlite3.connect(db_path, check_same_thread=False)
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA foreign_keys=ON")
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_handle BLOB UNIQUE NOT NULL,
            username TEXT UNIQUE NOT NULL,
            display_name TEXT NOT NULL,
            created_at TEXT NOT NULL DEFAULT (datetime('now')),
            passkey_only INTEGER DEFAULT 0
        );

        CREATE TABLE IF NOT EXISTS credentials (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            credential_id BLOB UNIQUE NOT NULL,
            public_key BLOB NOT NULL,
            sign_count INTEGER NOT NULL DEFAULT 0,
            aaguid TEXT,
            label TEXT,
            transports TEXT,
            created_at TEXT NOT NULL DEFAULT (datetime('now')),
            last_used TEXT,
            is_discoverable INTEGER DEFAULT 0,
            is_revoked INTEGER DEFAULT 0,
            FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
        );

        CREATE TABLE IF NOT EXISTS auth_events (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            credential_id BLOB,
            event_type TEXT NOT NULL,
            success INTEGER NOT NULL,
            ip_address TEXT,
            user_agent TEXT,
            details TEXT,
            created_at TEXT NOT NULL DEFAULT (datetime('now')),
            FOREIGN KEY (user_id) REFERENCES users(id)
        );

        CREATE TABLE IF NOT EXISTS recovery_codes (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            code_hash TEXT UNIQUE NOT NULL,
            used INTEGER DEFAULT 0,
            created_at TEXT NOT NULL DEFAULT (datetime('now')),
            FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
        );

        CREATE INDEX IF NOT EXISTS idx_creds_user ON credentials(user_id);
        CREATE INDEX IF NOT EXISTS idx_creds_cred_id ON credentials(credential_id);
        CREATE INDEX IF NOT EXISTS idx_events_user ON auth_events(user_id);
    """)
    conn.commit()
    return conn


# ---------------------------------------------------------------------------
# User management
# ---------------------------------------------------------------------------

def create_user(conn: sqlite3.Connection, username: str, display_name: str) -> dict:
    """Create a new user with a random user handle."""
    user_handle = secrets.token_bytes(32)
    try:
        conn.execute(
            "INSERT INTO users (user_handle, username, display_name) VALUES (?, ?, ?)",
            (user_handle, username, display_name),
        )
        conn.commit()
        user_id = conn.execute(
            "SELECT id FROM users WHERE username = ?", (username,)
        ).fetchone()[0]
        logger.info("Created user: %s (ID: %d)", username, user_id)
        return {
            "id": user_id,
            "user_handle": user_handle,
            "username": username,
            "display_name": display_name,
        }
    except sqlite3.IntegrityError:
        logger.warning("User already exists: %s", username)
        return get_user_by_username(conn, username)


def get_user_by_username(conn: sqlite3.Connection, username: str) -> dict | None:
    """Retrieve user by username."""
    row = conn.execute(
        "SELECT id, user_handle, username, display_name, passkey_only FROM users WHERE username = ?",
        (username,),
    ).fetchone()
    if not row:
        return None
    return {
        "id": row[0],
        "user_handle": row[1],
        "username": row[2],
        "display_name": row[3],
        "passkey_only": bool(row[4]),
    }


def get_user_by_handle(conn: sqlite3.Connection, user_handle: bytes) -> dict | None:
    """Retrieve user by user handle (for discoverable credential flows)."""
    row = conn.execute(
        "SELECT id, user_handle, username, display_name, passkey_only FROM users WHERE user_handle = ?",
        (user_handle,),
    ).fetchone()
    if not row:
        return None
    return {
        "id": row[0],
        "user_handle": row[1],
        "username": row[2],
        "display_name": row[3],
        "passkey_only": bool(row[4]),
    }


# ---------------------------------------------------------------------------
# Credential management
# ---------------------------------------------------------------------------

def store_credential(
    conn: sqlite3.Connection,
    user_id: int,
    credential_id: bytes,
    public_key: bytes,
    sign_count: int,
    aaguid: str = None,
    label: str = None,
    transports: list[str] = None,
    is_discoverable: bool = False,
) -> int:
    """Store a new WebAuthn credential in the database."""
    cursor = conn.execute(
        """INSERT INTO credentials
           (user_id, credential_id, public_key, sign_count, aaguid, label,
            transports, is_discoverable)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
        (
            user_id, credential_id, public_key, sign_count,
            aaguid, label,
            json.dumps(transports) if transports else None,
            1 if is_discoverable else 0,
        ),
    )
    conn.commit()
    cred_id = cursor.lastrowid
    logger.info(
        "Stored credential for user %d: %s (label: %s)",
        user_id, urlsafe_b64encode(credential_id).decode(), label,
    )
    return cred_id


def get_user_credentials(conn: sqlite3.Connection, user_id: int) -> list[dict]:
    """Get all active credentials for a user."""
    rows = conn.execute(
        """SELECT id, credential_id, public_key, sign_count, aaguid, label,
                  transports, created_at, last_used, is_discoverable
           FROM credentials
           WHERE user_id = ? AND is_revoked = 0""",
        (user_id,),
    ).fetchall()
    creds = []
    for row in rows:
        creds.append({
            "db_id": row[0],
            "credential_id": row[1],
            "public_key": row[2],
            "sign_count": row[3],
            "aaguid": row[4],
            "label": row[5],
            "transports": json.loads(row[6]) if row[6] else [],
            "created_at": row[7],
            "last_used": row[8],
            "is_discoverable": bool(row[9]),
        })
    return creds


def get_all_credentials(conn: sqlite3.Connection) -> list[dict]:
    """Get all active credentials across all users (for discoverable flows)."""
    rows = conn.execute(
        """SELECT c.credential_id, c.public_key, c.sign_count, u.user_handle
           FROM credentials c JOIN users u ON c.user_id = u.id
           WHERE c.is_revoked = 0"""
    ).fetchall()
    return [
        {
            "credential_id": r[0],
            "public_key": r[1],
            "sign_count": r[2],
            "user_handle": r[3],
        }
        for r in rows
    ]


def revoke_credential(conn: sqlite3.Connection, credential_db_id: int, user_id: int) -> bool:
    """Revoke a credential (soft delete)."""
    cursor = conn.execute(
        "UPDATE credentials SET is_revoked = 1 WHERE id = ? AND user_id = ?",
        (credential_db_id, user_id),
    )
    conn.commit()
    return cursor.rowcount > 0


def update_sign_count(conn: sqlite3.Connection, credential_id: bytes, new_count: int):
    """Update the sign count and last-used timestamp after authentication."""
    conn.execute(
        "UPDATE credentials SET sign_count = ?, last_used = datetime('now') WHERE credential_id = ?",
        (new_count, credential_id),
    )
    conn.commit()


# ---------------------------------------------------------------------------
# Recovery codes
# ---------------------------------------------------------------------------

def generate_recovery_codes(conn: sqlite3.Connection, user_id: int, count: int = 8) -> list[str]:
    """Generate one-time recovery codes for account recovery."""
    # Invalidate existing codes
    conn.execute("DELETE FROM recovery_codes WHERE user_id = ?", (user_id,))
    codes = []
    for _ in range(count):
        code = secrets.token_hex(4).upper()  # 8-char hex code
        code_hash = hashlib.sha256(code.encode()).hexdigest()
        conn.execute(
            "INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)",
            (user_id, code_hash),
        )
        codes.append(code)
    conn.commit()
    logger.info("Generated %d recovery codes for user %d", count, user_id)
    return codes


def verify_recovery_code(conn: sqlite3.Connection, user_id: int, code: str) -> bool:
    """Verify and consume a recovery code."""
    code_hash = hashlib.sha256(code.strip().upper().encode()).hexdigest()
    row = conn.execute(
        "SELECT id FROM recovery_codes WHERE user_id = ? AND code_hash = ? AND used = 0",
        (user_id, code_hash),
    ).fetchone()
    if not row:
        return False
    conn.execute("UPDATE recovery_codes SET used = 1 WHERE id = ?", (row[0],))
    conn.commit()
    return True


# ---------------------------------------------------------------------------
# Auth event logging
# ---------------------------------------------------------------------------

def log_auth_event(
    conn: sqlite3.Connection,
    user_id: int,
    event_type: str,
    success: bool,
    credential_id: bytes = None,
    ip_address: str = None,
    user_agent: str = None,
    details: str = None,
):
    """Log an authentication event for auditing."""
    conn.execute(
        """INSERT INTO auth_events
           (user_id, credential_id, event_type, success, ip_address, user_agent, details)
           VALUES (?, ?, ?, ?, ?, ?, ?)""",
        (user_id, credential_id, event_type, 1 if success else 0,
         ip_address, user_agent, details),
    )
    conn.commit()


# ---------------------------------------------------------------------------
# Credential data helpers for python-fido2
# ---------------------------------------------------------------------------

def build_credential_descriptors(creds: list[dict]) -> list:
    """Build PublicKeyCredentialDescriptor list from stored credentials."""
    descriptors = []
    for c in creds:
        desc = PublicKeyCredentialDescriptor(
            type="public-key",
            id=c["credential_id"],
        )
        descriptors.append(desc)
    return descriptors


def reconstruct_credential_data(creds: list[dict]):
    """Reconstruct AttestedCredentialData objects from stored credentials.

    The python-fido2 library's Fido2Server.authenticate_complete expects
    credential data objects that contain credential_id and public_key.
    We rebuild them from our database records.
    """
    from fido2.webauthn import AttestedCredentialData
    result = []
    for c in creds:
        cred_data = AttestedCredentialData.create(
            aaguid=bytes.fromhex(c["aaguid"]) if c.get("aaguid") else b"\x00" * 16,
            credential_id=c["credential_id"],
            public_key=c["public_key"],
        )
        result.append(cred_data)
    return result


# ---------------------------------------------------------------------------
# Flask application factory
# ---------------------------------------------------------------------------

INDEX_HTML = """<!DOCTYPE html>
<html>
<head>
    <title>FIDO2 WebAuthn Demo</title>
    <style>
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
               max-width: 700px; margin: 40px auto; padding: 0 20px; }
        h1 { color: #333; }
        .section { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px;
                   padding: 20px; margin: 20px 0; }
        input { padding: 8px 12px; margin: 5px; border: 1px solid #ced4da; border-radius: 4px; }
        button { padding: 8px 16px; margin: 5px; border: none; border-radius: 4px;
                 cursor: pointer; font-weight: 500; }
        .btn-primary { background: #0d6efd; color: white; }
        .btn-success { background: #198754; color: white; }
        .btn-danger { background: #dc3545; color: white; }
        #status { margin-top: 20px; padding: 10px; border-radius: 4px; display: none; }
        .success { background: #d1e7dd; color: #0f5132; display: block !important; }
        .error { background: #f8d7da; color: #842029; display: block !important; }
        .info { background: #cff4fc; color: #055160; display: block !important; }
    </style>
</head>
<body>
    <h1>FIDO2 / WebAuthn Authentication</h1>

    <div class="section">
        <h2>Register</h2>
        <input type="text" id="reg-username" placeholder="Username" />
        <input type="text" id="reg-display" placeholder="Display Name" />
        <br/>
        <label><input type="checkbox" id="reg-resident" /> Discoverable credential (passkey)</label>
        <br/>
        <button class="btn-primary" onclick="doRegister()">Register Security Key</button>
    </div>

    <div class="section">
        <h2>Authenticate</h2>
        <input type="text" id="auth-username" placeholder="Username (optional for passkeys)" />
        <button class="btn-success" onclick="doAuthenticate()">Authenticate</button>
    </div>

    <div id="status"></div>

    <script>
        function b64encode(buf) {
            return btoa(String.fromCharCode(...new Uint8Array(buf)))
                .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
        }
        function b64decode(str) {
            str = str.replace(/-/g, '+').replace(/_/g, '/');
            while (str.length % 4) str += '=';
            return Uint8Array.from(atob(str), c => c.charCodeAt(0)).buffer;
        }
        function showStatus(msg, type) {
            const el = document.getElementById('status');
            el.textContent = msg;
            el.className = type;
        }

        async function doRegister() {
            const username = document.getElementById('reg-username').value;
            const displayName = document.getElementById('reg-display').value || username;
            const resident = document.getElementById('reg-resident').checked;
            if (!username) { showStatus('Username required', 'error'); return; }

            try {
                showStatus('Starting registration...', 'info');
                const beginResp = await fetch('/api/register/begin', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({username, display_name: displayName, resident_key: resident})
                });
                if (!beginResp.ok) throw new Error(await beginResp.text());
                const options = await beginResp.json();

                // Decode binary fields
                options.publicKey.challenge = b64decode(options.publicKey.challenge);
                options.publicKey.user.id = b64decode(options.publicKey.user.id);
                if (options.publicKey.excludeCredentials) {
                    options.publicKey.excludeCredentials.forEach(c => { c.id = b64decode(c.id); });
                }

                showStatus('Touch your security key...', 'info');
                const credential = await navigator.credentials.create(options);

                const completeResp = await fetch('/api/register/complete', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({
                        id: credential.id,
                        rawId: b64encode(credential.rawId),
                        type: credential.type,
                        response: {
                            attestationObject: b64encode(credential.response.attestationObject),
                            clientDataJSON: b64encode(credential.response.clientDataJSON),
                        }
                    })
                });
                if (!completeResp.ok) throw new Error(await completeResp.text());
                const result = await completeResp.json();
                showStatus('Registration successful! ' + (result.recovery_codes ?
                    'Recovery codes: ' + result.recovery_codes.join(', ') : ''), 'success');
            } catch (err) {
                showStatus('Registration failed: ' + err.message, 'error');
            }
        }

        async function doAuthenticate() {
            const username = document.getElementById('auth-username').value;
            try {
                showStatus('Starting authentication...', 'info');
                const beginResp = await fetch('/api/authenticate/begin', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({username: username || null})
                });
                if (!beginResp.ok) throw new Error(await beginResp.text());
                const options = await beginResp.json();

                options.publicKey.challenge = b64decode(options.publicKey.challenge);
                if (options.publicKey.allowCredentials) {
                    options.publicKey.allowCredentials.forEach(c => { c.id = b64decode(c.id); });
                }

                showStatus('Touch your security key...', 'info');
                const assertion = await navigator.credentials.get(options);

                const completeResp = await fetch('/api/authenticate/complete', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({
                        id: assertion.id,
                        rawId: b64encode(assertion.rawId),
                        type: assertion.type,
                        response: {
                            authenticatorData: b64encode(assertion.response.authenticatorData),
                            clientDataJSON: b64encode(assertion.response.clientDataJSON),
                            signature: b64encode(assertion.response.signature),
                            userHandle: assertion.response.userHandle ?
                                b64encode(assertion.response.userHandle) : null,
                        }
                    })
                });
                if (!completeResp.ok) throw new Error(await completeResp.text());
                const result = await completeResp.json();
                showStatus('Authentication successful! Welcome, ' + result.username, 'success');
            } catch (err) {
                showStatus('Authentication failed: ' + err.message, 'error');
            }
        }
    </script>
</body>
</html>"""


def create_app(
    rp_id: str,
    rp_name: str,
    db_path: str,
    attestation: str = "none",
    user_verification: str = "preferred",
) -> Flask:
    """Create and configure the Flask application with WebAuthn endpoints."""
    app = Flask(__name__)
    app.secret_key = os.urandom(32)
    app.config["SESSION_COOKIE_SECURE"] = rp_id != "localhost"
    app.config["SESSION_COOKIE_HTTPONLY"] = True
    app.config["SESSION_COOKIE_SAMESITE"] = "Strict"

    rp = PublicKeyCredentialRpEntity(name=rp_name, id=rp_id)
    server = Fido2Server(rp)
    conn = init_database(db_path)

    attestation_pref = {
        "none": AttestationConveyancePreference.NONE,
        "indirect": AttestationConveyancePreference.INDIRECT,
        "direct": AttestationConveyancePreference.DIRECT,
        "enterprise": AttestationConveyancePreference.ENTERPRISE,
    }.get(attestation, AttestationConveyancePreference.NONE)

    uv_pref = {
        "required": UserVerificationRequirement.REQUIRED,
        "preferred": UserVerificationRequirement.PREFERRED,
        "discouraged": UserVerificationRequirement.DISCOURAGED,
    }.get(user_verification, UserVerificationRequirement.PREFERRED)

    @app.route("/")
    def index():
        return render_template_string(INDEX_HTML)

    # ------ Registration endpoints ------

    @app.route("/api/register/begin", methods=["POST"])
    def register_begin():
        data = request.get_json()
        if not data or not data.get("username"):
            abort(400, "username required")
        username = data["username"]
        display_name = data.get("display_name", username)
        resident_key = data.get("resident_key", False)

        user = get_user_by_username(conn, username)
        if not user:
            user = create_user(conn, username, display_name)

        # Get existing credentials to exclude
        existing_creds = get_user_credentials(conn, user["id"])
        exclude_list = build_credential_descriptors(existing_creds)

        resident_req = (
            ResidentKeyRequirement.REQUIRED if resident_key
            else ResidentKeyRequirement.DISCOURAGED
        )

        registration_data, state = server.register_begin(
            PublicKeyCredentialUserEntity(
                id=user["user_handle"],
                name=user["username"],
                display_name=user["display_name"],
            ),
            credentials=reconstruct_credential_data(existing_creds) if existing_creds else [],
            user_verification=uv_pref,
            authenticator_attachment=None,
            resident_key_requirement=resident_req,
        )
        session["reg_state"] = state
        session["reg_user_id"] = user["id"]
        session["reg_resident"] = resident_key

        # Serialize for JSON response
        options = dict(registration_data)
        return jsonify(options)

    @app.route("/api/register/complete", methods=["POST"])
    def register_complete():
        data = request.get_json()
        if not data:
            abort(400, "No credential response")

        state = session.pop("reg_state", None)
        user_id = session.pop("reg_user_id", None)
        is_resident = session.pop("reg_resident", False)
        if not state or not user_id:
            abort(400, "No pending registration")

        try:
            auth_data = server.register_complete(state, data)
        except Exception as exc:
            logger.warning("Registration verification failed: %s", exc)
            log_auth_event(
                conn, user_id, "registration", False,
                ip_address=request.remote_addr,
                user_agent=request.headers.get("User-Agent", ""),
                details=str(exc),
            )
            abort(400, f"Registration failed: {exc}")

        cred_data = auth_data.credential_data
        aaguid_hex = cred_data.aaguid.hex() if hasattr(cred_data, "aaguid") else None

        store_credential(
            conn,
            user_id=user_id,
            credential_id=cred_data.credential_id,
            public_key=cred_data.public_key,
            sign_count=auth_data.sign_count if hasattr(auth_data, "sign_count") else 0,
            aaguid=aaguid_hex,
            label=data.get("label", f"Key registered {datetime.now(timezone.utc).strftime('%Y-%m-%d')}"),
            is_discoverable=is_resident,
        )

        log_auth_event(
            conn, user_id, "registration", True,
            credential_id=cred_data.credential_id,
            ip_address=request.remote_addr,
            user_agent=request.headers.get("User-Agent", ""),
        )

        # Generate recovery codes on first credential registration
        user_creds = get_user_credentials(conn, user_id)
        response_data = {"status": "OK"}
        if len(user_creds) == 1:
            codes = generate_recovery_codes(conn, user_id)
            response_data["recovery_codes"] = codes
            response_data["message"] = "Save these recovery codes securely. They will not be shown again."

        return jsonify(response_data)

    # ------ Authentication endpoints ------

    @app.route("/api/authenticate/begin", methods=["POST"])
    def authenticate_begin():
        data = request.get_json() or {}
        username = data.get("username")

        if username:
            user = get_user_by_username(conn, username)
            if not user:
                abort(404, "User not found")
            existing_creds = get_user_credentials(conn, user["id"])
            if not existing_creds:
                abort(404, "No credentials registered")
            cred_data = reconstruct_credential_data(existing_creds)
            session["auth_user_id"] = user["id"]
        else:
            # Discoverable credential flow (passwordless)
            cred_data = []
            session["auth_user_id"] = None

        auth_data, state = server.authenticate_begin(
            credentials=cred_data if cred_data else None,
            user_verification=uv_pref,
        )
        session["auth_state"] = state

        options = dict(auth_data)
        return jsonify(options)

    @app.route("/api/authenticate/complete", methods=["POST"])
    def authenticate_complete():
        data = request.get_json()
        if not data:
            abort(400, "No assertion response")

        state = session.pop("auth_state", None)
        expected_user_id = session.pop("auth_user_id", None)
        if not state:
            abort(400, "No pending authentication")

        # Gather all credentials that could match
        if expected_user_id:
            user_creds = get_user_credentials(conn, expected_user_id)
            cred_data = reconstruct_credential_data(user_creds)
        else:
            # For discoverable credentials, gather all credentials
            all_creds = get_all_credentials(conn)
            user_creds = all_creds
            cred_data = reconstruct_credential_data(all_creds)

        try:
            auth_result = server.authenticate_complete(
                state,
                credentials=cred_data,
                response=data,
            )
        except Exception as exc:
            logger.warning("Authentication verification failed: %s", exc)
            if expected_user_id:
                log_auth_event(
                    conn, expected_user_id, "authentication", False,
                    ip_address=request.remote_addr,
                    user_agent=request.headers.get("User-Agent", ""),
                    details=str(exc),
                )
            abort(401, f"Authentication failed: {exc}")

        # Find the credential that was used
        used_cred_id = auth_result.credential_id
        new_sign_count = auth_result.new_sign_count

        # Detect sign count regression (possible cloned key)
        for c in user_creds:
            if c["credential_id"] == used_cred_id:
                if new_sign_count <= c["sign_count"] and new_sign_count != 0:
                    logger.warning(
                        "SECURITY: Sign count regression for credential %s "
                        "(stored: %d, received: %d) -- possible cloned key!",
                        urlsafe_b64encode(used_cred_id).decode(),
                        c["sign_count"], new_sign_count,
                    )
                break

        update_sign_count(conn, used_cred_id, new_sign_count)

        # Determine user from credential for discoverable flows
        if expected_user_id:
            user_id = expected_user_id
        else:
            # Look up user by credential
            row = conn.execute(
                "SELECT user_id FROM credentials WHERE credential_id = ?",
                (used_cred_id,),
            ).fetchone()
            user_id = row[0] if row else None

        if user_id:
            user = conn.execute(
                "SELECT username, display_name FROM users WHERE id = ?", (user_id,)
            ).fetchone()
            username = user[0] if user else "unknown"
            log_auth_event(
                conn, user_id, "authentication", True,
                credential_id=used_cred_id,
                ip_address=request.remote_addr,
                user_agent=request.headers.get("User-Agent", ""),
            )
        else:
            username = "unknown"

        session["authenticated_user"] = username
        return jsonify({"status": "OK", "username": username})

    # ------ Key management endpoints ------

    @app.route("/api/keys", methods=["GET"])
    def list_keys():
        username = session.get("authenticated_user")
        if not username:
            abort(401, "Not authenticated")
        user = get_user_by_username(conn, username)
        if not user:
            abort(404, "User not found")
        creds = get_user_credentials(conn, user["id"])
        return jsonify([
            {
                "id": c["db_id"],
                "label": c["label"],
                "aaguid": c["aaguid"],
                "created_at": c["created_at"],
                "last_used": c["last_used"],
                "sign_count": c["sign_count"],
                "is_discoverable": c["is_discoverable"],
                "credential_id_b64": urlsafe_b64encode(c["credential_id"]).decode(),
            }
            for c in creds
        ])

    @app.route("/api/keys/<int:key_id>/revoke", methods=["POST"])
    def revoke_key(key_id):
        username = session.get("authenticated_user")
        if not username:
            abort(401, "Not authenticated")
        user = get_user_by_username(conn, username)
        if not user:
            abort(404, "User not found")

        # Ensure at least one credential remains
        creds = get_user_credentials(conn, user["id"])
        if len(creds) <= 1:
            abort(400, "Cannot revoke last credential")

        if revoke_credential(conn, key_id, user["id"]):
            log_auth_event(
                conn, user["id"], "key_revocation", True,
                ip_address=request.remote_addr,
                details=f"Revoked key ID {key_id}",
            )
            return jsonify({"status": "OK", "message": f"Key {key_id} revoked"})
        abort(404, "Key not found")

    @app.route("/api/keys/<int:key_id>/label", methods=["PUT"])
    def update_key_label(key_id):
        username = session.get("authenticated_user")
        if not username:
            abort(401, "Not authenticated")
        user = get_user_by_username(conn, username)
        if not user:
            abort(404)
        data = request.get_json() or {}
        label = data.get("label", "")
        if not label:
            abort(400, "label required")
        conn.execute(
            "UPDATE credentials SET label = ? WHERE id = ? AND user_id = ?",
            (label, key_id, user["id"]),
        )
        conn.commit()
        return jsonify({"status": "OK"})

    # ------ Recovery endpoint ------

    @app.route("/api/recover", methods=["POST"])
    def recover_account():
        data = request.get_json() or {}
        username = data.get("username")
        code = data.get("recovery_code")
        if not username or not code:
            abort(400, "username and recovery_code required")
        user = get_user_by_username(conn, username)
        if not user:
            abort(404, "User not found")
        if verify_recovery_code(conn, user["id"], code):
            session["authenticated_user"] = username
            session["recovery_mode"] = True
            log_auth_event(
                conn, user["id"], "recovery", True,
                ip_address=request.remote_addr,
                details="Authenticated via recovery code",
            )
            return jsonify({
                "status": "OK",
                "message": "Recovery successful. Please register a new security key immediately.",
            })
        log_auth_event(
            conn, user["id"], "recovery", False,
            ip_address=request.remote_addr,
            details="Invalid recovery code",
        )
        abort(401, "Invalid recovery code")

    # ------ Admin/reporting endpoints ------

    @app.route("/api/admin/stats", methods=["GET"])
    def admin_stats():
        total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
        total_creds = conn.execute(
            "SELECT COUNT(*) FROM credentials WHERE is_revoked = 0"
        ).fetchone()[0]
        users_with_backup = conn.execute(
            """SELECT COUNT(DISTINCT user_id) FROM credentials
               WHERE is_revoked = 0
               GROUP BY user_id HAVING COUNT(*) >= 2"""
        ).fetchall()
        recent_auths = conn.execute(
            """SELECT COUNT(*) FROM auth_events
               WHERE event_type = 'authentication' AND success = 1
               AND created_at >= datetime('now', '-30 days')"""
        ).fetchone()[0]
        auth_failures = conn.execute(
            """SELECT COUNT(*) FROM auth_events
               WHERE event_type = 'authentication' AND success = 0
               AND created_at >= datetime('now', '-30 days')"""
        ).fetchone()[0]
        sign_count_warnings = conn.execute(
            """SELECT COUNT(*) FROM auth_events
               WHERE details LIKE '%sign count regression%'
               AND created_at >= datetime('now', '-30 days')"""
        ).fetchone()[0]

        # AAGUID distribution (authenticator model breakdown)
        aaguid_dist = conn.execute(
            """SELECT aaguid, COUNT(*) as cnt
               FROM credentials WHERE is_revoked = 0 AND aaguid IS NOT NULL
               GROUP BY aaguid ORDER BY cnt DESC"""
        ).fetchall()

        return jsonify({
            "total_users": total_users,
            "total_active_credentials": total_creds,
            "users_with_backup_key": len(users_with_backup),
            "auth_last_30_days": recent_auths,
            "auth_failures_last_30_days": auth_failures,
            "sign_count_regressions_30d": sign_count_warnings,
            "authenticator_models": [
                {"aaguid": r[0], "count": r[1]} for r in aaguid_dist
            ],
        })

    @app.route("/api/admin/audit-log", methods=["GET"])
    def audit_log():
        limit = request.args.get("limit", 100, type=int)
        rows = conn.execute(
            """SELECT ae.created_at, u.username, ae.event_type, ae.success,
                      ae.ip_address, ae.details
               FROM auth_events ae JOIN users u ON ae.user_id = u.id
               ORDER BY ae.created_at DESC LIMIT ?""",
            (min(limit, 1000),),
        ).fetchall()
        return jsonify([
            {
                "timestamp": r[0], "username": r[1], "event": r[2],
                "success": bool(r[3]), "ip": r[4], "details": r[5],
            }
            for r in rows
        ])

    return app


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description="FIDO2/WebAuthn Hardware Security Key Authentication Server",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Start development server on localhost
  python agent.py --rp-id localhost --rp-name "My App" --port 5000

  # Production mode with strict user verification
  python agent.py --rp-id auth.example.com --rp-name "Example Corp" \\
      --user-verification required --attestation direct --db prod_keys.db

  # Require discoverable credentials (passkeys)
  python agent.py --rp-id example.com --rp-name "Example" --port 8443
        """,
    )
    parser.add_argument("--rp-id", default="localhost", help="Relying Party ID (domain, default: localhost)")
    parser.add_argument("--rp-name", default="FIDO2 Demo", help="Relying Party display name")
    parser.add_argument("--host", default="localhost", help="Server bind address (default: localhost)")
    parser.add_argument("--port", type=int, default=5000, help="Server port (default: 5000)")
    parser.add_argument("--db", default="webauthn.db", help="SQLite database path (default: webauthn.db)")
    parser.add_argument(
        "--attestation", choices=["none", "indirect", "direct", "enterprise"],
        default="none", help="Attestation conveyance preference (default: none)",
    )
    parser.add_argument(
        "--user-verification", choices=["required", "preferred", "discouraged"],
        default="preferred", help="User verification requirement (default: preferred)",
    )
    parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging")

    args = parser.parse_args()

    if args.verbose:
        logging.getLogger().setLevel(logging.DEBUG)

    logger.info("Starting FIDO2 WebAuthn server")
    logger.info("  RP ID:    %s", args.rp_id)
    logger.info("  RP Name:  %s", args.rp_name)
    logger.info("  Host:     %s:%d", args.host, args.port)
    logger.info("  Database: %s", args.db)
    logger.info("  Attestation: %s", args.attestation)
    logger.info("  User Verification: %s", args.user_verification)

    app = create_app(
        rp_id=args.rp_id,
        rp_name=args.rp_name,
        db_path=args.db,
        attestation=args.attestation,
        user_verification=args.user_verification,
    )

    if args.rp_id != "localhost":
        logger.warning(
            "Running without TLS. In production, place behind a TLS-terminating "
            "reverse proxy (nginx, Caddy) -- WebAuthn requires HTTPS."
        )

    app.run(host=args.host, port=args.port, debug=args.verbose)


if __name__ == "__main__":
    main()
Keep exploring