Ctrl K
ComunidadGuías…

tests/test_passkeys.py

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

Archivo original ↗Preguntar a KiBot
"""The two WebAuthn ceremonies, driven by a real software authenticator.

`SoftWebauthnDevice` performs the same signing a phone or a YubiKey does, so
these exercise the actual cryptography rather than a mock of it — a stub would
happily agree that an unsigned response was valid, which is the one thing worth
checking here.

The base64url handling is deliberately not shared with `app/passkeys.py`: these
convert with the library's own helpers, so a mistake in ours shows up as a
disagreement rather than being applied to both sides.
"""

from __future__ import annotations

import base64
import hashlib
import json

import pytest
from cryptography.hazmat.primitives import hashes as _hashes
from cryptography.hazmat.primitives.asymmetric import ec as _ec
from soft_webauthn import SoftWebauthnDevice
from sqlalchemy import select
from webauthn.helpers import base64url_to_bytes

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


def _sha256(data: bytes) -> bytes:
    return hashlib.sha256(data).digest()


RP_ID = "stocktake.example.test"
ORIGIN = f"https://{RP_ID}"


@pytest.fixture
def settings() -> PortfolioSettings:
    s = PortfolioSettings()
    s.auth.webauthn.enabled = True
    s.auth.webauthn.rp_id = RP_ID
    s.auth.webauthn.rp_name = "Stocktake"
    s.auth.webauthn.origins = [ORIGIN]
    return s


@pytest.fixture
def user(session_factory) -> User:
    with session_factory() as db:
        u = User(email="owner@example.test", name="Owner", password_hash="x")
        db.add(u)
        db.commit()
        return u


class VerifyingDevice(SoftWebauthnDevice):
    """A device that performs user verification, as a real passkey does.

    `SoftWebauthnDevice` hardcodes the authenticator-data flags to `0x01` —
    User Present only, never User Verified. A phone or a security key asked for
    a biometric sets `0x04` as well, and the app requires it: that bit is what
    makes an assertion two factors rather than mere possession, which is why a
    passkey may sign in without a password or a code.

    The flag has to be set BEFORE signing, since the signature covers the
    authenticator data — so this re-signs rather than editing the result.
    """

    UP_AND_UV = b"\x05"

    def get(self, options, origin):
        assertion = super().get(options, origin)
        response = assertion["response"]
        data = bytearray(response["authenticatorData"])
        data[32:33] = self.UP_AND_UV
        response["authenticatorData"] = bytes(data)
        response["signature"] = self.private_key.sign(
            bytes(data) + _sha256(response["clientDataJSON"]),
            _ec.ECDSA(_hashes.SHA256()),
        )
        return assertion


def _b64(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).decode().rstrip("=")


def _for_device(raw_options: str) -> dict:
    """The options as a browser hands them to an authenticator.

    `options_to_json` base64url-encodes every buffer for the wire; the device
    wants the bytes back. Decoding here rather than in the app is deliberate —
    it is the browser's job, and doing it with the library's own helper keeps
    our encoding honest.
    """
    options = json.loads(raw_options)
    options["challenge"] = base64url_to_bytes(options["challenge"])
    if "user" in options:
        options["user"]["id"] = base64url_to_bytes(options["user"]["id"])
    for key in ("excludeCredentials", "allowCredentials"):
        for descriptor in options.get(key) or []:
            descriptor["id"] = base64url_to_bytes(descriptor["id"])
    return {"publicKey": options}


def _as_json(credential: dict) -> str:
    """What the browser posts: buffers base64url'd, `id` left as it is.

    `id` is ALREADY the base64url text of `rawId` — the device returns it as
    ASCII bytes. Encoding it like a buffer double-encodes it, and the library
    rejects the result with "id and raw_id were not equivalent".

    The padding is stripped because this device keeps the `=` and a browser
    does not. That is a fidelity gap in the double, not a case the app has to
    handle.
    """
    def enc(value):
        if isinstance(value, bytes):
            return _b64(value)
        if isinstance(value, dict):
            return {k: (v.decode().rstrip("=") if k == "id" and isinstance(v, bytes)
                        else enc(v))
                    for k, v in value.items()}
        return value

    return json.dumps(enc(credential))


def _enrol(db, user, settings, device=None):
    """Run a full registration and return the device that now holds the key."""
    device = device or VerifyingDevice()
    raw_options, token = passkeys.registration_options(db, user, settings)
    attestation = device.create(_for_device(raw_options), ORIGIN)
    passkeys.verify_registration(
        db, user, _as_json(attestation), token, settings, name="Test key")
    return device


def _assert(db, device, settings):
    raw_options, token = passkeys.authentication_options(db, settings)
    assertion = device.get(_for_device(raw_options), ORIGIN)
    return _as_json(assertion), token


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

def test_a_real_authenticator_enrols(session_factory, user, settings):
    with session_factory() as db:
        u = db.get(User, user.id)
        _enrol(db, u, settings)
        row = db.scalar(select(WebauthnCredential))

    assert row.user_id == user.id
    assert row.rp_id == RP_ID
    assert row.name == "Test key"
    # Stored as base64url of the raw id, which is what the assertion arrives
    # holding — if these disagree, sign-in cannot find the credential.
    assert base64url_to_bytes(row.credential_id)


def test_a_tampered_registration_is_refused(session_factory, user, settings):
    """The signature is what makes enrolment mean anything. A response whose
    client data was edited after signing must not produce a credential."""
    with session_factory() as db:
        u = db.get(User, user.id)
        raw_options, token = passkeys.registration_options(db, u, settings)
        device = SoftWebauthnDevice()
        attestation = device.create(_for_device(raw_options), ORIGIN)
        # Same shape, different challenge: exactly what a replayed or forged
        # response looks like.
        client_data = json.loads(attestation["response"]["clientDataJSON"])
        client_data["challenge"] = _b64(b"a different challenge entirely")
        attestation["response"]["clientDataJSON"] = json.dumps(client_data).encode()

        with pytest.raises(passkeys.PasskeyError):
            passkeys.verify_registration(
                db, u, _as_json(attestation), token, settings)
        assert db.scalar(select(WebauthnCredential)) is None


def test_enrolling_excludes_the_keys_already_held(session_factory, user, settings):
    """Otherwise the same authenticator registers itself twice and the account
    page lists one key as two."""
    with session_factory() as db:
        u = db.get(User, user.id)
        _enrol(db, u, settings)
        raw_options, _ = passkeys.registration_options(db, u, settings)

    excluded = json.loads(raw_options)["excludeCredentials"]
    assert len(excluded) == 1


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

def test_an_enrolled_key_proves_who_it_belongs_to(session_factory, user, settings):
    with session_factory() as db:
        u = db.get(User, user.id)
        device = _enrol(db, u, settings)
        credential, token = _assert(db, device, settings)
        signed_in = passkeys.verify_authentication(db, credential, token, settings)

        assert signed_in.id == user.id


def test_a_key_this_app_has_never_seen_is_refused(session_factory, user, settings):
    with session_factory() as db:
        _enrol(db, db.get(User, user.id), settings)
        stranger = VerifyingDevice()
        stranger.cred_init(RP_ID, b"stranger")
        credential, token = _assert(db, stranger, settings)

        with pytest.raises(passkeys.PasskeyError):
            passkeys.verify_authentication(db, credential, token, settings)


def test_a_challenge_is_consumed_by_reading_it(session_factory, user, settings):
    """Directly, rather than through a ceremony.

    Asserting it end-to-end proves less than it looks: replaying a whole
    assertion is ALSO refused by the sign counter, so that test stays green
    with the deletion removed. This one fails.
    """
    with session_factory() as db:
        u = db.get(User, user.id)
        _, token = passkeys.registration_options(db, u, settings)

        assert passkeys.take_challenge(db, token)
        with pytest.raises(passkeys.PasskeyError):
            passkeys.take_challenge(db, token)


def test_replaying_a_whole_assertion_is_refused(session_factory, user, settings):
    with session_factory() as db:
        u = db.get(User, user.id)
        device = _enrol(db, u, settings)
        credential, token = _assert(db, device, settings)
        passkeys.verify_authentication(db, credential, token, settings)

        with pytest.raises(passkeys.PasskeyError):
            passkeys.verify_authentication(db, credential, token, settings)


def test_a_registration_challenge_cannot_be_spent_by_another_account(
    session_factory, user, settings
):
    """The challenge remembers who it was issued to. Without that check, a
    credential enrolled in one ceremony could be attached to a different
    account by whoever could post the response.
    """
    with session_factory() as db:
        other = User(email="other@example.test", name="Other", password_hash="x")
        db.add(other)
        db.flush()
        u = db.get(User, user.id)
        raw_options, token = passkeys.registration_options(db, u, settings)
        attestation = SoftWebauthnDevice().create(_for_device(raw_options), ORIGIN)

        with pytest.raises(passkeys.PasskeyError, match="different account"):
            passkeys.verify_registration(
                db, other, _as_json(attestation), token, settings)


def test_an_expired_challenge_is_refused(session_factory, user, settings):
    import datetime as dt

    with session_factory() as db:
        u = db.get(User, user.id)
        device = _enrol(db, u, settings)
        credential, token = _assert(db, device, settings)
        row = db.scalar(select(WebauthnChallenge))
        row.expires_at = dt.datetime.now(dt.timezone.utc) - dt.timedelta(seconds=1)
        db.flush()

        with pytest.raises(passkeys.PasskeyError):
            passkeys.verify_authentication(db, credential, token, settings)


def test_signing_in_asks_for_no_account(session_factory, settings):
    """The ceremony is discoverable. Naming the account's keys up front would
    answer "does this email have an account here" to anybody who asked."""
    with session_factory() as db:
        raw_options, _ = passkeys.authentication_options(db, settings)

    assert not json.loads(raw_options).get("allowCredentials")


def test_a_stale_challenge_is_swept(session_factory, user, settings):
    import datetime as dt

    with session_factory() as db:
        u = db.get(User, user.id)
        passkeys.registration_options(db, u, settings)
        db.scalar(select(WebauthnChallenge)).expires_at = (
            dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=1))
        db.flush()

        assert passkeys.purge_expired_challenges(db) == 1
        assert db.scalar(select(WebauthnChallenge)) is None


# --------------------------------------------------------------------------- #
# Whether they can be offered at all
# --------------------------------------------------------------------------- #

def test_enabled_without_a_domain_is_not_configured(settings):
    """A true `enabled` with no rp_id would offer a button that fails inside
    the browser, where nobody can see why."""
    settings.auth.webauthn.rp_id = None

    assert not passkeys.configured(settings)
    # Names where to fix it rather than which YAML key is missing: the setting
    # is editable on that page now, so the key is no longer the useful noun.
    assert "passkey domain" in passkeys.unavailable_reason(settings, secure=True)


def test_plain_http_is_named_as_the_reason(settings):
    assert passkeys.configured(settings)
    assert "HTTPS" in passkeys.unavailable_reason(settings, secure=False)
    assert passkeys.unavailable_reason(settings, secure=True) is None


def test_an_authenticator_that_did_not_verify_the_user_is_refused(
    session_factory, user, settings
):
    """This is what earns a passkey the right to sign in alone.

    Without user verification an assertion proves only that the device was
    present — one factor. The app would then be accepting a single factor from
    somebody holding a stolen phone, while asking a password user for two.
    `SoftWebauthnDevice` sets User Present only, so it stands in for exactly
    that authenticator.
    """
    with session_factory() as db:
        u = db.get(User, user.id)
        device = _enrol(db, u, settings)
        present_only = SoftWebauthnDevice()
        present_only.credential_id = device.credential_id
        present_only.private_key = device.private_key
        present_only.rp_id = device.rp_id
        present_only.user_handle = device.user_handle
        present_only.sign_count = device.sign_count
        credential, token = _assert(db, present_only, settings)

        with pytest.raises(passkeys.PasskeyError):
            passkeys.verify_authentication(db, credential, token, settings)


# --------------------------------------------------------------------------- #
# With more than one on file
# --------------------------------------------------------------------------- #
# Each test above holds one credential and one challenge, so a lookup that
# dropped its `where` found the right row anyway; a mutation run removed the
# credential match from sign-in, the challenge match from both ceremonies and
# the owner filter from the account page's list, and nothing failed.

def test_each_key_signs_in_as_its_own_account(session_factory, user, settings):
    with session_factory() as db:
        first = db.get(User, user.id)
        second = User(email="second@example.test", name="Second", password_hash="x")
        db.add(second)
        db.flush()
        first_device = _enrol(db, first, settings)
        second_device = _enrol(db, second, settings)

        credential, token = _assert(db, second_device, settings)
        assert passkeys.verify_authentication(db, credential, token, settings).id == second.id
        credential, token = _assert(db, first_device, settings)
        assert passkeys.verify_authentication(db, credential, token, settings).id == first.id


def test_two_ceremonies_in_flight_each_take_their_own_challenge(session_factory, user, settings):
    with session_factory() as db:
        u = db.get(User, user.id)
        device = _enrol(db, u, settings)
        first_options, first_token = passkeys.authentication_options(db, settings)
        second_options, second_token = passkeys.authentication_options(db, settings)

        assertion = device.get(_for_device(second_options), ORIGIN)
        signed_in = passkeys.verify_authentication(db, _as_json(assertion), second_token,
                                                   settings)
        assert signed_in.id == user.id
        assertion = device.get(_for_device(first_options), ORIGIN)
        assert passkeys.verify_authentication(db, _as_json(assertion), first_token,
                                              settings).id == user.id


def test_signing_in_records_the_counter_and_when(session_factory, user, settings):
    """The counter is what refuses an assertion from a cloned authenticator,
    so it has to be the one the last sign-in reported."""
    with session_factory() as db:
        u = db.get(User, user.id)
        device = _enrol(db, u, settings)
        credential, token = _assert(db, device, settings)
        passkeys.verify_authentication(db, credential, token, settings)
        row = db.scalar(select(WebauthnCredential))

        assert row.sign_count == device.sign_count > 0
        assert row.last_used_at is not None


def test_a_counter_gone_backwards_is_refused(session_factory, user, settings):
    """An authenticator that counts never reports less than last time; a copy
    of the key made before the last use does. Refused only if the count from
    that use was kept."""
    with session_factory() as db:
        u = db.get(User, user.id)
        device = _enrol(db, u, settings)
        credential, token = _assert(db, device, settings)
        passkeys.verify_authentication(db, credential, token, settings)
        device.sign_count -= 1                    # the copy, one use behind

        credential, token = _assert(db, device, settings)
        with pytest.raises(passkeys.PasskeyError):
            passkeys.verify_authentication(db, credential, token, settings)


def test_the_account_lists_only_its_own_keys(session_factory, user, settings):
    with session_factory() as db:
        first = db.get(User, user.id)
        second = User(email="second@example.test", name="Second", password_hash="x")
        db.add(second)
        db.flush()
        _enrol(db, first, settings)
        _enrol(db, second, settings)

        assert [k.user_id for k in passkeys.for_user(db, first)] == [first.id]


@pytest.mark.parametrize("typed, stored", [("  ", "Passkey"), ("", "Passkey"),
                                           ("  Phone  ", "Phone"), ("x" * 90, "x" * 80)])
def test_a_key_is_named_as_typed_or_called_passkey(session_factory, user, settings, typed,
                                                   stored):
    with session_factory() as db:
        u = db.get(User, user.id)
        device = VerifyingDevice()
        raw_options, token = passkeys.registration_options(db, u, settings)
        attestation = device.create(_for_device(raw_options), ORIGIN)
        row = passkeys.verify_registration(db, u, _as_json(attestation), token, settings,
                                           name=typed)
        assert row.name == stored