"""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