75 lines
2.8 KiB
Python
75 lines
2.8 KiB
Python
"""
|
|
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'<WebAuthnCredential user_id={self.user_id} name={self.name!r}>'
|