UMBRA/backend/app/models/passkey_credential.py
Kyle Pope e8e3f62ff8 Phase 1: Add passkey (WebAuthn/FIDO2) backend
New files:
- models/passkey_credential.py: PasskeyCredential model with indexed credential_id
- alembic 061: Create passkey_credentials table
- services/passkey.py: Challenge token management (itsdangerous + nonce replay
  protection) and py_webauthn wrappers for registration/authentication
- routers/passkeys.py: 6 endpoints (register begin/complete, login begin/complete,
  list, delete) with full security hardening

Changes:
- config.py: WEBAUTHN_RP_ID, RP_NAME, ORIGIN, CHALLENGE_TTL settings
- main.py: Mount passkey router, add CSRF exemptions for login endpoints
- auth.py: Add has_passkeys to /auth/status response
- nginx.conf: Rate limiting on all passkey endpoints, Permissions-Policy
  updated for publickey-credentials-get/create
- requirements.txt: Add webauthn>=2.1.0

Security: password re-entry for registration (V-02), single-use nonce
challenges (V-01), constant-time login/begin (V-03), shared lockout
counter, generic 401 errors, audit logging on all events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:46:00 +08:00

31 lines
1.3 KiB
Python

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)