Ctrl K
ComunidadGuías…

app/passkeys.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Passkeys and security keys: the two WebAuthn ceremonies.

Separate from auth.py for the same reason twofactor.py is — none of this knows
about sessions or requests. It turns an authenticator's answer into either a
stored credential or a verified user id, and refuses everything else.

Three things this file exists to get right:

* **`rp_id` comes from config, never from a request.** Host and
  X-Forwarded-Host are client-controlled, and the rp_id is the anchor every
  credential is permanently bound to — decisions.md #96.
* **A challenge is single-use.** It is deleted as it is read, so a replayed
  ceremony finds nothing to verify against rather than a still-valid nonce.
* **Signing in is discoverable.** The authenticator names the account, so no
  email is asked for and none is confirmed to exist.
"""

from __future__ import annotations

import datetime as dt
import hashlib
import logging
import secrets

from sqlalchemy import delete, select
from sqlalchemy.orm import Session as DbSession

from appcore import ensure_utc

from .models import User, WebauthnChallenge, WebauthnCredential
from .settings import PortfolioSettings

log = logging.getLogger(__name__)

# How long a browser has to finish a ceremony. The prompt itself times out at
# 60s; this is the outer bound on the row, not the user's patience.
CHALLENGE_TTL = dt.timedelta(minutes=5)


def _utcnow() -> dt.datetime:
    return dt.datetime.now(dt.timezone.utc)


def _webauthn():
    """Imported on use, like pyotp — see app/memory.py for the rationale."""
    import webauthn

    return webauthn


class PasskeyError(Exception):
    """A ceremony that did not verify. `message` is the sentence to show: one
    of this module's own, never an exception's text."""

    def __init__(self, message: str):
        super().__init__(message)
        self.message = message


# --------------------------------------------------------------------------- #
# Whether this deployment can offer them at all
# --------------------------------------------------------------------------- #

def configured(settings: PortfolioSettings) -> bool:
    """Enabled, with the two things a credential cannot be created without.

    Deliberately not "enabled", singular: a true `enabled` with no rp_id would
    offer a button that fails inside the browser, where the reason is invisible.
    """
    wa = settings.auth.webauthn
    return bool(wa.enabled and wa.rp_id and wa.origins)


def unavailable_reason(settings: PortfolioSettings, *, secure: bool) -> str | None:
    """Why passkeys cannot be offered here, in a sentence, or None.

    Said out loud rather than hiding the control, because every reason is
    something the person reading it can fix, and a missing button teaches
    nobody anything.
    """
    wa = settings.auth.webauthn
    if not wa.enabled:
        return "Turn on passkeys in Admin → Settings."
    if not wa.rp_id or not wa.origins:
        return "Set the passkey domain in Admin → Settings."
    if not secure:
        return "Requires Stocktake to be available via HTTPS."
    return None


# --------------------------------------------------------------------------- #
# Challenges
# --------------------------------------------------------------------------- #

def _hash_token(raw: str) -> str:
    return hashlib.sha256(raw.encode()).hexdigest()


def issue_challenge(db: DbSession, challenge: bytes, user: User | None) -> str:
    """Remember a challenge, returning the token the browser carries back."""
    raw = secrets.token_urlsafe(32)
    db.add(WebauthnChallenge(
        token_hash=_hash_token(raw),
        challenge=_b64(challenge),
        user_id=user.id if user else None,
        expires_at=_utcnow() + CHALLENGE_TTL,
    ))
    db.flush()
    return raw


def take_challenge(db: DbSession, raw: str | None) -> tuple[bytes, int | None]:
    """The challenge behind this token, consumed as it is read.

    Deleting on read is what makes a ceremony single-use: replaying a captured
    response finds no row, rather than a nonce that is still good.
    """
    if not raw:
        raise PasskeyError("This sign-in attempt has expired. Try again.")
    row = db.scalar(
        select(WebauthnChallenge).where(
            WebauthnChallenge.token_hash == _hash_token(raw))
    )
    if row is None or ensure_utc(row.expires_at) < _utcnow():
        if row is not None:
            db.delete(row)
            db.flush()
        raise PasskeyError("This sign-in attempt has expired. Try again.")
    challenge, user_id = _unb64(row.challenge), row.user_id
    db.delete(row)
    db.flush()
    return challenge, user_id


def purge_expired_challenges(db: DbSession) -> int:
    """Half-finished ceremonies nobody came back to. Runs with maintenance."""
    result = db.execute(
        delete(WebauthnChallenge).where(WebauthnChallenge.expires_at < _utcnow()))
    return result.rowcount or 0


def _b64(raw: bytes) -> str:
    import base64

    return base64.urlsafe_b64encode(raw).decode().rstrip("=")


def _unb64(value: str) -> bytes:
    import base64

    return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))


# --------------------------------------------------------------------------- #
# Enrolment
# --------------------------------------------------------------------------- #

def registration_options(
    db: DbSession, user: User, settings: PortfolioSettings
) -> tuple[str, str]:
    """Options for `navigator.credentials.create`, and the challenge token.

    Existing credentials are excluded so a key already enrolled says so in the
    browser's own words instead of registering itself twice.
    """
    wa = settings.auth.webauthn
    w = _webauthn()
    from webauthn.helpers.structs import (  # noqa: PLC0415 - optional dependency
        AuthenticatorSelectionCriteria,
        PublicKeyCredentialDescriptor,
        ResidentKeyRequirement,
        UserVerificationRequirement,
    )

    options = w.generate_registration_options(
        rp_id=wa.rp_id,
        rp_name=wa.rp_name or wa.rp_id,
        # `public_id` rather than the row id: it is what the account is known
        # by outside the database, and it is what a credential binds to.
        user_id=str(user.public_id).encode(),
        user_name=user.email,
        user_display_name=user.name or user.email,
        exclude_credentials=[
            PublicKeyCredentialDescriptor(id=_unb64(c.credential_id))
            for c in _credentials(db, user)
        ],
        authenticator_selection=AuthenticatorSelectionCriteria(
            # Discoverable, so signing in needs no email typed first.
            resident_key=ResidentKeyRequirement.PREFERRED,
            # Enrolled under the same requirement sign-in enforces, so a key
            # cannot be registered here and then refused every time it is used.
            user_verification=UserVerificationRequirement.REQUIRED,
        ),
    )
    return w.options_to_json(options), issue_challenge(db, options.challenge, user)


def verify_registration(
    db: DbSession, user: User, credential: str, token: str | None,
    settings: PortfolioSettings, *, name: str = "",
) -> WebauthnCredential:
    """Store the credential the browser just created, or raise."""
    wa = settings.auth.webauthn
    challenge, owner = take_challenge(db, token)
    if owner != user.id:
        raise PasskeyError("That request belongs to a different account.")
    try:
        verified = _webauthn().verify_registration_response(
            credential=credential,
            expected_challenge=challenge,
            expected_rp_id=wa.rp_id,
            expected_origin=list(wa.origins),
        )
    except Exception as exc:  # noqa: BLE001 - the library raises many types
        # Usually the origin or domain set in Admin → Settings, which only
        # whoever runs the install can fix, so the reason goes to the log.
        log.warning("a passkey could not be registered: %s", exc)
        raise PasskeyError("That passkey could not be registered.") from exc

    row = WebauthnCredential(
        user_id=user.id,
        credential_id=_b64(verified.credential_id),
        public_key=_b64(verified.credential_public_key),
        sign_count=verified.sign_count,
        name=(name or "").strip()[:80] or "Passkey",
        rp_id=wa.rp_id,
    )
    db.add(row)
    db.flush()
    return row


# --------------------------------------------------------------------------- #
# Signing in
# --------------------------------------------------------------------------- #

def authentication_options(
    db: DbSession, settings: PortfolioSettings
) -> tuple[str, str]:
    """Options for `navigator.credentials.get`, and the challenge token.

    No `allow_credentials`: the ceremony is discoverable, and naming the
    account's keys would mean asking who is signing in before they have proved
    anything — which answers "does this email have an account" to anyone who
    asks.
    """
    w = _webauthn()
    from webauthn.helpers.structs import (  # noqa: PLC0415 - optional dependency
        UserVerificationRequirement,
    )

    # REQUIRED is what lets a passkey stand in for a password AND a code: it
    # makes the authenticator prove a biometric or PIN, so the assertion is
    # two factors rather than mere possession. decisions.md #113.
    options = w.generate_authentication_options(
        rp_id=settings.auth.webauthn.rp_id,
        user_verification=UserVerificationRequirement.REQUIRED,
    )
    return w.options_to_json(options), issue_challenge(db, options.challenge, None)


def verify_authentication(
    db: DbSession, credential: str, token: str | None, settings: PortfolioSettings
) -> User:
    """The user this assertion proves, or raise.

    The sign counter is the replay defence: an authenticator that implements it
    must never send one that has gone backwards. Most passkeys always send 0,
    which means "no counter" rather than "replayed" — so 0 is not compared.
    """
    wa = settings.auth.webauthn
    challenge, _ = take_challenge(db, token)
    import json  # noqa: PLC0415 - only this path needs it

    try:
        raw_id = json.loads(credential)["rawId"]
    except (ValueError, KeyError, TypeError) as exc:
        raise PasskeyError("That passkey could not be read.") from exc

    row = db.scalar(
        select(WebauthnCredential).where(WebauthnCredential.credential_id == raw_id))
    if row is None:
        raise PasskeyError("That passkey is not registered here.")

    try:
        verified = _webauthn().verify_authentication_response(
            credential=credential,
            expected_challenge=challenge,
            expected_rp_id=wa.rp_id,
            expected_origin=list(wa.origins),
            credential_public_key=_unb64(row.public_key),
            credential_current_sign_count=row.sign_count,
            require_user_verification=True,
        )
    except Exception as exc:  # noqa: BLE001 - the library raises many types
        raise PasskeyError("That passkey could not be verified.") from exc

    if verified.new_sign_count:
        row.sign_count = verified.new_sign_count
    row.last_used_at = _utcnow()
    db.flush()
    user = db.get(User, row.user_id)
    if user is None:
        raise PasskeyError("That passkey is not registered here.")
    return user


# --------------------------------------------------------------------------- #
# The list on the account page
# --------------------------------------------------------------------------- #

def _credentials(db: DbSession, user: User) -> list[WebauthnCredential]:
    return list(db.scalars(
        select(WebauthnCredential)
        .where(WebauthnCredential.user_id == user.id)
        .order_by(WebauthnCredential.created_at)
    ))


def for_user(db: DbSession, user: User) -> list[WebauthnCredential]:
    return _credentials(db, user)


def count_for(db: DbSession, user: User) -> int:
    return len(_credentials(db, user))


def remove(db: DbSession, user: User, credential_id: int) -> bool:
    """Delete one of this user's passkeys. Scoped to the user on purpose —
    an id from a form must never be able to reach somebody else's row."""
    row = db.scalar(
        select(WebauthnCredential).where(
            WebauthnCredential.id == credential_id,
            WebauthnCredential.user_id == user.id,
        )
    )
    if row is None:
        return False
    db.delete(row)
    db.flush()
    return True