import os from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.hashes import SHA256 from fido2.cose import ES256 from fido2.utils import sha256 from fido2.webauthn import ( Aaguid, AttestationObject, AttestedCredentialData, AuthenticationResponse, AuthenticatorAssertionResponse, AuthenticatorAttestationResponse, AuthenticatorData, CollectedClientData, PublicKeyCredentialDescriptor, PublicKeyCredentialType, RegistrationResponse, ) from porchlight.authn.webauthn import WebAuthnService RP_ID = "localhost" RP_NAME = "Test RP" ORIGIN = "http://localhost:8000" def _make_service() -> WebAuthnService: return WebAuthnService(rp_id=RP_ID, rp_name=RP_NAME, origin=ORIGIN) def _generate_credential() -> tuple[ec.EllipticCurvePrivateKey, bytes, AttestedCredentialData]: """Generate a test credential: (private_key, credential_id, attested_credential_data).""" private_key = ec.generate_private_key(ec.SECP256R1()) cose_key = ES256.from_cryptography_key(private_key.public_key()) credential_id = os.urandom(32) attested = AttestedCredentialData.create( aaguid=Aaguid.NONE, credential_id=credential_id, public_key=cose_key, ) return private_key, credential_id, attested def _build_registration_response( credential_id: bytes, attested: AttestedCredentialData, challenge: bytes, ) -> RegistrationResponse: """Build a valid registration response for the given challenge.""" auth_data = AuthenticatorData.create( rp_id_hash=sha256(RP_ID.encode()), flags=AuthenticatorData.FLAG.UP | AuthenticatorData.FLAG.AT, counter=0, credential_data=attested, ) attestation_object = AttestationObject.create(fmt="none", auth_data=auth_data, att_stmt={}) client_data = CollectedClientData.create( type=CollectedClientData.TYPE.CREATE, challenge=challenge, origin=ORIGIN, ) return RegistrationResponse( raw_id=credential_id, response=AuthenticatorAttestationResponse( client_data=client_data, attestation_object=attestation_object, ), ) def _build_authentication_response( private_key: ec.EllipticCurvePrivateKey, credential_id: bytes, challenge: bytes, counter: int = 1, ) -> AuthenticationResponse: """Build a valid authentication response signed with the private key.""" client_data = CollectedClientData.create( type=CollectedClientData.TYPE.GET, challenge=challenge, origin=ORIGIN, ) auth_data = AuthenticatorData.create( rp_id_hash=sha256(RP_ID.encode()), flags=AuthenticatorData.FLAG.UP, counter=counter, ) signature = private_key.sign(auth_data + client_data.hash, ec.ECDSA(SHA256())) return AuthenticationResponse( raw_id=credential_id, response=AuthenticatorAssertionResponse( client_data=client_data, authenticator_data=auth_data, signature=signature, ), ) # --- Registration tests --- def test_begin_registration_returns_options_and_state() -> None: service = _make_service() options, state = service.begin_registration( user_id=b"user-123", username="alice", ) # Options should be a dict suitable for JSON serialization assert "publicKey" in options pub_key = options["publicKey"] assert "challenge" in pub_key assert "rp" in pub_key assert "user" in pub_key # State should be a dict with challenge assert "challenge" in state def test_complete_registration_returns_credential_data() -> None: service = _make_service() _private_key, credential_id, attested = _generate_credential() _options, state = service.begin_registration( user_id=b"user-123", username="alice", ) # Extract challenge from state to build a matching response challenge = state["challenge"] response = _build_registration_response(credential_id, attested, challenge) result = service.complete_registration(state, response) assert result.credential_data is not None assert result.credential_data.credential_id == credential_id def test_begin_registration_requires_resident_key() -> None: service = _make_service() options, _state = service.begin_registration(user_id=b"user-123", username="alice") pub_key = options["publicKey"] auth_sel = pub_key["authenticatorSelection"] assert auth_sel["residentKey"] == "required" assert auth_sel["requireResidentKey"] is True def test_begin_registration_prefers_user_verification() -> None: service = _make_service() options, _state = service.begin_registration(user_id=b"user-123", username="alice") pub_key = options["publicKey"] auth_sel = pub_key["authenticatorSelection"] assert auth_sel["userVerification"] == "preferred" def test_begin_registration_with_existing_credentials() -> None: service = _make_service() _, cred_id, _attested = _generate_credential() existing = [ PublicKeyCredentialDescriptor( type=PublicKeyCredentialType.PUBLIC_KEY, id=cred_id, ) ] options, _state = service.begin_registration( user_id=b"user-123", username="alice", existing_credentials=existing, ) pub_key = options["publicKey"] assert "excludeCredentials" in pub_key assert len(pub_key["excludeCredentials"]) == 1 # --- Authentication tests --- def test_begin_authentication_without_credentials() -> None: """Usernameless flow: no allowCredentials, browser shows passkey picker.""" service = _make_service() options, state = service.begin_authentication() assert "publicKey" in options assert "challenge" in state pub_key = options["publicKey"] # allowCredentials should be absent or empty allow = pub_key.get("allowCredentials", []) assert allow is None or len(allow) == 0 def test_begin_authentication_prefers_user_verification() -> None: service = _make_service() options, _state = service.begin_authentication() pub_key = options["publicKey"] assert pub_key["userVerification"] == "preferred" def test_begin_authentication_returns_options_and_state() -> None: service = _make_service() _, cred_id, _attested = _generate_credential() credentials = [ PublicKeyCredentialDescriptor( type=PublicKeyCredentialType.PUBLIC_KEY, id=cred_id, ) ] options, state = service.begin_authentication(credentials=credentials) assert "publicKey" in options assert "challenge" in state def test_complete_authentication_verifies_signature() -> None: service = _make_service() private_key, credential_id, attested = _generate_credential() credentials = [ PublicKeyCredentialDescriptor( type=PublicKeyCredentialType.PUBLIC_KEY, id=credential_id, ) ] _options, state = service.begin_authentication(credentials=credentials) challenge = state["challenge"] response = _build_authentication_response(private_key, credential_id, challenge) result = service.complete_authentication( state=state, credentials=[attested], response=response, ) assert result.credential_id == credential_id def test_complete_authentication_wrong_signature_raises() -> None: import pytest service = _make_service() _private_key, credential_id, attested = _generate_credential() # Generate a different key to produce a wrong signature wrong_key = ec.generate_private_key(ec.SECP256R1()) credentials = [ PublicKeyCredentialDescriptor( type=PublicKeyCredentialType.PUBLIC_KEY, id=credential_id, ) ] _options, state = service.begin_authentication(credentials=credentials) challenge = state["challenge"] response = _build_authentication_response(wrong_key, credential_id, challenge) with pytest.raises(ValueError, match="Invalid signature"): service.complete_authentication( state=state, credentials=[attested], response=response, )