#!/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 = """
FIDO2 WebAuthn Demo
FIDO2 / WebAuthn Authentication
Register
Authenticate
"""
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//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//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()