""" app/models/webauthn_credential.py — WebAuthn (passkey) credential storage. Each user can register multiple passkeys (phone, laptop, YubiKey, etc.). Credentials are used for server authentication only — the vault key is still derived from the master password client-side (zero-knowledge preserved). Zero-knowledge note: WebAuthn replaces TOTP as a second factor OR replaces the password-based server auth (passwordless flow). In both cases the master password is still required client-side to derive the vault key. """ import json from datetime import datetime, timezone from sqlalchemy.dialects.mysql import INTEGER from app import db class WebAuthnCredential(db.Model): __tablename__ = 'webauthn_credentials' id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True) user_id = db.Column( INTEGER(unsigned=True), db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True, ) # Base64url-encoded credential ID from the authenticator. credential_id = db.Column(db.String(512), nullable=False, unique=True) # COSE-encoded public key bytes, base64url. public_key = db.Column(db.Text, nullable=False) # Monotonically increasing counter — used to detect cloned authenticators. sign_count = db.Column(db.BigInteger, nullable=False, default=0) # JSON list of transport hints e.g. '["internal", "hybrid"]' transports = db.Column(db.String(255), nullable=True) # Authenticator AAGUID (UUID string) from attestation. aaguid = db.Column(db.String(64), nullable=True) # User-assigned friendly name shown in the UI. name = db.Column(db.String(128), nullable=False, default='Passkey') created_at = db.Column( db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), ) last_used_at = db.Column(db.DateTime, nullable=True) def get_transports(self) -> list: """Return transport hints as a Python list (empty list if none).""" if not self.transports: return [] try: return json.loads(self.transports) except (ValueError, TypeError): return [] def set_transports(self, transports: list) -> None: self.transports = json.dumps(transports) if transports else None def to_dict(self) -> dict: return { 'id': self.id, 'credential_id': self.credential_id, 'name': self.name, 'aaguid': self.aaguid, 'transports': self.get_transports(), 'created_at': self.created_at.isoformat() if self.created_at else None, 'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None, } def __repr__(self): return f''