diff --git a/backend/alembic/versions/061_add_passkey_credentials.py b/backend/alembic/versions/061_add_passkey_credentials.py new file mode 100644 index 0000000..0a8b749 --- /dev/null +++ b/backend/alembic/versions/061_add_passkey_credentials.py @@ -0,0 +1,40 @@ +"""Add passkey_credentials table for WebAuthn/FIDO2 authentication + +Revision ID: 061 +Revises: 060 +""" +import sqlalchemy as sa +from alembic import op + +revision = "061" +down_revision = "060" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "passkey_credentials", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "user_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("credential_id", sa.Text, unique=True, nullable=False), + sa.Column("public_key", sa.Text, nullable=False), + sa.Column("sign_count", sa.Integer, nullable=False, server_default="0"), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("transports", sa.Text, nullable=True), + sa.Column("backed_up", sa.Boolean, nullable=False, server_default="false"), + sa.Column("created_at", sa.DateTime, server_default=sa.text("now()")), + sa.Column("last_used_at", sa.DateTime, nullable=True), + ) + op.create_index( + "ix_passkey_credentials_user_id", "passkey_credentials", ["user_id"] + ) + + +def downgrade(): + op.drop_table("passkey_credentials") diff --git a/backend/app/config.py b/backend/app/config.py index 592af82..4c2fab9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -30,6 +30,12 @@ class Settings(BaseSettings): # Concurrent session limit per user (oldest evicted when exceeded) MAX_SESSIONS_PER_USER: int = 10 + # WebAuthn / Passkey configuration + WEBAUTHN_RP_ID: str = "localhost" # eTLD+1 domain, e.g. "umbra.ghost6.xyz" + WEBAUTHN_RP_NAME: str = "UMBRA" + WEBAUTHN_ORIGIN: str = "http://localhost" # Full origin with scheme, e.g. "https://umbra.ghost6.xyz" + WEBAUTHN_CHALLENGE_TTL: int = 60 # Challenge token lifetime in seconds + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", @@ -47,6 +53,9 @@ class Settings(BaseSettings): self.CORS_ORIGINS = "http://localhost:5173" assert self.COOKIE_SECURE is not None # type narrowing assert self.CORS_ORIGINS is not None + # Validate WebAuthn origin includes scheme (S-04) + if not self.WEBAUTHN_ORIGIN.startswith(("http://", "https://")): + raise ValueError("WEBAUTHN_ORIGIN must include scheme (http:// or https://)") return self diff --git a/backend/app/main.py b/backend/app/main.py index 6c30d36..d177d05 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.config import settings from app.database import engine from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates -from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router +from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router, passkeys as passkeys_router from app.jobs.notifications import run_notification_dispatch # Import models so Alembic's autogenerate can discover them @@ -23,6 +23,7 @@ from app.models import user_connection as _user_connection_model # noqa: F401 from app.models import calendar_member as _calendar_member_model # noqa: F401 from app.models import event_lock as _event_lock_model # noqa: F401 from app.models import event_invitation as _event_invitation_model # noqa: F401 +from app.models import passkey_credential as _passkey_credential_model # noqa: F401 # --------------------------------------------------------------------------- @@ -49,6 +50,8 @@ class CSRFHeaderMiddleware: "/api/auth/totp-verify", "/api/auth/totp/enforce-setup", "/api/auth/totp/enforce-confirm", + "/api/auth/passkeys/login/begin", + "/api/auth/passkeys/login/complete", }) _MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) @@ -134,6 +137,7 @@ app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"]) app.include_router(weather.router, prefix="/api/weather", tags=["Weather"]) app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"]) app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"]) +app.include_router(passkeys_router.router, prefix="/api/auth/passkeys", tags=["Passkeys"]) app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 05f7a00..da2607d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -23,6 +23,7 @@ from app.models.event_lock import EventLock from app.models.event_invitation import EventInvitation, EventInvitationOverride from app.models.project_member import ProjectMember from app.models.project_task_assignment import ProjectTaskAssignment +from app.models.passkey_credential import PasskeyCredential __all__ = [ "Settings", @@ -51,4 +52,5 @@ __all__ = [ "EventInvitationOverride", "ProjectMember", "ProjectTaskAssignment", + "PasskeyCredential", ] diff --git a/backend/app/models/passkey_credential.py b/backend/app/models/passkey_credential.py new file mode 100644 index 0000000..4d03042 --- /dev/null +++ b/backend/app/models/passkey_credential.py @@ -0,0 +1,30 @@ +from datetime import datetime + +from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class PasskeyCredential(Base): + __tablename__ = "passkey_credentials" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + # base64url-encoded credential ID (spec allows up to 1023 bytes → ~1363 chars) + credential_id: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + # base64url-encoded COSE public key + public_key: Mapped[str] = mapped_column(Text, nullable=False) + # Authenticator sign count for clone detection + sign_count: Mapped[int] = mapped_column(Integer, default=0) + # User-assigned label (e.g. "MacBook Pro — Chrome") + name: Mapped[str] = mapped_column(String(100), nullable=False) + # JSON array of transport hints (e.g. '["usb","hybrid"]') + transports: Mapped[str | None] = mapped_column(Text, nullable=True) + # Whether the credential is backed up / synced across devices + backed_up: Mapped[bool] = mapped_column(Boolean, default=False) + + created_at: Mapped[datetime] = mapped_column(default=func.now()) + last_used_at: Mapped[datetime | None] = mapped_column(nullable=True) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index fe97a1f..2b0cf13 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -29,6 +29,7 @@ from app.models.user import User from app.models.session import UserSession from app.models.settings import Settings from app.models.system_config import SystemConfig +from app.models.passkey_credential import PasskeyCredential from app.models.calendar import Calendar from app.schemas.auth import ( SetupRequest, LoginRequest, RegisterRequest, @@ -513,6 +514,16 @@ async def auth_status( config = config_result.scalar_one_or_none() registration_open = config.allow_registration if config else False + # Check if authenticated user has passkeys registered (Q-04) + has_passkeys = False + if authenticated and u: + pk_result = await db.execute( + select(func.count()).select_from(PasskeyCredential).where( + PasskeyCredential.user_id == u.id + ) + ) + has_passkeys = pk_result.scalar_one() > 0 + return { "authenticated": authenticated, "setup_required": setup_required, @@ -520,6 +531,7 @@ async def auth_status( "username": u.username if authenticated and u else None, "registration_open": registration_open, "is_locked": is_locked, + "has_passkeys": has_passkeys, } diff --git a/backend/app/routers/passkeys.py b/backend/app/routers/passkeys.py new file mode 100644 index 0000000..27dd26b --- /dev/null +++ b/backend/app/routers/passkeys.py @@ -0,0 +1,451 @@ +""" +Passkey (WebAuthn/FIDO2) router. + +Endpoints (all under /api/auth/passkeys — registered in main.py): + + POST /register/begin — Start passkey registration (auth + password required) + POST /register/complete — Complete registration ceremony (auth required) + POST /login/begin — Start passkey authentication (public, CSRF-exempt) + POST /login/complete — Complete authentication ceremony (public, CSRF-exempt) + GET / — List registered passkeys (auth required) + DELETE /{id} — Remove a passkey (auth + password required) + +Security: + - Challenge tokens signed with itsdangerous (60s TTL, single-use nonce) + - Registration binds challenge to user_id, validated on complete (S-01) + - Registration requires password re-entry (V-02) + - Generic 401 on all auth failures (no credential enumeration) + - Constant-time response on login/begin (V-03) + - Failed passkey logins increment shared lockout counter + - Passkey login bypasses TOTP (passkey IS 2FA) +""" +import asyncio +import json +import logging +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.database import get_db +from app.models.user import User +from app.models.passkey_credential import PasskeyCredential +from app.routers.auth import get_current_user +from app.services.audit import get_client_ip, log_audit_event +from app.services.auth import averify_password_with_upgrade +from app.services.session import ( + create_db_session, + set_session_cookie, + check_account_lockout, + record_failed_login, + record_successful_login, +) +from app.services.passkey import ( + create_challenge_token, + verify_challenge_token, + build_registration_options, + verify_registration as verify_registration_response_svc, + build_authentication_options, + verify_authentication as verify_authentication_response_svc, +) +from webauthn.helpers import bytes_to_base64url, base64url_to_bytes + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# Request/Response schemas +# --------------------------------------------------------------------------- + +class PasskeyRegisterBeginRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + password: str = Field(max_length=128) + + +class PasskeyRegisterCompleteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + credential: str = Field(max_length=8192) + challenge_token: str = Field(max_length=2048) + name: str = Field(min_length=1, max_length=100) + + +class PasskeyLoginBeginRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + username: str | None = Field(None, max_length=50) + + +class PasskeyLoginCompleteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + credential: str = Field(max_length=8192) + challenge_token: str = Field(max_length=2048) + + +class PasskeyDeleteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + password: str = Field(max_length=128) + + +# --------------------------------------------------------------------------- +# Registration endpoints (authenticated) +# --------------------------------------------------------------------------- + +@router.post("/register/begin") +async def passkey_register_begin( + data: PasskeyRegisterBeginRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Start passkey registration. Requires password re-entry (V-02).""" + # V-02: Verify password before allowing registration + valid, new_hash = await averify_password_with_upgrade( + data.password, current_user.password_hash + ) + if not valid: + raise HTTPException(status_code=401, detail="Invalid password") + if new_hash: + current_user.password_hash = new_hash + await db.commit() + + # Load existing credential IDs for exclusion + result = await db.execute( + select(PasskeyCredential.credential_id).where( + PasskeyCredential.user_id == current_user.id + ) + ) + existing_ids = [ + base64url_to_bytes(row[0]) for row in result.all() + ] + + options_json, challenge = build_registration_options( + user_id=current_user.id, + username=current_user.username, + existing_credential_ids=existing_ids, + ) + token = create_challenge_token(challenge, user_id=current_user.id) + + return { + "options": json.loads(options_json), + "challenge_token": token, + } + + +@router.post("/register/complete") +async def passkey_register_complete( + data: PasskeyRegisterCompleteRequest, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Complete passkey registration ceremony.""" + # Verify challenge token — cross-check user binding (S-01) + single-use nonce (V-01) + challenge = verify_challenge_token( + data.challenge_token, expected_user_id=current_user.id + ) + if challenge is None: + raise HTTPException(status_code=401, detail="Invalid or expired challenge") + + try: + verified = verify_registration_response_svc( + credential_json=data.credential, + challenge=challenge, + ) + except Exception as e: + logger.warning("Passkey registration verification failed: %s", e) + raise HTTPException(status_code=400, detail="Registration verification failed") + + # Store credential + credential_id_b64 = bytes_to_base64url(verified.credential_id) + + # Check for duplicate (race condition safety) + existing = await db.execute( + select(PasskeyCredential).where( + PasskeyCredential.credential_id == credential_id_b64 + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Credential already registered") + + # Extract transport hints if available + transports_json = None + if hasattr(verified, 'credential_device_type'): + pass # py_webauthn doesn't expose transports on VerifiedRegistration + # Transports come from the browser response — parse from credential JSON + try: + cred_data = json.loads(data.credential) + if "response" in cred_data and "transports" in cred_data["response"]: + transports_json = json.dumps(cred_data["response"]["transports"]) + except (json.JSONDecodeError, KeyError): + pass + + # Determine backup state from py_webauthn flags + backed_up = getattr(verified, 'credential_backed_up', False) + + new_credential = PasskeyCredential( + user_id=current_user.id, + credential_id=credential_id_b64, + public_key=bytes_to_base64url(verified.credential_public_key), + sign_count=verified.sign_count, + name=data.name, + transports=transports_json, + backed_up=backed_up, + ) + db.add(new_credential) + + # B-02: If user has mfa_enforce_pending, clear it (passkey = MFA) + if current_user.mfa_enforce_pending: + current_user.mfa_enforce_pending = False + + # Extract response data BEFORE commit (ORM expiry rule) + response_data = { + "id": None, # will be set after flush + "name": new_credential.name, + "created_at": None, + "backed_up": backed_up, + } + + await db.flush() + response_data["id"] = new_credential.id + response_data["created_at"] = str(new_credential.created_at) if new_credential.created_at else None + + await log_audit_event( + db, action="passkey.registered", actor_id=current_user.id, + detail={"credential_name": data.name}, + ip=get_client_ip(request), + ) + await db.commit() + + return response_data + + +# --------------------------------------------------------------------------- +# Authentication endpoints (unauthenticated — CSRF-exempt) +# --------------------------------------------------------------------------- + +@router.post("/login/begin") +async def passkey_login_begin( + data: PasskeyLoginBeginRequest, + db: AsyncSession = Depends(get_db), +): + """Start passkey authentication. CSRF-exempt, public endpoint.""" + credential_data = None + + if data.username: + # Look up user's credentials for allowCredentials + result = await db.execute( + select(User).where(User.username == data.username.lower().strip()) + ) + user = result.scalar_one_or_none() + if user: + cred_result = await db.execute( + select( + PasskeyCredential.credential_id, + PasskeyCredential.transports, + ).where(PasskeyCredential.user_id == user.id) + ) + rows = cred_result.all() + if rows: + credential_data = [] + for row in rows: + cid_bytes = base64url_to_bytes(row[0]) + transports = json.loads(row[1]) if row[1] else None + credential_data.append((cid_bytes, transports)) + + # V-03: Generate options regardless of whether user exists or has passkeys. + # Identical response shape prevents timing enumeration. + options_json, challenge = build_authentication_options( + credential_ids_and_transports=credential_data, + ) + token = create_challenge_token(challenge) + + return { + "options": json.loads(options_json), + "challenge_token": token, + } + + +@router.post("/login/complete") +async def passkey_login_complete( + data: PasskeyLoginCompleteRequest, + request: Request, + response: Response, + db: AsyncSession = Depends(get_db), +): + """Complete passkey authentication. CSRF-exempt, public endpoint.""" + # Verify challenge token (60s TTL, single-use nonce V-01) + challenge = verify_challenge_token(data.challenge_token) + if challenge is None: + raise HTTPException(status_code=401, detail="Authentication failed") + + # Parse credential_id from browser response + try: + cred_data = json.loads(data.credential) + raw_id_b64 = cred_data.get("rawId") or cred_data.get("id", "") + except (json.JSONDecodeError, KeyError): + raise HTTPException(status_code=401, detail="Authentication failed") + + # Look up credential by ID + result = await db.execute( + select(PasskeyCredential).where( + PasskeyCredential.credential_id == raw_id_b64 + ) + ) + credential = result.scalar_one_or_none() + if not credential: + raise HTTPException(status_code=401, detail="Authentication failed") + + # Load user + user_result = await db.execute( + select(User).where(User.id == credential.user_id) + ) + user = user_result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=401, detail="Authentication failed") + + # Check account lockout (C-03) + await check_account_lockout(user) + + # Check active status (C-03) + if not user.is_active: + raise HTTPException(status_code=401, detail="Authentication failed") + + # Verify the authentication response + try: + verified = verify_authentication_response_svc( + credential_json=data.credential, + challenge=challenge, + credential_public_key=base64url_to_bytes(credential.public_key), + credential_current_sign_count=credential.sign_count, + ) + except Exception as e: + logger.warning("Passkey authentication verification failed for user %s: %s", user.id, e) + # Increment failed login counter (shared with password auth) + await record_failed_login(db, user) + await log_audit_event( + db, action="passkey.login_failed", actor_id=user.id, + detail={"reason": "verification_failed"}, + ip=get_client_ip(request), + ) + await db.commit() + raise HTTPException(status_code=401, detail="Authentication failed") + + # Update sign count (log anomaly but don't fail — S-05) + new_sign_count = verified.new_sign_count + if new_sign_count < credential.sign_count and credential.sign_count > 0: + logger.warning( + "Sign count anomaly for user %s credential %s: expected >= %d, got %d", + user.id, credential.id, credential.sign_count, new_sign_count, + ) + await log_audit_event( + db, action="passkey.sign_count_anomaly", actor_id=user.id, + detail={ + "credential_id": credential.id, + "expected": credential.sign_count, + "received": new_sign_count, + }, + ip=get_client_ip(request), + ) + + credential.sign_count = new_sign_count + credential.last_used_at = datetime.now() + + # Record successful login + await record_successful_login(db, user) + + # Create session (shared service — enforces session cap) + client_ip = get_client_ip(request) + user_agent = request.headers.get("user-agent") + _, token = await create_db_session(db, user, client_ip, user_agent) + set_session_cookie(response, token) + + await log_audit_event( + db, action="passkey.login_success", actor_id=user.id, + detail={"credential_name": credential.name}, + ip=client_ip, + ) + await db.commit() + + # B-01: Handle must_change_password and mfa_enforce_pending flags + result_data: dict = {"authenticated": True} + if user.must_change_password: + result_data["must_change_password"] = True + # Passkey satisfies MFA — if mfa_enforce_pending, clear it + if user.mfa_enforce_pending: + user.mfa_enforce_pending = False + await db.commit() + + return result_data + + +# --------------------------------------------------------------------------- +# Management endpoints (authenticated) +# --------------------------------------------------------------------------- + +@router.get("/") +async def list_passkeys( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List all passkeys for the current user.""" + result = await db.execute( + select(PasskeyCredential) + .where(PasskeyCredential.user_id == current_user.id) + .order_by(PasskeyCredential.created_at.desc()) + ) + credentials = result.scalars().all() + + return [ + { + "id": c.id, + "name": c.name, + "created_at": str(c.created_at) if c.created_at else None, + "last_used_at": str(c.last_used_at) if c.last_used_at else None, + "backed_up": c.backed_up, + } + for c in credentials + ] + + +@router.delete("/{credential_id}") +async def delete_passkey( + credential_id: int, + data: PasskeyDeleteRequest, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete a passkey. Requires password confirmation (S-06).""" + # Verify password + valid, new_hash = await averify_password_with_upgrade( + data.password, current_user.password_hash + ) + if not valid: + raise HTTPException(status_code=401, detail="Invalid password") + if new_hash: + current_user.password_hash = new_hash + + # Look up credential — verify ownership (IDOR prevention) + result = await db.execute( + select(PasskeyCredential).where( + PasskeyCredential.id == credential_id, + PasskeyCredential.user_id == current_user.id, + ) + ) + credential = result.scalar_one_or_none() + if not credential: + raise HTTPException(status_code=404, detail="Passkey not found") + + cred_name = credential.name + await db.delete(credential) + + await log_audit_event( + db, action="passkey.deleted", actor_id=current_user.id, + detail={"credential_name": cred_name, "credential_db_id": credential_id}, + ip=get_client_ip(request), + ) + await db.commit() + + return {"message": "Passkey removed"} diff --git a/backend/app/services/passkey.py b/backend/app/services/passkey.py new file mode 100644 index 0000000..fda3a1b --- /dev/null +++ b/backend/app/services/passkey.py @@ -0,0 +1,220 @@ +""" +Passkey (WebAuthn/FIDO2) service. + +Handles challenge token creation/verification (itsdangerous + nonce replay protection) +and wraps py_webauthn library calls for registration and authentication ceremonies. +""" +import base64 +import json +import logging +import secrets +import time +import threading + +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired + +from webauthn import ( + generate_registration_options, + verify_registration_response, + generate_authentication_options, + verify_authentication_response, + options_to_json, +) +from webauthn.helpers.structs import ( + PublicKeyCredentialDescriptor, + AuthenticatorSelectionCriteria, + AuthenticatorTransport, + ResidentKeyRequirement, + UserVerificationRequirement, + AttestationConveyancePreference, +) +from webauthn.helpers import bytes_to_base64url, base64url_to_bytes + +from app.config import settings as app_settings + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Challenge token management (itsdangerous + nonce replay protection V-01) +# --------------------------------------------------------------------------- + +_challenge_serializer = URLSafeTimedSerializer( + secret_key=app_settings.SECRET_KEY, + salt="webauthn-challenge-v1", +) + +# Thread-safe nonce cache for single-use enforcement +# Keys: nonce string, Values: expiry timestamp +_used_nonces: dict[str, float] = {} +_nonce_lock = threading.Lock() + + +def create_challenge_token(challenge: bytes, user_id: int | None = None) -> str: + """Sign challenge + nonce + optional user_id. Returns opaque token string.""" + nonce = secrets.token_urlsafe(16) + payload = { + "ch": base64.b64encode(challenge).decode(), + "n": nonce, + } + if user_id is not None: + payload["uid"] = user_id + return _challenge_serializer.dumps(payload) + + +def verify_challenge_token(token: str, expected_user_id: int | None = None) -> bytes | None: + """Verify token (TTL from config), enforce single-use via nonce. + + If expected_user_id provided, cross-check user binding (for registration). + Returns challenge bytes or None on failure. + """ + try: + data = _challenge_serializer.loads( + token, max_age=app_settings.WEBAUTHN_CHALLENGE_TTL + ) + except (BadSignature, SignatureExpired): + return None + + nonce = data.get("n") + if not nonce: + return None + + now = time.time() + with _nonce_lock: + # Lazy cleanup of expired nonces + expired = [k for k, v in _used_nonces.items() if v <= now] + for k in expired: + del _used_nonces[k] + + # Check for replay + if nonce in _used_nonces: + return None + + # Mark nonce as used + _used_nonces[nonce] = now + app_settings.WEBAUTHN_CHALLENGE_TTL + + # Cross-check user binding for registration tokens + if expected_user_id is not None: + if data.get("uid") != expected_user_id: + return None + + return base64.b64decode(data["ch"]) + + +# --------------------------------------------------------------------------- +# py_webauthn wrappers +# All synchronous — ECDSA P-256 verification is ~0.1ms, faster than executor overhead. +# --------------------------------------------------------------------------- + +def build_registration_options( + user_id: int, + username: str, + existing_credential_ids: list[bytes], +) -> tuple[str, bytes]: + """Generate WebAuthn registration options. + + Returns (options_json_str, challenge_bytes). + """ + exclude_credentials = [ + PublicKeyCredentialDescriptor(id=cid) + for cid in existing_credential_ids + ] + + options = generate_registration_options( + rp_id=app_settings.WEBAUTHN_RP_ID, + rp_name=app_settings.WEBAUTHN_RP_NAME, + user_id=str(user_id).encode(), + user_name=username, + attestation=AttestationConveyancePreference.NONE, + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.PREFERRED, + ), + exclude_credentials=exclude_credentials, + timeout=60000, + ) + + options_json = options_to_json(options) + return options_json, options.challenge + + +def verify_registration( + credential_json: str, + challenge: bytes, +) -> "VerifiedRegistration": + """Verify a registration response from the browser. + + Returns VerifiedRegistration on success, raises on failure. + """ + from webauthn.helpers.structs import RegistrationCredential + + credential = RegistrationCredential.model_validate_json(credential_json) + return verify_registration_response( + credential=credential, + expected_challenge=challenge, + expected_rp_id=app_settings.WEBAUTHN_RP_ID, + expected_origin=app_settings.WEBAUTHN_ORIGIN, + require_user_verification=False, + ) + + +def build_authentication_options( + credential_ids_and_transports: list[tuple[bytes, list[str] | None]] | None = None, +) -> tuple[str, bytes]: + """Generate WebAuthn authentication options. + + If credential_ids_and_transports provided, includes allowCredentials. + Otherwise, allows discoverable credential flow. + Returns (options_json_str, challenge_bytes). + """ + allow_credentials = None + if credential_ids_and_transports: + allow_credentials = [] + for cid, transports in credential_ids_and_transports: + transport_list = None + if transports: + transport_list = [ + AuthenticatorTransport(t) + for t in transports + if t in [e.value for e in AuthenticatorTransport] + ] + allow_credentials.append( + PublicKeyCredentialDescriptor( + id=cid, + transports=transport_list or None, + ) + ) + + options = generate_authentication_options( + rp_id=app_settings.WEBAUTHN_RP_ID, + allow_credentials=allow_credentials, + user_verification=UserVerificationRequirement.PREFERRED, + timeout=60000, + ) + + options_json = options_to_json(options) + return options_json, options.challenge + + +def verify_authentication( + credential_json: str, + challenge: bytes, + credential_public_key: bytes, + credential_current_sign_count: int, +) -> "VerifiedAuthentication": + """Verify an authentication response from the browser. + + Returns VerifiedAuthentication on success, raises on failure. + Sign count anomalies are NOT hard-failed — caller should log and continue. + """ + from webauthn.helpers.structs import AuthenticationCredential + + credential = AuthenticationCredential.model_validate_json(credential_json) + return verify_authentication_response( + credential=credential, + expected_challenge=challenge, + expected_rp_id=app_settings.WEBAUTHN_RP_ID, + expected_origin=app_settings.WEBAUTHN_ORIGIN, + credential_public_key=credential_public_key, + credential_current_sign_count=credential_current_sign_count, + require_user_verification=False, + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 0f6463b..36582a7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,3 +15,4 @@ python-dateutil==2.9.0 itsdangerous==2.2.0 httpx==0.27.2 apscheduler==3.10.4 +webauthn>=2.1.0 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 85d709c..7bbfa77 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -83,6 +83,29 @@ server { include /etc/nginx/proxy-params.conf; } + # Passkey authentication — rate-limited (C-04) + location /api/auth/passkeys/login/begin { + limit_req zone=auth_limit burst=5 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + location /api/auth/passkeys/login/complete { + limit_req zone=auth_limit burst=5 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + # Passkey registration — authenticated, lower burst + location /api/auth/passkeys/register/begin { + limit_req zone=auth_limit burst=3 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + location /api/auth/passkeys/register/complete { + limit_req zone=auth_limit burst=3 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + # SEC-14: Rate-limit public registration endpoint location /api/auth/register { limit_req zone=register_limit burst=3 nodelay; @@ -164,5 +187,5 @@ server { add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # PT-I03: Restrict unnecessary browser APIs - add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=*, publickey-credentials-create=*" always; }