From 4432d9e7c24055bada6d75c1b7787ab84f8c5143 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 10:49:55 -0800 Subject: [PATCH 01/47] Remove Pydantic as dependency --- requirements.txt | 6 +----- setup.py | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index fc26dcc..025e1f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ -annotated-types==0.5.0 asn1crypto==1.4.0 black==21.9b0 -cbor2==5.4.6 +cbor2==5.5.0 cffi==1.15.0 click==8.0.3 cryptography==41.0.4 @@ -12,12 +11,9 @@ pathspec==0.9.0 platformdirs==2.4.0 pycodestyle==2.8.0 pycparser==2.20 -pydantic==2.4.2 -pydantic_core==2.10.1 pyflakes==2.4.0 pyOpenSSL==23.2.0 regex==2021.10.8 six==1.16.0 toml==0.10.2 tomli==1.2.1 -typing_extensions==4.7.1 diff --git a/setup.py b/setup.py index 6e0b8b9..1baf7af 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,6 @@ def find_version(*file_paths): 'asn1crypto>=1.4.0', 'cbor2>=5.4.6', 'cryptography>=41.0.4', - 'pydantic>=1.10.11', 'pyOpenSSL>=23.2.0', ] ) From c6d5fa1575073a13807de1703081b090726d3094 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 10:50:11 -0800 Subject: [PATCH 02/47] Expand upon VS Code settings --- .vscode/settings.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 3b46fe2..ac79380 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,11 @@ { + "black-formatter.args": [ + "--line-length", "99" + ], "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter" + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnPaste": false, + "editor.formatOnSaveMode": "file", + "editor.formatOnSave": true } } From cf7a3e9319ece1f5d88cb38cf659acc88304e5f5 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 10:50:36 -0800 Subject: [PATCH 03/47] Refactor structs into dataclasses --- webauthn/helpers/structs.py | 184 ++++++++---------------------------- 1 file changed, 39 insertions(+), 145 deletions(-) diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index 5bada95..bc8ad2f 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -1,23 +1,8 @@ from enum import Enum +from dataclasses import dataclass, field from typing import Callable, List, Literal, Optional, Any, Dict -try: - from pydantic import ( # type: ignore[attr-defined] - BaseModel, - field_validator, - ConfigDict, - FieldValidationInfo, - model_serializer, - ) - - PYDANTIC_V2 = True -except ImportError: - from pydantic import BaseModel, validator - from pydantic.fields import ModelField # type: ignore[attr-defined] - - PYDANTIC_V2 = False - from .base64url_to_bytes import base64url_to_bytes from .bytes_to_base64url import bytes_to_base64url from .cose import COSEAlgorithmIdentifier @@ -25,115 +10,6 @@ from .snake_case_to_camel_case import snake_case_to_camel_case -def _to_bytes(v: Any) -> Any: - if isinstance(v, bytes): - """ - Return raw bytes from subclasses as well - - `strict_bytes_validator()` performs a similar check to this, but it passes through the - subclass as-is and Pydantic then rejects it. Passing the subclass into `bytes()` lets us - return `bytes` and make Pydantic happy. - """ - return bytes(v) - elif isinstance(v, memoryview): - return v.tobytes() - else: - # Allow Pydantic to validate the field as usual to support the full range of bytes-like - # values - return v - - -class WebAuthnBaseModel(BaseModel): - """ - A subclass of Pydantic's BaseModel that includes convenient defaults - when working with WebAuthn data structures - - `modelInstance.json()` (to JSON): - - Encodes bytes to Base64URL - - Converts snake_case properties to camelCase - - `Model.parse_raw()` (from JSON): - - Decodes Base64URL to bytes - - Converts camelCase properties to snake_case - """ - - if PYDANTIC_V2: - model_config = ConfigDict( # type: ignore[typeddict-unknown-key] - alias_generator=snake_case_to_camel_case, - populate_by_name=True, - ser_json_bytes="base64", - ) - - @field_validator("*", mode="before") - def _pydantic_v2_validate_bytes_fields( - cls, v: Any, info: FieldValidationInfo # type: ignore[valid-type] - ) -> Any: - """ - `FieldValidationInfo` above is being deprecated for `ValidationInfo`, see the following: - - - https://github.com/pydantic/pydantic-core/issues/994 - - https://github.com/pydantic/pydantic/issues/7667 - - There are now docs for the new way to access `field_name` that's only available in - Pydantic v2.4+... - - https://docs.pydantic.dev/latest/concepts/types/#access-to-field-name - - This use of `FieldValidationInfo` will continue to work for now, but when it gets - removed from Pydantic the `info.field_name` below will need to get updated to - `info.data.field_name` after changing the type of `info` above to `ValidationInfo` - """ - field = cls.model_fields[info.field_name] # type: ignore[attr-defined] - - if field.annotation != bytes: - return v - - if isinstance(v, str): - # NOTE: - # Ideally we should only do this when info.mode == "json", but - # that does not work when using the deprecated parse_raw method - return base64url_to_bytes(v) - - return _to_bytes(v) - - @model_serializer(mode="wrap", when_used="json") - def _pydantic_v2_serialize_bytes_fields( - self, serializer: Callable[..., Dict[str, Any]] - ) -> Dict[str, Any]: - """ - Remove trailing "=" from bytes fields serialized as base64 encoded strings. - """ - - serialized = serializer(self) - - for name, field_info in self.model_fields.items(): # type: ignore[attr-defined] - value = serialized.get(name) - if field_info.annotation is bytes and isinstance(value, str): - serialized[name] = value.rstrip("=") - - return serialized - - else: - - class Config: - json_encoders = {bytes: bytes_to_base64url} - json_loads = json_loads_base64url_to_bytes - alias_generator = snake_case_to_camel_case - allow_population_by_field_name = True - - @validator("*", pre=True, allow_reuse=True) # type: ignore[type-var] - def _pydantic_v1_validate_bytes_fields(cls, v: Any, field: ModelField) -> Any: - """ - Allow for Pydantic models to define fields as `bytes`, but allow consuming projects to - specify bytes-adjacent values (bytes subclasses, memoryviews, etc...) that otherwise - function like `bytes`. Keeps the library Pythonic. - """ - if field.type_ != bytes: - return v - - return _to_bytes(v) - - ################ # # Fundamental data structures @@ -286,7 +162,8 @@ class TokenBindingStatus(str, Enum): SUPPORTED = "supported" -class TokenBinding(WebAuthnBaseModel): +@dataclass +class TokenBinding(): """ https://www.w3.org/TR/webauthn-2/#dictdef-tokenbinding """ @@ -295,7 +172,8 @@ class TokenBinding(WebAuthnBaseModel): id: Optional[str] = None -class PublicKeyCredentialRpEntity(WebAuthnBaseModel): +@dataclass +class PublicKeyCredentialRpEntity(): """Information about the Relying Party. Attributes: @@ -309,7 +187,8 @@ class PublicKeyCredentialRpEntity(WebAuthnBaseModel): id: Optional[str] = None -class PublicKeyCredentialUserEntity(WebAuthnBaseModel): +@dataclass +class PublicKeyCredentialUserEntity(): """Information about a user of a Relying Party. Attributes: @@ -325,7 +204,8 @@ class PublicKeyCredentialUserEntity(WebAuthnBaseModel): display_name: str -class PublicKeyCredentialParameters(WebAuthnBaseModel): +@dataclass +class PublicKeyCredentialParameters(): """Information about a cryptographic algorithm that may be used when creating a credential. Attributes: @@ -339,7 +219,8 @@ class PublicKeyCredentialParameters(WebAuthnBaseModel): alg: COSEAlgorithmIdentifier -class PublicKeyCredentialDescriptor(WebAuthnBaseModel): +@dataclass +class PublicKeyCredentialDescriptor(): """Information about a generated credential. Attributes: @@ -357,7 +238,8 @@ class PublicKeyCredentialDescriptor(WebAuthnBaseModel): transports: Optional[List[AuthenticatorTransport]] = None -class AuthenticatorSelectionCriteria(WebAuthnBaseModel): +@dataclass +class AuthenticatorSelectionCriteria(): """A Relying Party's requirements for the types of authenticators that may interact with the client/browser. Attributes: @@ -377,7 +259,8 @@ class AuthenticatorSelectionCriteria(WebAuthnBaseModel): ] = UserVerificationRequirement.PREFERRED -class CollectedClientData(WebAuthnBaseModel): +@dataclass +class CollectedClientData(): """Decoded ClientDataJSON Attributes: @@ -404,7 +287,8 @@ class CollectedClientData(WebAuthnBaseModel): ################ -class PublicKeyCredentialCreationOptions(WebAuthnBaseModel): +@dataclass +class PublicKeyCredentialCreationOptions(): """Registration Options. Attributes: @@ -430,7 +314,8 @@ class PublicKeyCredentialCreationOptions(WebAuthnBaseModel): attestation: AttestationConveyancePreference = AttestationConveyancePreference.NONE -class AuthenticatorAttestationResponse(WebAuthnBaseModel): +@dataclass +class AuthenticatorAttestationResponse(): """The `response` property on a registration credential. Attributes: @@ -447,7 +332,8 @@ class AuthenticatorAttestationResponse(WebAuthnBaseModel): transports: Optional[List[AuthenticatorTransport]] = None -class RegistrationCredential(WebAuthnBaseModel): +@dataclass +class RegistrationCredential(): """A registration-specific subclass of PublicKeyCredential returned from `navigator.credentials.create()` Attributes: @@ -468,7 +354,8 @@ class RegistrationCredential(WebAuthnBaseModel): ] = PublicKeyCredentialType.PUBLIC_KEY -class AttestationStatement(WebAuthnBaseModel): +@dataclass +class AttestationStatement(): """A collection of all possible fields that may exist in an attestation statement. Combinations of these fields are specific to a particular attestation format. https://www.w3.org/TR/webauthn-2/#sctn-defined-attestation-formats @@ -487,7 +374,8 @@ class AttestationStatement(WebAuthnBaseModel): pub_area: Optional[bytes] = None -class AuthenticatorDataFlags(WebAuthnBaseModel): +@dataclass +class AuthenticatorDataFlags(): """Flags the authenticator will set about information contained within the `attestationObject.authData` property. Attributes: @@ -509,7 +397,8 @@ class AuthenticatorDataFlags(WebAuthnBaseModel): ed: bool -class AttestedCredentialData(WebAuthnBaseModel): +@dataclass +class AttestedCredentialData(): """Information about a credential. Attributes: @@ -525,7 +414,8 @@ class AttestedCredentialData(WebAuthnBaseModel): credential_public_key: bytes -class AuthenticatorData(WebAuthnBaseModel): +@dataclass +class AuthenticatorData(): """Context the authenticator provides about itself and the environment in which the registration or authentication ceremony took place. Attributes: @@ -546,7 +436,8 @@ class AuthenticatorData(WebAuthnBaseModel): extensions: Optional[bytes] = None -class AttestationObject(WebAuthnBaseModel): +@dataclass +class AttestationObject(): """Information about an attestation, including a statement and authenticator data. Attributes: @@ -559,7 +450,7 @@ class AttestationObject(WebAuthnBaseModel): fmt: AttestationFormat auth_data: AuthenticatorData - att_stmt: AttestationStatement = AttestationStatement() + att_stmt: AttestationStatement = field(default_factory=AttestationStatement) ################ @@ -569,7 +460,8 @@ class AttestationObject(WebAuthnBaseModel): ################ -class PublicKeyCredentialRequestOptions(WebAuthnBaseModel): +@dataclass +class PublicKeyCredentialRequestOptions(): """Authentication Options. Attributes: @@ -585,13 +477,14 @@ class PublicKeyCredentialRequestOptions(WebAuthnBaseModel): challenge: bytes timeout: Optional[int] = None rp_id: Optional[str] = None - allow_credentials: Optional[List[PublicKeyCredentialDescriptor]] = [] + allow_credentials: Optional[List[PublicKeyCredentialDescriptor]] = field(default_factory=[]) user_verification: Optional[ UserVerificationRequirement ] = UserVerificationRequirement.PREFERRED -class AuthenticatorAssertionResponse(WebAuthnBaseModel): +@dataclass +class AuthenticatorAssertionResponse(): """The `response` property on an authentication credential. Attributes: @@ -609,7 +502,8 @@ class AuthenticatorAssertionResponse(WebAuthnBaseModel): user_handle: Optional[bytes] = None -class AuthenticationCredential(WebAuthnBaseModel): +@dataclass +class AuthenticationCredential(): """An authentication-specific subclass of PublicKeyCredential. Returned from `navigator.credentials.get()` Attributes: From cbed385e3f1a68d3c8a53d3a65fbbbb72275e4ad Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 10:51:26 -0800 Subject: [PATCH 04/47] Remove references to Pydantic in code --- .../verify_authentication_response.py | 13 +++++------ .../helpers/decode_credential_public_key.py | 11 ++++++---- webauthn/helpers/options_to_json.py | 17 ++------------ .../parse_authentication_credential_json.py | 13 ++++------- webauthn/helpers/parse_backup_flags.py | 5 +++-- webauthn/helpers/parse_client_data_json.py | 4 +--- .../parse_registration_credential_json.py | 12 +++------- .../registration/formats/android_safetynet.py | 22 +++++++++++-------- .../verify_registration_response.py | 16 ++++++++------ 9 files changed, 47 insertions(+), 66 deletions(-) diff --git a/webauthn/authentication/verify_authentication_response.py b/webauthn/authentication/verify_authentication_response.py index 9298f71..ec510c3 100644 --- a/webauthn/authentication/verify_authentication_response.py +++ b/webauthn/authentication/verify_authentication_response.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass import hashlib from typing import List, Union @@ -20,11 +21,11 @@ CredentialDeviceType, PublicKeyCredentialType, TokenBindingStatus, - WebAuthnBaseModel, ) -class VerifiedAuthentication(WebAuthnBaseModel): +@dataclass +class VerifiedAuthentication: """ Information about a verified authentication of which an RP can make use """ @@ -98,9 +99,7 @@ def verify_authentication_response( ) if expected_challenge != client_data.challenge: - raise InvalidAuthenticationResponse( - "Client data challenge was not expected challenge" - ) + raise InvalidAuthenticationResponse("Client data challenge was not expected challenge") if isinstance(expected_origin, str): if expected_origin != client_data.origin: @@ -133,9 +132,7 @@ def verify_authentication_response( raise InvalidAuthenticationResponse("Unexpected RP ID hash") if not auth_data.flags.up: - raise InvalidAuthenticationResponse( - "User was not present during authentication" - ) + raise InvalidAuthenticationResponse("User was not present during authentication") if require_user_verification and not auth_data.flags.uv: raise InvalidAuthenticationResponse( diff --git a/webauthn/helpers/decode_credential_public_key.py b/webauthn/helpers/decode_credential_public_key.py index e94852d..e1708c4 100644 --- a/webauthn/helpers/decode_credential_public_key.py +++ b/webauthn/helpers/decode_credential_public_key.py @@ -1,20 +1,22 @@ from typing import Union +from dataclasses import dataclass import cbor2 -from pydantic import BaseModel from .cose import COSECRV, COSEKTY, COSEAlgorithmIdentifier, COSEKey from .exceptions import InvalidPublicKeyStructure, UnsupportedPublicKeyType -class DecodedOKPPublicKey(BaseModel): +@dataclass +class DecodedOKPPublicKey(): kty: COSEKTY alg: COSEAlgorithmIdentifier crv: COSECRV x: bytes -class DecodedEC2PublicKey(BaseModel): +@dataclass +class DecodedEC2PublicKey(): kty: COSEKTY alg: COSEAlgorithmIdentifier crv: COSECRV @@ -22,7 +24,8 @@ class DecodedEC2PublicKey(BaseModel): y: bytes -class DecodedRSAPublicKey(BaseModel): +@dataclass +class DecodedRSAPublicKey(): kty: COSEKTY alg: COSEAlgorithmIdentifier n: bytes diff --git a/webauthn/helpers/options_to_json.py b/webauthn/helpers/options_to_json.py index 658b073..7614b81 100644 --- a/webauthn/helpers/options_to_json.py +++ b/webauthn/helpers/options_to_json.py @@ -1,7 +1,6 @@ from typing import Union from .structs import ( - PYDANTIC_V2, PublicKeyCredentialCreationOptions, PublicKeyCredentialRequestOptions, ) @@ -16,18 +15,6 @@ def options_to_json( """ Prepare options for transmission to the front end as JSON """ - if PYDANTIC_V2: - json_options = options.model_dump_json( # type: ignore[union-attr] - by_alias=True, - exclude_unset=False, - exclude_none=True, - ) + # TODO: Write this - else: - json_options = options.json( - by_alias=True, - exclude_unset=False, - exclude_none=True, - ) - - return json_options + return {} diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index 1aad49a..62ec826 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -1,9 +1,8 @@ import json from typing import Callable, Union -from pydantic import ValidationError from .exceptions import InvalidAuthenticationResponse -from .structs import PYDANTIC_V2, AuthenticationCredential +from .structs import AuthenticationCredential def parse_authentication_credential_json(json_val: Union[str, dict]) -> AuthenticationCredential: @@ -11,17 +10,13 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti Parse a JSON form of an authentication credential, as either a stringified JSON object or a plain dict, into an instance of AuthenticationCredential """ - if PYDANTIC_V2: - parsing_method: Callable = AuthenticationCredential.model_validate_json # type: ignore[attr-defined] - else: # assuming V1 - parsing_method = AuthenticationCredential.parse_raw - if isinstance(json_val, dict): json_val = json.dumps(json_val) try: - authentication_credential = parsing_method(json_val) - except ValidationError as exc: + # TODO: Write this + authentication_credential = AuthenticationCredential() + except Exception as exc: raise InvalidAuthenticationResponse( "Unable to parse an authentication credential from JSON data" ) from exc diff --git a/webauthn/helpers/parse_backup_flags.py b/webauthn/helpers/parse_backup_flags.py index 9ca0bf7..b6dd115 100644 --- a/webauthn/helpers/parse_backup_flags.py +++ b/webauthn/helpers/parse_backup_flags.py @@ -1,11 +1,12 @@ from enum import Enum -from pydantic import BaseModel +from dataclasses import dataclass from .structs import AuthenticatorDataFlags, CredentialDeviceType from .exceptions import InvalidBackupFlags -class ParsedBackupFlags(BaseModel): +@dataclass +class ParsedBackupFlags(): credential_device_type: CredentialDeviceType credential_backed_up: bool diff --git a/webauthn/helpers/parse_client_data_json.py b/webauthn/helpers/parse_client_data_json.py index 152b517..212d638 100644 --- a/webauthn/helpers/parse_client_data_json.py +++ b/webauthn/helpers/parse_client_data_json.py @@ -1,8 +1,6 @@ import json from json.decoder import JSONDecodeError -from pydantic import ValidationError - from .base64url_to_bytes import base64url_to_bytes from .exceptions import InvalidClientDataJSONStructure from .structs import CollectedClientData, TokenBinding @@ -65,7 +63,7 @@ def parse_client_data_json(val: bytes) -> CollectedClientData: token_binding.id = f"{id}" client_data.token_binding = token_binding - except ValidationError: + except Exception: # If we encounter a status we don't expect then ignore token_binding # completely pass diff --git a/webauthn/helpers/parse_registration_credential_json.py b/webauthn/helpers/parse_registration_credential_json.py index 4e7b9f6..dd65ec1 100644 --- a/webauthn/helpers/parse_registration_credential_json.py +++ b/webauthn/helpers/parse_registration_credential_json.py @@ -1,9 +1,8 @@ import json from typing import Callable, Union -from pydantic import ValidationError from .exceptions import InvalidRegistrationResponse -from .structs import PYDANTIC_V2, RegistrationCredential +from .structs import RegistrationCredential def parse_registration_credential_json(json_val: Union[str, dict]) -> RegistrationCredential: @@ -11,17 +10,12 @@ def parse_registration_credential_json(json_val: Union[str, dict]) -> Registrati Parse a JSON form of a registration credential, as either a stringified JSON object or a plain dict, into an instance of RegistrationCredential """ - if PYDANTIC_V2: - parsing_method: Callable = RegistrationCredential.model_validate_json # type: ignore[attr-defined] - else: # assuming V1 - parsing_method = RegistrationCredential.parse_raw - if isinstance(json_val, dict): json_val = json.dumps(json_val) try: - registration_credential = parsing_method(json_val) - except ValidationError as exc: + registration_credential = RegistrationCredential() + except Exception as exc: raise InvalidRegistrationResponse( "Unable to parse a registration credential from JSON data" ) from exc diff --git a/webauthn/registration/formats/android_safetynet.py b/webauthn/registration/formats/android_safetynet.py index 8da9f47..105bae1 100644 --- a/webauthn/registration/formats/android_safetynet.py +++ b/webauthn/registration/formats/android_safetynet.py @@ -1,4 +1,5 @@ import base64 +from dataclasses import dataclass import hashlib from typing import List @@ -20,17 +21,19 @@ InvalidRegistrationResponse, ) from webauthn.helpers.known_root_certs import globalsign_r2, globalsign_root_ca -from webauthn.helpers.structs import PYDANTIC_V2, AttestationStatement, WebAuthnBaseModel +from webauthn.helpers.structs import AttestationStatement -class SafetyNetJWSHeader(WebAuthnBaseModel): +@dataclass +class SafetyNetJWSHeader(): """Properties in the Header of a SafetyNet JWS""" alg: str x5c: List[str] -class SafetyNetJWSPayload(WebAuthnBaseModel): +@dataclass +class SafetyNetJWSPayload(): """Properties in the Payload of a SafetyNet JWS Values below correspond to camelCased properties in the JWS itself. This class @@ -87,12 +90,13 @@ def verify_android_safetynet( "Response JWS did not have three parts (SafetyNet)" ) - if PYDANTIC_V2: - header = SafetyNetJWSHeader.model_validate_json(base64url_to_bytes(jws_parts[0])) # type: ignore[attr-defined] - payload = SafetyNetJWSPayload.model_validate_json(base64url_to_bytes(jws_parts[1])) # type: ignore[attr-defined] - else: - header = SafetyNetJWSHeader.parse_raw(base64url_to_bytes(jws_parts[0])) - payload = SafetyNetJWSPayload.parse_raw(base64url_to_bytes(jws_parts[1])) + # TODO: Rewrite this + # if PYDANTIC_V2: + # header = SafetyNetJWSHeader.model_validate_json(base64url_to_bytes(jws_parts[0])) # type: ignore[attr-defined] + # payload = SafetyNetJWSPayload.model_validate_json(base64url_to_bytes(jws_parts[1])) # type: ignore[attr-defined] + # else: + # header = SafetyNetJWSHeader.parse_raw(base64url_to_bytes(jws_parts[0])) + # payload = SafetyNetJWSPayload.parse_raw(base64url_to_bytes(jws_parts[1])) signature_bytes_str: str = jws_parts[2] diff --git a/webauthn/registration/verify_registration_response.py b/webauthn/registration/verify_registration_response.py index 0aab6d9..d1711ad 100644 --- a/webauthn/registration/verify_registration_response.py +++ b/webauthn/registration/verify_registration_response.py @@ -1,4 +1,5 @@ import hashlib +from dataclasses import dataclass from typing import List, Mapping, Optional, Union from webauthn.helpers import ( @@ -13,14 +14,12 @@ from webauthn.helpers.cose import COSEAlgorithmIdentifier from webauthn.helpers.exceptions import InvalidRegistrationResponse from webauthn.helpers.structs import ( - PYDANTIC_V2, AttestationFormat, ClientDataType, CredentialDeviceType, PublicKeyCredentialType, RegistrationCredential, TokenBindingStatus, - WebAuthnBaseModel, ) from .formats.android_key import verify_android_key from .formats.android_safetynet import verify_android_safetynet @@ -31,7 +30,8 @@ from .generate_registration_options import default_supported_pub_key_algs -class VerifiedRegistration(WebAuthnBaseModel): +@dataclass +class VerifiedRegistration(): """Information about a verified attestation of which an RP can make use. Attributes: @@ -210,10 +210,12 @@ def verify_registration_response( if attestation_object.fmt == AttestationFormat.NONE: # A "none" attestation should not contain _anything_ in its attestation # statement - if PYDANTIC_V2: - num_att_stmt_fields_set = len(attestation_object.att_stmt.model_fields_set) # type: ignore[attr-defined] - else: - num_att_stmt_fields_set = len(attestation_object.att_stmt.__fields_set__) + # TODO: Rewrite this + # if PYDANTIC_V2: + # num_att_stmt_fields_set = len(attestation_object.att_stmt.model_fields_set) # type: ignore[attr-defined] + # else: + # num_att_stmt_fields_set = len(attestation_object.att_stmt.__fields_set__) + num_att_stmt_fields_set = 0 if num_att_stmt_fields_set > 0: raise InvalidRegistrationResponse( From b134c58a394353e02c4f40808bf104f51578e7df Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 10:56:27 -0800 Subject: [PATCH 05/47] Run Black over everything --- tests/test_bytes_subclass_support.py | 4 +- tests/test_decode_credential_public_key.py | 12 ++--- tests/test_generate_registration_options.py | 8 +--- tests/test_parse_authenticator_data.py | 18 +++---- tests/test_parse_backup_flags.py | 8 ++-- tests/test_tpm_parse_cert_info.py | 5 +- tests/test_validate_certificate_chain.py | 4 +- tests/test_verify_authentication_response.py | 12 +++-- tests/test_verify_registration_response.py | 20 ++++---- ...test_verify_registration_response_apple.py | 8 +--- .../helpers/decode_credential_public_key.py | 6 +-- .../decoded_public_key_to_cryptography.py | 9 +--- .../helpers/json_loads_base64url_to_bytes.py | 8 +--- webauthn/helpers/parse_authenticator_data.py | 2 +- webauthn/helpers/parse_backup_flags.py | 2 +- webauthn/helpers/parse_cbor.py | 4 +- webauthn/helpers/parse_client_data_json.py | 12 ++--- webauthn/helpers/structs.py | 48 ++++++++----------- .../helpers/validate_certificate_chain.py | 7 +-- .../helpers/verify_safetynet_timestamp.py | 4 +- webauthn/helpers/verify_signature.py | 8 +--- webauthn/registration/formats/android_key.py | 8 +--- .../registration/formats/android_safetynet.py | 28 ++++------- webauthn/registration/formats/apple.py | 12 ++--- webauthn/registration/formats/fido_u2f.py | 16 ++----- webauthn/registration/formats/packed.py | 8 +--- webauthn/registration/formats/tpm.py | 20 ++------ .../generate_registration_options.py | 5 +- .../verify_registration_response.py | 26 +++------- 29 files changed, 113 insertions(+), 219 deletions(-) diff --git a/tests/test_bytes_subclass_support.py b/tests/test_bytes_subclass_support.py index 1e0b4e3..0acf267 100644 --- a/tests/test_bytes_subclass_support.py +++ b/tests/test_bytes_subclass_support.py @@ -99,7 +99,7 @@ def test_supports_strings_for_bytes(self) -> None: authenticator_data=bytes(), client_data_json=bytes(), signature=bytes(), - user_handle='some_user_handle_string' # type: ignore + user_handle="some_user_handle_string", # type: ignore ) - self.assertEqual(response.user_handle, b'some_user_handle_string') + self.assertEqual(response.user_handle, b"some_user_handle_string") diff --git a/tests/test_decode_credential_public_key.py b/tests/test_decode_credential_public_key.py index fcc2184..334d268 100644 --- a/tests/test_decode_credential_public_key.py +++ b/tests/test_decode_credential_public_key.py @@ -23,13 +23,11 @@ def test_decodes_ec2_public_key(self) -> None: assert decoded.crv == 1 assert ( decoded.x - and bytes_to_base64url(decoded.x) - == "MMcEPFOpY_jJlmcBrnbgvq4-7CGKt5TBEPmxdjpTaDE" + and bytes_to_base64url(decoded.x) == "MMcEPFOpY_jJlmcBrnbgvq4-7CGKt5TBEPmxdjpTaDE" ) assert ( decoded.y - and bytes_to_base64url(decoded.y) - == "xuwbECbDdNfTTegnc174oYdusZiMmJgct0yI_ulrJGI" + and bytes_to_base64url(decoded.y) == "xuwbECbDdNfTTegnc174oYdusZiMmJgct0yI_ulrJGI" ) def test_decode_rsa_public_key(self) -> None: @@ -62,11 +60,9 @@ def test_decode_uncompressed_ec2_public_key(self) -> None: assert decoded.crv == 1 assert ( decoded.x - and bytes_to_base64url(decoded.x) - == "FrEpm55XKvkgIN-izKDHBF-VJ09Rw2F5mFOFcJ5MVM0" + and bytes_to_base64url(decoded.x) == "FrEpm55XKvkgIN-izKDHBF-VJ09Rw2F5mFOFcJ5MVM0" ) assert ( decoded.y - and bytes_to_base64url(decoded.y) - == "o0EM9dj0V-xJ1JwpE2XZ_8NRIt5KVvr71Zl0rB8BWOs" + and bytes_to_base64url(decoded.y) == "o0EM9dj0V-xJ1JwpE2XZ_8NRIt5KVvr71Zl0rB8BWOs" ) diff --git a/tests/test_generate_registration_options.py b/tests/test_generate_registration_options.py index 2969df4..9f31c07 100644 --- a/tests/test_generate_registration_options.py +++ b/tests/test_generate_registration_options.py @@ -66,9 +66,7 @@ def test_generates_options_with_custom_values(self) -> None: timeout=120000, ) - assert options.rp == PublicKeyCredentialRpEntity( - id="example.com", name="Example Co" - ) + assert options.rp == PublicKeyCredentialRpEntity(id="example.com", name="Example Co") assert options.challenge == b"1234567890" assert options.user == PublicKeyCredentialUserEntity( id=b"ABAV6QWPBEY9WOTOA1A4", @@ -80,9 +78,7 @@ def test_generates_options_with_custom_values(self) -> None: alg=COSEAlgorithmIdentifier.ECDSA_SHA_512, ) assert options.timeout == 120000 - assert options.exclude_credentials == [ - PublicKeyCredentialDescriptor(id=b"1234567890") - ] + assert options.exclude_credentials == [PublicKeyCredentialDescriptor(id=b"1234567890")] assert options.authenticator_selection == AuthenticatorSelectionCriteria( authenticator_attachment=AuthenticatorAttachment.PLATFORM, resident_key=ResidentKeyRequirement.REQUIRED, diff --git a/tests/test_parse_authenticator_data.py b/tests/test_parse_authenticator_data.py index f4e3d0d..8f7f230 100644 --- a/tests/test_parse_authenticator_data.py +++ b/tests/test_parse_authenticator_data.py @@ -89,9 +89,7 @@ def _generate_auth_data( class TestWebAuthnParseAuthenticatorData(TestCase): def test_correctly_parses_simple(self) -> None: - (auth_data, rp_id_hash, sign_count, _, _, _) = _generate_auth_data( - 10, up=True, uv=True - ) + (auth_data, rp_id_hash, sign_count, _, _, _) = _generate_auth_data(10, up=True, uv=True) output = parse_authenticator_data(auth_data) @@ -138,7 +136,9 @@ def test_parses_uv_false(self) -> None: self.assertFalse(output.flags.uv) def test_parses_attested_credential_data_and_extension_data(self) -> None: - auth_data = bytes.fromhex("50569158be61d7a1ba084f80e45e938fd326e0a8dff07b37036e6c82303ae26bc1000004377b3024675546afcb92e4495c8a1e193f00dca30058b8d74f6bd74de90baeb34afb51e3578e1ac4ca9f79a7f88473d8254d5762ca82d68f3bf63f49e9b284caab4d45d6f9bb468d0c1b7f0f727378c1db8adb4802cb7c5ad9c5eb905bf0ba03f79bd1f04d63765452d49c4087acfad340516dc892eafd87d498ae9e6fd6f06a3f423108ebdc032d93e82fdd6deacc1b638fd56838a482f01232ad01e266e016a50b8121816997a167f41139900fe46094b8ef30aad14ee08cc457366a033bb4a0554dcf9c9589f9622d4f84481541014c870291c87d7a3bbe3d8b07eb02509de5721e3f728aa5eac41e9c5af02869a4010103272006215820e613b86a8d4ebae24e84a0270b6773f7bb30d1d59f5ec379910ebe7c87714274a16b6372656450726f7465637401") + auth_data = bytes.fromhex( + "50569158be61d7a1ba084f80e45e938fd326e0a8dff07b37036e6c82303ae26bc1000004377b3024675546afcb92e4495c8a1e193f00dca30058b8d74f6bd74de90baeb34afb51e3578e1ac4ca9f79a7f88473d8254d5762ca82d68f3bf63f49e9b284caab4d45d6f9bb468d0c1b7f0f727378c1db8adb4802cb7c5ad9c5eb905bf0ba03f79bd1f04d63765452d49c4087acfad340516dc892eafd87d498ae9e6fd6f06a3f423108ebdc032d93e82fdd6deacc1b638fd56838a482f01232ad01e266e016a50b8121816997a167f41139900fe46094b8ef30aad14ee08cc457366a033bb4a0554dcf9c9589f9622d4f84481541014c870291c87d7a3bbe3d8b07eb02509de5721e3f728aa5eac41e9c5af02869a4010103272006215820e613b86a8d4ebae24e84a0270b6773f7bb30d1d59f5ec379910ebe7c87714274a16b6372656450726f7465637401" + ) output = parse_authenticator_data(auth_data) cred_data = output.attested_credential_data @@ -146,7 +146,7 @@ def test_parses_attested_credential_data_and_extension_data(self) -> None: assert cred_data # Make mypy happy self.assertEqual( bytes_to_base64url(cred_data.credential_public_key), - "pAEBAycgBiFYIOYTuGqNTrriToSgJwtnc_e7MNHVn17DeZEOvnyHcUJ0" + "pAEBAycgBiFYIOYTuGqNTrriToSgJwtnc_e7MNHVn17DeZEOvnyHcUJ0", ) extensions = output.extensions @@ -154,7 +154,7 @@ def test_parses_attested_credential_data_and_extension_data(self) -> None: assert extensions # Make mypy happy parsed_extensions = cbor2.loads(extensions) - self.assertEqual(parsed_extensions, {'credProtect': 1}) + self.assertEqual(parsed_extensions, {"credProtect": 1}) def test_parses_only_extension_data(self) -> None: # Pulled from Conformance Testing suite @@ -171,8 +171,8 @@ def test_parses_only_extension_data(self) -> None: self.assertEqual( parsed_extensions, { - 'example.extension': 'This is an example extension! If you read this message, you probably successfully passing conformance tests. Good job!', - } + "example.extension": "This is an example extension! If you read this message, you probably successfully passing conformance tests. Good job!", + }, ) def test_parses_backup_state_flags(self) -> None: @@ -202,7 +202,7 @@ def test_parses_bad_eddsa_auth_data(self) -> None: self.assertEqual( cred_data.credential_id.hex(), - "e82fe6bde300e4ecc93e0016448ad00fa6f28a011a6f87ff7b0cfca499beaf83344c3660b5ecabf72a3b2838a0cc7d87d3fa58292b53449cff13ad69732d7521649d365ccbc5d0a0fa4b4e09eae99537261f2f44093f8f4fd4cf5796e0fe58ff0615ffc5882836bbe7b99b08be2986721c1c5a6ac7f32d3220d9b34d8dee2fc9" + "e82fe6bde300e4ecc93e0016448ad00fa6f28a011a6f87ff7b0cfca499beaf83344c3660b5ecabf72a3b2838a0cc7d87d3fa58292b53449cff13ad69732d7521649d365ccbc5d0a0fa4b4e09eae99537261f2f44093f8f4fd4cf5796e0fe58ff0615ffc5882836bbe7b99b08be2986721c1c5a6ac7f32d3220d9b34d8dee2fc9", ) self.assertEqual( cred_data.credential_public_key.hex(), diff --git a/tests/test_parse_backup_flags.py b/tests/test_parse_backup_flags.py index 7e23f93..fafabac 100644 --- a/tests/test_parse_backup_flags.py +++ b/tests/test_parse_backup_flags.py @@ -24,7 +24,7 @@ def test_returns_single_device_not_backed_up(self) -> None: parsed = parse_backup_flags(self.flags) - self.assertEqual(parsed.credential_device_type, 'single_device') + self.assertEqual(parsed.credential_device_type, "single_device") self.assertEqual(parsed.credential_backed_up, False) def test_returns_multi_device_not_backed_up(self) -> None: @@ -33,7 +33,7 @@ def test_returns_multi_device_not_backed_up(self) -> None: parsed = parse_backup_flags(self.flags) - self.assertEqual(parsed.credential_device_type, 'multi_device') + self.assertEqual(parsed.credential_device_type, "multi_device") self.assertEqual(parsed.credential_backed_up, False) def test_returns_multi_device_backed_up(self) -> None: @@ -42,7 +42,7 @@ def test_returns_multi_device_backed_up(self) -> None: parsed = parse_backup_flags(self.flags) - self.assertEqual(parsed.credential_device_type, 'multi_device') + self.assertEqual(parsed.credential_device_type, "multi_device") self.assertEqual(parsed.credential_backed_up, True) def test_raises_on_invalid_backup_state_flags(self) -> None: @@ -53,4 +53,4 @@ def test_raises_on_invalid_backup_state_flags(self) -> None: InvalidBackupFlags, "impossible", ): - parse_backup_flags(self.flags) + parse_backup_flags(self.flags) diff --git a/tests/test_tpm_parse_cert_info.py b/tests/test_tpm_parse_cert_info.py index 5c68fcc..d68f894 100644 --- a/tests/test_tpm_parse_cert_info.py +++ b/tests/test_tpm_parse_cert_info.py @@ -16,10 +16,7 @@ def test_properly_parses_cert_info_bytes(self) -> None: output.qualified_signer == b'\x00\x0bW"f{J5_9"\x15\tL\x01\xd5e\xbcr\xc6\xc9\x03\xbc#\xb5m\xee\xb5yI+j\xe6\xce' ) - assert ( - output.extra_data - == b"`\x0bD(A\x99\xf3\xd3\x12I[\x04\x1f\xf4\xe7\xfb)\xc8\x02\x8f" - ) + assert output.extra_data == b"`\x0bD(A\x99\xf3\xd3\x12I[\x04\x1f\xf4\xe7\xfb)\xc8\x02\x8f" assert output.firmware_version == b"\x97g1K\xfaf`T" # Attested assert output.attested.name_alg == TPM_ALG.SHA256 diff --git a/tests/test_validate_certificate_chain.py b/tests/test_validate_certificate_chain.py index 902c233..cae44fb 100644 --- a/tests/test_validate_certificate_chain.py +++ b/tests/test_validate_certificate_chain.py @@ -27,9 +27,7 @@ class TestValidateCertificateChain(TestCase): # TODO: Revisit these tests when we figure out how to generate dynamic certs that # won't start failing tests 72 hours after creation... @patch("OpenSSL.crypto.X509StoreContext.verify_certificate") - def test_validates_certificate_chain( - self, mock_verify_certificate: MagicMock - ) -> None: + def test_validates_certificate_chain(self, mock_verify_certificate: MagicMock) -> None: # Mocked because these certs actually expired and started failing this test mock_verify_certificate.return_value = True diff --git a/tests/test_verify_authentication_response.py b/tests/test_verify_authentication_response.py index acdbc1a..4e3e5a7 100644 --- a/tests/test_verify_authentication_response.py +++ b/tests/test_verify_authentication_response.py @@ -42,7 +42,7 @@ def test_verify_authentication_response_with_EC2_public_key(self): ) assert verification.new_sign_count == 78 assert verification.credential_backed_up == False - assert verification.credential_device_type == 'single_device' + assert verification.credential_device_type == "single_device" def test_verify_authentication_response_with_RSA_public_key(self): credential = """{ @@ -222,7 +222,8 @@ def test_supports_multiple_expected_origins(self) -> None: ) def test_supports_already_parsed_credential(self) -> None: - parsed_credential = parse_authentication_credential_json("""{ + parsed_credential = parse_authentication_credential_json( + """{ "id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", "rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", "response": { @@ -233,7 +234,8 @@ def test_supports_already_parsed_credential(self) -> None: }, "type": "public-key", "clientExtensionResults": {} - }""") + }""" + ) challenge = base64url_to_bytes( "iPmAi1Pp1XL6oAgq3PWZtZPnZa1zFUDoGbaQ0_KvVG1lF2s3Rt_3o4uSzccy0tmcTIpTTT4BU1T-I4maavndjQ" ) @@ -264,10 +266,10 @@ def test_supports_dict_credential(self) -> None: "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAQ", "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVBtQWkxUHAxWEw2b0FncTNQV1p0WlBuWmExekZVRG9HYmFRMF9LdlZHMWxGMnMzUnRfM280dVN6Y2N5MHRtY1RJcFRUVDRCVTFULUk0bWFhdm5kalEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", "signature": "iOHKX3erU5_OYP_r_9HLZ-CexCE4bQRrxM8WmuoKTDdhAnZSeTP0sjECjvjfeS8MJzN1ArmvV0H0C3yy_FdRFfcpUPZzdZ7bBcmPh1XPdxRwY747OrIzcTLTFQUPdn1U-izCZtP_78VGw9pCpdMsv4CUzZdJbEcRtQuRS03qUjqDaovoJhOqEBmxJn9Wu8tBi_Qx7A33RbYjlfyLm_EDqimzDZhyietyop6XUcpKarKqVH0M6mMrM5zTjp8xf3W7odFCadXEJg-ERZqFM0-9Uup6kJNLbr6C5J4NDYmSm3HCSA6lp2iEiMPKU8Ii7QZ61kybXLxsX4w4Dm3fOLjmDw", - "userHandle": "T1RWa1l6VXdPRFV0WW1NNVlTMDBOVEkxTFRnd056Z3RabVZpWVdZNFpEVm1ZMk5p" + "userHandle": "T1RWa1l6VXdPRFV0WW1NNVlTMDBOVEkxTFRnd056Z3RabVZpWVdZNFpEVm1ZMk5p", }, "type": "public-key", - "clientExtensionResults": {} + "clientExtensionResults": {}, } challenge = base64url_to_bytes( "iPmAi1Pp1XL6oAgq3PWZtZPnZa1zFUDoGbaQ0_KvVG1lF2s3Rt_3o4uSzccy0tmcTIpTTT4BU1T-I4maavndjQ" diff --git a/tests/test_verify_registration_response.py b/tests/test_verify_registration_response.py index c693c90..64ce550 100644 --- a/tests/test_verify_registration_response.py +++ b/tests/test_verify_registration_response.py @@ -3,7 +3,11 @@ import cbor2 from pydantic import ValidationError -from webauthn.helpers import base64url_to_bytes, bytes_to_base64url, parse_registration_credential_json +from webauthn.helpers import ( + base64url_to_bytes, + bytes_to_base64url, + parse_registration_credential_json, +) from webauthn.helpers.exceptions import InvalidRegistrationResponse, InvalidCBORData from webauthn.helpers.known_root_certs import globalsign_r2 from webauthn.helpers.structs import ( @@ -57,7 +61,7 @@ def test_verifies_none_attestation_response(self) -> None: assert verification.credential_type == PublicKeyCredentialType.PUBLIC_KEY assert verification.sign_count == 23 assert verification.credential_backed_up == False - assert verification.credential_device_type == 'single_device' + assert verification.credential_device_type == "single_device" def test_raises_exception_on_unsupported_attestation_type(self) -> None: cred_json = { @@ -228,13 +232,11 @@ def test_supports_dict_credential(self) -> None: "rawId": "9y1xA8Tmg1FEmT-c7_fvWZ_uoTuoih3OvR45_oAK-cwHWhAbXrl2q62iLVTjiyEZ7O7n-CROOY494k7Q3xrs_w", "response": { "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAFwAAAAAAAAAAAAAAAAAAAAAAQPctcQPE5oNRRJk_nO_371mf7qE7qIodzr0eOf6ACvnMB1oQG165dqutoi1U44shGezu5_gkTjmOPeJO0N8a7P-lAQIDJiABIVggSFbUJF-42Ug3pdM8rDRFu_N5oiVEysPDB6n66r_7dZAiWCDUVnB39FlGypL-qAoIO9xWHtJygo2jfDmHl-_eKFRLDA", - "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiVHdON240V1R5R0tMYzRaWS1xR3NGcUtuSE00bmdscXN5VjBJQ0psTjJUTzlYaVJ5RnRya2FEd1V2c3FsLWdrTEpYUDZmbkYxTWxyWjUzTW00UjdDdnciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiVHdON240V1R5R0tMYzRaWS1xR3NGcUtuSE00bmdscXN5VjBJQ0psTjJUTzlYaVJ5RnRya2FEd1V2c3FsLWdrTEpYUDZmbkYxTWxyWjUzTW00UjdDdnciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", }, "type": "public-key", "clientExtensionResults": {}, - "transports": [ - "cable" - ] + "transports": ["cable"], } challenge = base64url_to_bytes( @@ -258,13 +260,11 @@ def test_raises_useful_error_on_bad_attestation_object(self) -> None: "rawId": "9y1xA8Tmg1FEmT-c7_fvWZ_uoTuoih3OvR45_oAK-cwHWhAbXrl2q62iLVTjiyEZ7O7n-CROOY494k7Q3xrs_w", "response": { "attestationObject": "", - "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiVHdON240V1R5R0tMYzRaWS1xR3NGcUtuSE00bmdscXN5VjBJQ0psTjJUTzlYaVJ5RnRya2FEd1V2c3FsLWdrTEpYUDZmbkYxTWxyWjUzTW00UjdDdnciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiVHdON240V1R5R0tMYzRaWS1xR3NGcUtuSE00bmdscXN5VjBJQ0psTjJUTzlYaVJ5RnRya2FEd1V2c3FsLWdrTEpYUDZmbkYxTWxyWjUzTW00UjdDdnciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", }, "type": "public-key", "clientExtensionResults": {}, - "transports": [ - "cable" - ] + "transports": ["cable"], } challenge = base64url_to_bytes( diff --git a/tests/test_verify_registration_response_apple.py b/tests/test_verify_registration_response_apple.py index dcb997f..4847c93 100644 --- a/tests/test_verify_registration_response_apple.py +++ b/tests/test_verify_registration_response_apple.py @@ -10,9 +10,7 @@ class TestVerifyRegistrationResponseApple(TestCase): # TODO: Revisit these tests when we figure out how to generate dynamic certs that # won't start failing tests 72 hours after creation... @patch("OpenSSL.crypto.X509StoreContext.verify_certificate") - def test_verify_attestation_apple_passkey( - self, mock_verify_certificate: MagicMock - ) -> None: + def test_verify_attestation_apple_passkey(self, mock_verify_certificate: MagicMock) -> None: # Mocked because these certs actually expired and started failing this test mock_verify_certificate.return_value = True @@ -40,6 +38,4 @@ def test_verify_attestation_apple_passkey( ) assert verification.fmt == AttestationFormat.APPLE - assert verification.credential_id == base64url_to_bytes( - "0yhsKG_gCzynIgNbvXWkqJKL8Uc" - ) + assert verification.credential_id == base64url_to_bytes("0yhsKG_gCzynIgNbvXWkqJKL8Uc") diff --git a/webauthn/helpers/decode_credential_public_key.py b/webauthn/helpers/decode_credential_public_key.py index e1708c4..473e8dd 100644 --- a/webauthn/helpers/decode_credential_public_key.py +++ b/webauthn/helpers/decode_credential_public_key.py @@ -8,7 +8,7 @@ @dataclass -class DecodedOKPPublicKey(): +class DecodedOKPPublicKey: kty: COSEKTY alg: COSEAlgorithmIdentifier crv: COSECRV @@ -16,7 +16,7 @@ class DecodedOKPPublicKey(): @dataclass -class DecodedEC2PublicKey(): +class DecodedEC2PublicKey: kty: COSEKTY alg: COSEAlgorithmIdentifier crv: COSECRV @@ -25,7 +25,7 @@ class DecodedEC2PublicKey(): @dataclass -class DecodedRSAPublicKey(): +class DecodedRSAPublicKey: kty: COSEKTY alg: COSEAlgorithmIdentifier n: bytes diff --git a/webauthn/helpers/decoded_public_key_to_cryptography.py b/webauthn/helpers/decoded_public_key_to_cryptography.py index e07d38d..06eb99b 100644 --- a/webauthn/helpers/decoded_public_key_to_cryptography.py +++ b/webauthn/helpers/decoded_public_key_to_cryptography.py @@ -35,9 +35,7 @@ def decoded_public_key_to_cryptography( y = int(codecs.encode(public_key.y, "hex"), 16) curve = get_ec2_curve(public_key.crv) - ecc_pub_key = EllipticCurvePublicNumbers(x, y, curve).public_key( - default_backend() - ) + ecc_pub_key = EllipticCurvePublicNumbers(x, y, curve).public_key(default_backend()) return ecc_pub_key elif isinstance(public_key, DecodedRSAPublicKey): @@ -56,10 +54,7 @@ def decoded_public_key_to_cryptography( -8 (EdDSA), where crv is 6 (Ed25519). https://www.w3.org/TR/webauthn-2/#sctn-public-key-easy """ - if ( - public_key.alg != COSEAlgorithmIdentifier.EDDSA - or public_key.crv != COSECRV.ED25519 - ): + if public_key.alg != COSEAlgorithmIdentifier.EDDSA or public_key.crv != COSECRV.ED25519: raise UnsupportedPublicKey( f"OKP public key with alg {public_key.alg} and crv {public_key.crv} is not supported" ) diff --git a/webauthn/helpers/json_loads_base64url_to_bytes.py b/webauthn/helpers/json_loads_base64url_to_bytes.py index 24e6c92..2efe502 100644 --- a/webauthn/helpers/json_loads_base64url_to_bytes.py +++ b/webauthn/helpers/json_loads_base64url_to_bytes.py @@ -16,14 +16,10 @@ def _object_hook_base64url_to_bytes(orig_dict: dict) -> dict: orig_dict["clientDataJSON"] = base64url_to_bytes(orig_dict["clientDataJSON"]) # Registration if "attestationObject" in orig_dict: - orig_dict["attestationObject"] = base64url_to_bytes( - orig_dict["attestationObject"] - ) + orig_dict["attestationObject"] = base64url_to_bytes(orig_dict["attestationObject"]) # Authentication if "authenticatorData" in orig_dict: - orig_dict["authenticatorData"] = base64url_to_bytes( - orig_dict["authenticatorData"] - ) + orig_dict["authenticatorData"] = base64url_to_bytes(orig_dict["authenticatorData"]) if "signature" in orig_dict: orig_dict["signature"] = base64url_to_bytes(orig_dict["signature"]) if "userHandle" in orig_dict: diff --git a/webauthn/helpers/parse_authenticator_data.py b/webauthn/helpers/parse_authenticator_data.py index 7d58de1..b8faded 100644 --- a/webauthn/helpers/parse_authenticator_data.py +++ b/webauthn/helpers/parse_authenticator_data.py @@ -94,7 +94,7 @@ def parse_authenticator_data(val: bytes) -> AuthenticatorData: authenticator_data.extensions = extension_bytes # We should have parsed all authenticator data by this point - if (len(val) > pointer): + if len(val) > pointer: raise InvalidAuthenticatorDataStructure( "Leftover bytes detected while parsing authenticator data" ) diff --git a/webauthn/helpers/parse_backup_flags.py b/webauthn/helpers/parse_backup_flags.py index b6dd115..e22ee25 100644 --- a/webauthn/helpers/parse_backup_flags.py +++ b/webauthn/helpers/parse_backup_flags.py @@ -6,7 +6,7 @@ @dataclass -class ParsedBackupFlags(): +class ParsedBackupFlags: credential_device_type: CredentialDeviceType credential_backed_up: bool diff --git a/webauthn/helpers/parse_cbor.py b/webauthn/helpers/parse_cbor.py index a98ae49..277386f 100644 --- a/webauthn/helpers/parse_cbor.py +++ b/webauthn/helpers/parse_cbor.py @@ -15,8 +15,6 @@ def parse_cbor(data: bytes) -> Any: try: to_return = cbor2.loads(data) except Exception as exc: - raise InvalidCBORData( - "Could not decode CBOR data" - ) from exc + raise InvalidCBORData("Could not decode CBOR data") from exc return to_return diff --git a/webauthn/helpers/parse_client_data_json.py b/webauthn/helpers/parse_client_data_json.py index 212d638..5973ce7 100644 --- a/webauthn/helpers/parse_client_data_json.py +++ b/webauthn/helpers/parse_client_data_json.py @@ -13,23 +13,17 @@ def parse_client_data_json(val: bytes) -> CollectedClientData: try: json_dict = json.loads(val) except JSONDecodeError: - raise InvalidClientDataJSONStructure( - "Unable to decode client_data_json bytes as JSON" - ) + raise InvalidClientDataJSONStructure("Unable to decode client_data_json bytes as JSON") # Ensure required values are present in client data if "type" not in json_dict: - raise InvalidClientDataJSONStructure( - 'client_data_json missing required property "type"' - ) + raise InvalidClientDataJSONStructure('client_data_json missing required property "type"') if "challenge" not in json_dict: raise InvalidClientDataJSONStructure( 'client_data_json missing required property "challenge"' ) if "origin" not in json_dict: - raise InvalidClientDataJSONStructure( - 'client_data_json missing required property "origin"' - ) + raise InvalidClientDataJSONStructure('client_data_json missing required property "origin"') client_data = CollectedClientData( type=json_dict["type"], diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index bc8ad2f..3b2d0a6 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -163,7 +163,7 @@ class TokenBindingStatus(str, Enum): @dataclass -class TokenBinding(): +class TokenBinding: """ https://www.w3.org/TR/webauthn-2/#dictdef-tokenbinding """ @@ -173,7 +173,7 @@ class TokenBinding(): @dataclass -class PublicKeyCredentialRpEntity(): +class PublicKeyCredentialRpEntity: """Information about the Relying Party. Attributes: @@ -188,7 +188,7 @@ class PublicKeyCredentialRpEntity(): @dataclass -class PublicKeyCredentialUserEntity(): +class PublicKeyCredentialUserEntity: """Information about a user of a Relying Party. Attributes: @@ -205,7 +205,7 @@ class PublicKeyCredentialUserEntity(): @dataclass -class PublicKeyCredentialParameters(): +class PublicKeyCredentialParameters: """Information about a cryptographic algorithm that may be used when creating a credential. Attributes: @@ -220,7 +220,7 @@ class PublicKeyCredentialParameters(): @dataclass -class PublicKeyCredentialDescriptor(): +class PublicKeyCredentialDescriptor: """Information about a generated credential. Attributes: @@ -232,14 +232,12 @@ class PublicKeyCredentialDescriptor(): """ id: bytes - type: Literal[ - PublicKeyCredentialType.PUBLIC_KEY - ] = PublicKeyCredentialType.PUBLIC_KEY + type: Literal[PublicKeyCredentialType.PUBLIC_KEY] = PublicKeyCredentialType.PUBLIC_KEY transports: Optional[List[AuthenticatorTransport]] = None @dataclass -class AuthenticatorSelectionCriteria(): +class AuthenticatorSelectionCriteria: """A Relying Party's requirements for the types of authenticators that may interact with the client/browser. Attributes: @@ -260,7 +258,7 @@ class AuthenticatorSelectionCriteria(): @dataclass -class CollectedClientData(): +class CollectedClientData: """Decoded ClientDataJSON Attributes: @@ -288,7 +286,7 @@ class CollectedClientData(): @dataclass -class PublicKeyCredentialCreationOptions(): +class PublicKeyCredentialCreationOptions: """Registration Options. Attributes: @@ -315,7 +313,7 @@ class PublicKeyCredentialCreationOptions(): @dataclass -class AuthenticatorAttestationResponse(): +class AuthenticatorAttestationResponse: """The `response` property on a registration credential. Attributes: @@ -333,7 +331,7 @@ class AuthenticatorAttestationResponse(): @dataclass -class RegistrationCredential(): +class RegistrationCredential: """A registration-specific subclass of PublicKeyCredential returned from `navigator.credentials.create()` Attributes: @@ -349,13 +347,11 @@ class RegistrationCredential(): raw_id: bytes response: AuthenticatorAttestationResponse authenticator_attachment: Optional[AuthenticatorAttachment] = None - type: Literal[ - PublicKeyCredentialType.PUBLIC_KEY - ] = PublicKeyCredentialType.PUBLIC_KEY + type: Literal[PublicKeyCredentialType.PUBLIC_KEY] = PublicKeyCredentialType.PUBLIC_KEY @dataclass -class AttestationStatement(): +class AttestationStatement: """A collection of all possible fields that may exist in an attestation statement. Combinations of these fields are specific to a particular attestation format. https://www.w3.org/TR/webauthn-2/#sctn-defined-attestation-formats @@ -375,7 +371,7 @@ class AttestationStatement(): @dataclass -class AuthenticatorDataFlags(): +class AuthenticatorDataFlags: """Flags the authenticator will set about information contained within the `attestationObject.authData` property. Attributes: @@ -398,7 +394,7 @@ class AuthenticatorDataFlags(): @dataclass -class AttestedCredentialData(): +class AttestedCredentialData: """Information about a credential. Attributes: @@ -415,7 +411,7 @@ class AttestedCredentialData(): @dataclass -class AuthenticatorData(): +class AuthenticatorData: """Context the authenticator provides about itself and the environment in which the registration or authentication ceremony took place. Attributes: @@ -437,7 +433,7 @@ class AuthenticatorData(): @dataclass -class AttestationObject(): +class AttestationObject: """Information about an attestation, including a statement and authenticator data. Attributes: @@ -461,7 +457,7 @@ class AttestationObject(): @dataclass -class PublicKeyCredentialRequestOptions(): +class PublicKeyCredentialRequestOptions: """Authentication Options. Attributes: @@ -484,7 +480,7 @@ class PublicKeyCredentialRequestOptions(): @dataclass -class AuthenticatorAssertionResponse(): +class AuthenticatorAssertionResponse: """The `response` property on an authentication credential. Attributes: @@ -503,7 +499,7 @@ class AuthenticatorAssertionResponse(): @dataclass -class AuthenticationCredential(): +class AuthenticationCredential: """An authentication-specific subclass of PublicKeyCredential. Returned from `navigator.credentials.get()` Attributes: @@ -519,9 +515,7 @@ class AuthenticationCredential(): raw_id: bytes response: AuthenticatorAssertionResponse authenticator_attachment: Optional[AuthenticatorAttachment] = None - type: Literal[ - PublicKeyCredentialType.PUBLIC_KEY - ] = PublicKeyCredentialType.PUBLIC_KEY + type: Literal[PublicKeyCredentialType.PUBLIC_KEY] = PublicKeyCredentialType.PUBLIC_KEY ################ diff --git a/webauthn/helpers/validate_certificate_chain.py b/webauthn/helpers/validate_certificate_chain.py index 47b3948..9fef8c5 100644 --- a/webauthn/helpers/validate_certificate_chain.py +++ b/webauthn/helpers/validate_certificate_chain.py @@ -44,12 +44,9 @@ def validate_certificate_chain( # May be an empty array, that's fine intermediate_certs_bytes = x5c[1:] intermediate_certs_crypto = [ - load_der_x509_certificate(cert, default_backend()) - for cert in intermediate_certs_bytes - ] - intermediate_certs = [ - X509().from_cryptography(cert) for cert in intermediate_certs_crypto + load_der_x509_certificate(cert, default_backend()) for cert in intermediate_certs_bytes ] + intermediate_certs = [X509().from_cryptography(cert) for cert in intermediate_certs_crypto] except Exception as err: raise InvalidCertificateChain(f"Could not prepare intermediate certs: {err}") diff --git a/webauthn/helpers/verify_safetynet_timestamp.py b/webauthn/helpers/verify_safetynet_timestamp.py index d11f49a..721f6b2 100644 --- a/webauthn/helpers/verify_safetynet_timestamp.py +++ b/webauthn/helpers/verify_safetynet_timestamp.py @@ -12,9 +12,7 @@ def verify_safetynet_timestamp(timestamp_ms: int) -> None: # Make sure the response was generated in the past if timestamp_ms > (now + grace_ms): - raise ValueError( - f"Payload timestamp {timestamp_ms} was later than {now} + {grace_ms}" - ) + raise ValueError(f"Payload timestamp {timestamp_ms} was later than {now} + {grace_ms}") # Make sure the response arrived within the grace period if timestamp_ms < (now - grace_ms): diff --git a/webauthn/helpers/verify_signature.py b/webauthn/helpers/verify_signature.py index d0ae3a6..a3b732b 100644 --- a/webauthn/helpers/verify_signature.py +++ b/webauthn/helpers/verify_signature.py @@ -53,9 +53,7 @@ def verify_signature( public_key.verify(signature, data, get_ec2_sig_alg(signature_alg)) elif isinstance(public_key, RSAPublicKey): if is_rsa_pkcs(signature_alg): - public_key.verify( - signature, data, PKCS1v15(), get_rsa_pkcs1_sig_alg(signature_alg) - ) + public_key.verify(signature, data, PKCS1v15(), get_rsa_pkcs1_sig_alg(signature_alg)) elif is_rsa_pss(signature_alg): rsa_alg = get_rsa_pss_sig_alg(signature_alg) public_key.verify( @@ -65,9 +63,7 @@ def verify_signature( rsa_alg, ) else: - raise UnsupportedAlgorithm( - f"Unrecognized RSA signature alg {signature_alg}" - ) + raise UnsupportedAlgorithm(f"Unrecognized RSA signature alg {signature_alg}") elif isinstance(public_key, Ed25519PublicKey): public_key.verify(signature, data) else: diff --git a/webauthn/registration/formats/android_key.py b/webauthn/registration/formats/android_key.py index 9c441d1..a9c9c6b 100644 --- a/webauthn/registration/formats/android_key.py +++ b/webauthn/registration/formats/android_key.py @@ -61,9 +61,7 @@ def verify_android_key( ) if not attestation_statement.x5c: - raise InvalidRegistrationResponse( - "Attestation statement was missing x5c (Android Key)" - ) + raise InvalidRegistrationResponse("Attestation statement was missing x5c (Android Key)") # Validate certificate chain try: @@ -98,9 +96,7 @@ def verify_android_key( # and clientDataHash using the public key in the first certificate in x5c with the # algorithm specified in alg. attestation_cert_bytes = attestation_statement.x5c[0] - attestation_cert = x509.load_der_x509_certificate( - attestation_cert_bytes, default_backend() - ) + attestation_cert = x509.load_der_x509_certificate(attestation_cert_bytes, default_backend()) attestation_cert_pub_key = attestation_cert.public_key() try: diff --git a/webauthn/registration/formats/android_safetynet.py b/webauthn/registration/formats/android_safetynet.py index 105bae1..2dbc3b7 100644 --- a/webauthn/registration/formats/android_safetynet.py +++ b/webauthn/registration/formats/android_safetynet.py @@ -25,7 +25,7 @@ @dataclass -class SafetyNetJWSHeader(): +class SafetyNetJWSHeader: """Properties in the Header of a SafetyNet JWS""" alg: str @@ -33,7 +33,7 @@ class SafetyNetJWSHeader(): @dataclass -class SafetyNetJWSPayload(): +class SafetyNetJWSPayload: """Properties in the Payload of a SafetyNet JWS Values below correspond to camelCased properties in the JWS itself. This class @@ -72,23 +72,17 @@ def verify_android_safetynet( if not attestation_statement.ver: # As of this writing, there is only one format of the SafetyNet response and # ver is reserved for future use (so for now just make sure it's present) - raise InvalidRegistrationResponse( - "Attestation statement was missing version (SafetyNet)" - ) + raise InvalidRegistrationResponse("Attestation statement was missing version (SafetyNet)") if not attestation_statement.response: - raise InvalidRegistrationResponse( - "Attestation statement was missing response (SafetyNet)" - ) + raise InvalidRegistrationResponse("Attestation statement was missing response (SafetyNet)") # Begin peeling apart the JWS in the attestation statement response jws = attestation_statement.response.decode("ascii") jws_parts = jws.split(".") if len(jws_parts) != 3: - raise InvalidRegistrationResponse( - "Response JWS did not have three parts (SafetyNet)" - ) + raise InvalidRegistrationResponse("Response JWS did not have three parts (SafetyNet)") # TODO: Rewrite this # if PYDANTIC_V2: @@ -129,18 +123,14 @@ def verify_android_safetynet( nonce_data_str = nonce_data_hash_bytes.decode("utf-8") if payload.nonce != nonce_data_str: - raise InvalidRegistrationResponse( - "Payload nonce was not expected value (SafetyNet)" - ) + raise InvalidRegistrationResponse("Payload nonce was not expected value (SafetyNet)") # Verify that the SafetyNet response actually came from the SafetyNet service # by following the steps in the SafetyNet online documentation. x5c = [base64url_to_bytes(cert) for cert in header.x5c] if not payload.cts_profile_match: - raise InvalidRegistrationResponse( - "Could not verify device integrity (SafetyNet)" - ) + raise InvalidRegistrationResponse("Could not verify device integrity (SafetyNet)") if verify_timestamp_ms: try: @@ -178,9 +168,7 @@ def verify_android_safetynet( signature_bytes = base64url_to_bytes(signature_bytes_str) if header.alg != "RS256": - raise InvalidRegistrationResponse( - f"JWS header alg was not RS256: {header.alg} (SafetyNet" - ) + raise InvalidRegistrationResponse(f"JWS header alg was not RS256: {header.alg} (SafetyNet") # Get cert public key bytes attestation_cert_pub_key = attestation_cert.public_key() diff --git a/webauthn/registration/formats/apple.py b/webauthn/registration/formats/apple.py index 63cf664..a3c07ec 100644 --- a/webauthn/registration/formats/apple.py +++ b/webauthn/registration/formats/apple.py @@ -39,9 +39,7 @@ def verify_apple( """ if not attestation_statement.x5c: - raise InvalidRegistrationResponse( - "Attestation statement was missing x5c (Apple)" - ) + raise InvalidRegistrationResponse("Attestation statement was missing x5c (Apple)") # Validate the certificate chain try: @@ -78,9 +76,7 @@ def verify_apple( # Verify that nonce equals the value of the extension with # OID 1.2.840.113635.100.8.2 in credCert. attestation_cert_bytes = attestation_statement.x5c[0] - attestation_cert = x509.load_der_x509_certificate( - attestation_cert_bytes, default_backend() - ) + attestation_cert = x509.load_der_x509_certificate(attestation_cert_bytes, default_backend()) cert_extensions = attestation_cert.extensions # Still no documented name for this OID... @@ -102,9 +98,7 @@ def verify_apple( ext_value: bytes = ext_value_wrapper.value[6:] if ext_value != nonce_bytes: - raise InvalidRegistrationResponse( - "Certificate nonce was not expected value (Apple)" - ) + raise InvalidRegistrationResponse("Certificate nonce was not expected value (Apple)") # Verify that the credential public key equals the Subject Public Key of credCert. attestation_cert_pub_key = attestation_cert.public_key() diff --git a/webauthn/registration/formats/fido_u2f.py b/webauthn/registration/formats/fido_u2f.py index 70d661b..7ec5e1c 100644 --- a/webauthn/registration/formats/fido_u2f.py +++ b/webauthn/registration/formats/fido_u2f.py @@ -41,9 +41,7 @@ def verify_fido_u2f( See https://www.w3.org/TR/webauthn-2/#sctn-fido-u2f-attestation """ if not attestation_statement.sig: - raise InvalidRegistrationResponse( - "Attestation statement was missing signature (FIDO-U2F)" - ) + raise InvalidRegistrationResponse("Attestation statement was missing signature (FIDO-U2F)") if not attestation_statement.x5c: raise InvalidRegistrationResponse( @@ -80,20 +78,14 @@ def verify_fido_u2f( # We need the cert's x and y points so make sure they exist if not isinstance(leaf_cert_pub_key, EllipticCurvePublicKey): - raise InvalidRegistrationResponse( - "Leaf cert was not an EC2 certificate (FIDO-U2F)" - ) + raise InvalidRegistrationResponse("Leaf cert was not an EC2 certificate (FIDO-U2F)") if not isinstance(leaf_cert_pub_key.curve, SECP256R1): - raise InvalidRegistrationResponse( - "Leaf cert did not use P-256 curve (FIDO-U2F)" - ) + raise InvalidRegistrationResponse("Leaf cert did not use P-256 curve (FIDO-U2F)") decoded_public_key = decode_credential_public_key(credential_public_key) if not isinstance(decoded_public_key, DecodedEC2PublicKey): - raise InvalidRegistrationResponse( - "Credential public key was not EC2 (FIDO-U2F)" - ) + raise InvalidRegistrationResponse("Credential public key was not EC2 (FIDO-U2F)") # Convert the public key to "Raw ANSI X9.62 public key format" public_key_u2f = b"".join( diff --git a/webauthn/registration/formats/packed.py b/webauthn/registration/formats/packed.py index 4c9849b..75c0aed 100644 --- a/webauthn/registration/formats/packed.py +++ b/webauthn/registration/formats/packed.py @@ -32,14 +32,10 @@ def verify_packed( See https://www.w3.org/TR/webauthn-2/#sctn-packed-attestation """ if not attestation_statement.sig: - raise InvalidRegistrationResponse( - "Attestation statement was missing signature (Packed)" - ) + raise InvalidRegistrationResponse("Attestation statement was missing signature (Packed)") if not attestation_statement.alg: - raise InvalidRegistrationResponse( - "Attestation statement was missing algorithm (Packed)" - ) + raise InvalidRegistrationResponse("Attestation statement was missing algorithm (Packed)") # Extract attStmt bytes from attestation_object attestation_dict = parse_cbor(attestation_object) diff --git a/webauthn/registration/formats/tpm.py b/webauthn/registration/formats/tpm.py index 84a3d59..504242a 100644 --- a/webauthn/registration/formats/tpm.py +++ b/webauthn/registration/formats/tpm.py @@ -54,14 +54,10 @@ def verify_tpm( See https://www.w3.org/TR/webauthn-2/#sctn-tpm-attestation """ if not attestation_statement.cert_info: - raise InvalidRegistrationResponse( - "Attestation statement was missing certInfo (TPM)" - ) + raise InvalidRegistrationResponse("Attestation statement was missing certInfo (TPM)") if not attestation_statement.pub_area: - raise InvalidRegistrationResponse( - "Attestation statement was missing pubArea (TPM)" - ) + raise InvalidRegistrationResponse("Attestation statement was missing pubArea (TPM)") if not attestation_statement.alg: raise InvalidRegistrationResponse("Attestation statement was missing alg (TPM)") @@ -195,9 +191,7 @@ def verify_tpm( # Verify the sig is a valid signature over certInfo using the attestation # public key in aikCert with the algorithm specified in alg. attestation_cert_bytes = attestation_statement.x5c[0] - attestation_cert = x509.load_der_x509_certificate( - attestation_cert_bytes, default_backend() - ) + attestation_cert = x509.load_der_x509_certificate(attestation_cert_bytes, default_backend()) attestation_cert_pub_key = attestation_cert.public_key() try: @@ -208,9 +202,7 @@ def verify_tpm( data=attestation_statement.cert_info, ) except InvalidSignature: - raise InvalidRegistrationResponse( - "Could not verify attestation statement signature (TPM)" - ) + raise InvalidRegistrationResponse("Could not verify attestation statement signature (TPM)") # Verify that aikCert meets the requirements in § 8.3.1 TPM Attestation Statement # Certificate Requirements. @@ -301,9 +293,7 @@ def verify_tpm( # The Basic Constraints extension MUST have the CA component set to false. if ext_basic_constraints.ca is not False: - raise InvalidRegistrationResponse( - "Certificate Basic Constraints CA was not False (TPM)" - ) + raise InvalidRegistrationResponse("Certificate Basic Constraints CA was not False (TPM)") # If aikCert contains an extension with OID 1.3.6.1.4.1.45724.1.1.4 # (id-fido-gen-ce-aaguid) verify that the value of this extension matches the diff --git a/webauthn/registration/generate_registration_options.py b/webauthn/registration/generate_registration_options.py index b43b02e..edd93bd 100644 --- a/webauthn/registration/generate_registration_options.py +++ b/webauthn/registration/generate_registration_options.py @@ -20,10 +20,7 @@ def _generate_pub_key_cred_params( """ Take an array of algorithm ID ints and return an array of PublicKeyCredentialParameters """ - return [ - PublicKeyCredentialParameters(type="public-key", alg=alg) - for alg in supported_algs - ] + return [PublicKeyCredentialParameters(type="public-key", alg=alg) for alg in supported_algs] default_supported_pub_key_algs = [ diff --git a/webauthn/registration/verify_registration_response.py b/webauthn/registration/verify_registration_response.py index d1711ad..03723e6 100644 --- a/webauthn/registration/verify_registration_response.py +++ b/webauthn/registration/verify_registration_response.py @@ -31,7 +31,7 @@ @dataclass -class VerifiedRegistration(): +class VerifiedRegistration: """Information about a verified attestation of which an RP can make use. Attributes: @@ -70,12 +70,8 @@ def verify_registration_response( expected_rp_id: str, expected_origin: Union[str, List[str]], require_user_verification: bool = False, - supported_pub_key_algs: List[ - COSEAlgorithmIdentifier - ] = default_supported_pub_key_algs, - pem_root_certs_bytes_by_fmt: Optional[ - Mapping[AttestationFormat, List[bytes]] - ] = None, + supported_pub_key_algs: List[COSEAlgorithmIdentifier] = default_supported_pub_key_algs, + pem_root_certs_bytes_by_fmt: Optional[Mapping[AttestationFormat, List[bytes]]] = None, ) -> VerifiedRegistration: """Verify an authenticator's response to navigator.credentials.create() @@ -126,9 +122,7 @@ def verify_registration_response( ) if expected_challenge != client_data.challenge: - raise InvalidRegistrationResponse( - "Client data challenge was not expected challenge" - ) + raise InvalidRegistrationResponse("Client data challenge was not expected challenge") if isinstance(expected_origin, str): if expected_origin != client_data.origin: @@ -171,21 +165,15 @@ def verify_registration_response( ) if not auth_data.attested_credential_data: - raise InvalidRegistrationResponse( - "Authenticator did not provide attested credential data" - ) + raise InvalidRegistrationResponse("Authenticator did not provide attested credential data") attested_credential_data = auth_data.attested_credential_data if not attested_credential_data.credential_id: - raise InvalidRegistrationResponse( - "Authenticator did not provide a credential ID" - ) + raise InvalidRegistrationResponse("Authenticator did not provide a credential ID") if not attested_credential_data.credential_public_key: - raise InvalidRegistrationResponse( - "Authenticator did not provide a credential public key" - ) + raise InvalidRegistrationResponse("Authenticator did not provide a credential public key") if not attested_credential_data.aaguid: raise InvalidRegistrationResponse("Authenticator did not provide an AAGUID") From fc60fe8f80d4a2398694ebdadbd10fa33d3f0645 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 11:24:51 -0800 Subject: [PATCH 06/47] Rewrite auth credential parsing --- webauthn/helpers/exceptions.py | 4 + .../parse_authentication_credential_json.py | 83 +++++++++++++++++-- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/webauthn/helpers/exceptions.py b/webauthn/helpers/exceptions.py index 1f58ded..523ba89 100644 --- a/webauthn/helpers/exceptions.py +++ b/webauthn/helpers/exceptions.py @@ -18,6 +18,10 @@ class InvalidClientDataJSONStructure(Exception): pass +class InvalidJSONStructure(Exception): + pass + + class InvalidAuthenticatorDataStructure(Exception): pass diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index 62ec826..aebff31 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -1,8 +1,15 @@ import json +from json.decoder import JSONDecodeError from typing import Callable, Union -from .exceptions import InvalidAuthenticationResponse -from .structs import AuthenticationCredential +from .exceptions import InvalidAuthenticationResponse, InvalidJSONStructure +from .base64url_to_bytes import base64url_to_bytes +from .structs import ( + AuthenticationCredential, + AuthenticatorAssertionResponse, + AuthenticatorAttachment, + PublicKeyCredentialType, +) def parse_authentication_credential_json(json_val: Union[str, dict]) -> AuthenticationCredential: @@ -10,12 +17,78 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti Parse a JSON form of an authentication credential, as either a stringified JSON object or a plain dict, into an instance of AuthenticationCredential """ - if isinstance(json_val, dict): - json_val = json.dumps(json_val) + if isinstance(json_val, str): + try: + json_val = json.loads(json_val) + except JSONDecodeError: + raise InvalidJSONStructure("Unable to decode credential as JSON") + + assert isinstance(json_val, dict) + + cred_id = json_val.get("id") + if not isinstance(cred_id, str): + raise InvalidJSONStructure("JSON missing required id") + + cred_raw_id = json_val.get("rawId") + if not isinstance(cred_id, str): + raise InvalidJSONStructure("JSON missing required rawId") + + cred_response = json_val.get("response") + if not isinstance(cred_response, dict): + raise InvalidJSONStructure("JSON missing required response") + + response_client_data_json = cred_response.get("clientDataJSON") + if not isinstance(response_client_data_json, str): + raise InvalidJSONStructure("JSON response missing required clientDataJSON") + + response_authenticator_data = cred_response.get("authenticatorData") + if not isinstance(response_authenticator_data, str): + raise InvalidJSONStructure("JSON response missing required authenticatorData") + + response_signature = cred_response.get("signature") + if not isinstance(response_signature, str): + raise InvalidJSONStructure("JSON response missing required signature") + + response_user_handle = cred_response.get("userHandle") + if isinstance(response_user_handle, str): + response_user_handle = base64url_to_bytes(response_user_handle) + else: + response_user_handle = None + + cred_authenticator_attachment = json_val.get("authenticatorAttachment") + if isinstance(cred_authenticator_attachment, str): + try: + cred_authenticator_attachment = AuthenticatorAttachment(cred_authenticator_attachment) + except ValueError as cred_attachment_exc: + raise InvalidJSONStructure( + "Unexpected authenticator attachment" + ) from cred_attachment_exc + else: + cred_authenticator_attachment = None + + cred_type = json_val.get("type") + if isinstance(cred_type, str): + try: + cred_type = PublicKeyCredentialType(cred_type) + except ValueError as cred_type_exc: + raise InvalidJSONStructure("Unexpected credential type") from cred_type_exc + else: + cred_type = None try: # TODO: Write this - authentication_credential = AuthenticationCredential() + authentication_credential = AuthenticationCredential( + id=cred_id, + raw_id=base64url_to_bytes(cred_raw_id), + response=AuthenticatorAssertionResponse( + client_data_json=base64url_to_bytes(response_client_data_json), + authenticator_data=base64url_to_bytes(response_authenticator_data), + signature=base64url_to_bytes(response_signature), + user_handle=response_user_handle, + ), + authenticator_attachment=cred_authenticator_attachment, + type=cred_type, + ) except Exception as exc: raise InvalidAuthenticationResponse( "Unable to parse an authentication credential from JSON data" From b7d76311890cf064b97a6b6ee6731e5df0419ba8 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 11:34:17 -0800 Subject: [PATCH 07/47] Restore typing_extensions for mypy --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 025e1f2..1c334c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,4 @@ regex==2021.10.8 six==1.16.0 toml==0.10.2 tomli==1.2.1 +typing_extensions==4.9.0 From 5b4336488d18d58e15f48f22baba0a0e38955d15 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 11:34:51 -0800 Subject: [PATCH 08/47] Fix mypy config --- mypy.ini | 5 ----- 1 file changed, 5 deletions(-) diff --git a/mypy.ini b/mypy.ini index 10e9fbf..32e6487 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,11 +1,6 @@ [mypy] -plugins = pydantic.mypy - python_version = 3.8 -[pydantic-mypy] -init_typed=True - [mypy-asn1crypto.*] ignore_missing_imports = True From f5dd32c0fbac5feba8d8d6a6051e4c2deb8199b7 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 11:34:57 -0800 Subject: [PATCH 09/47] Remove unused type --- webauthn/helpers/parse_authentication_credential_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index aebff31..7dc7263 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -1,6 +1,6 @@ import json from json.decoder import JSONDecodeError -from typing import Callable, Union +from typing import Union from .exceptions import InvalidAuthenticationResponse, InvalidJSONStructure from .base64url_to_bytes import base64url_to_bytes From 8bf3c717a898eba2e8293d027e7c9ed9fb47f557 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 11:37:07 -0800 Subject: [PATCH 10/47] Fix incorrect instance check --- webauthn/helpers/parse_authentication_credential_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index 7dc7263..795c040 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -30,7 +30,7 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti raise InvalidJSONStructure("JSON missing required id") cred_raw_id = json_val.get("rawId") - if not isinstance(cred_id, str): + if not isinstance(cred_raw_id, str): raise InvalidJSONStructure("JSON missing required rawId") cred_response = json_val.get("response") From bd28d8a094ebd5c41a6e8157b52fc0a99bf1be62 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 11:41:28 -0800 Subject: [PATCH 11/47] Don't initialize an Optional field --- webauthn/helpers/structs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index 3b2d0a6..a129e04 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -473,7 +473,7 @@ class PublicKeyCredentialRequestOptions: challenge: bytes timeout: Optional[int] = None rp_id: Optional[str] = None - allow_credentials: Optional[List[PublicKeyCredentialDescriptor]] = field(default_factory=[]) + allow_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None user_verification: Optional[ UserVerificationRequirement ] = UserVerificationRequirement.PREFERRED From 9535b9cf26cb41632e9791fea06d2f8cfadf2146 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 11:58:55 -0800 Subject: [PATCH 12/47] Update VS Code settings --- .vscode/settings.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index ac79380..3bf08d7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,10 +2,12 @@ "black-formatter.args": [ "--line-length", "99" ], + "mypy-type-checker.path": ["venv/bin/mypy"], "[python]": { "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnPaste": false, "editor.formatOnSaveMode": "file", - "editor.formatOnSave": true - } + "editor.formatOnSave": true, + }, + "python.analysis.typeCheckingMode": "basic" } From 2c3357e15ca56774e367e4e161490dcb99358e9e Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 11:59:15 -0800 Subject: [PATCH 13/47] Tweak exception messages --- .../parse_authentication_credential_json.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index 795c040..05038b1 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -27,27 +27,27 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti cred_id = json_val.get("id") if not isinstance(cred_id, str): - raise InvalidJSONStructure("JSON missing required id") + raise InvalidJSONStructure("Credential missing required id") cred_raw_id = json_val.get("rawId") if not isinstance(cred_raw_id, str): - raise InvalidJSONStructure("JSON missing required rawId") + raise InvalidJSONStructure("Credential missing required rawId") cred_response = json_val.get("response") if not isinstance(cred_response, dict): - raise InvalidJSONStructure("JSON missing required response") + raise InvalidJSONStructure("Credential missing required response") response_client_data_json = cred_response.get("clientDataJSON") if not isinstance(response_client_data_json, str): - raise InvalidJSONStructure("JSON response missing required clientDataJSON") + raise InvalidJSONStructure("Credential response missing required clientDataJSON") response_authenticator_data = cred_response.get("authenticatorData") if not isinstance(response_authenticator_data, str): - raise InvalidJSONStructure("JSON response missing required authenticatorData") + raise InvalidJSONStructure("Credential response missing required authenticatorData") response_signature = cred_response.get("signature") if not isinstance(response_signature, str): - raise InvalidJSONStructure("JSON response missing required signature") + raise InvalidJSONStructure("Credential response missing required signature") response_user_handle = cred_response.get("userHandle") if isinstance(response_user_handle, str): @@ -61,7 +61,7 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti cred_authenticator_attachment = AuthenticatorAttachment(cred_authenticator_attachment) except ValueError as cred_attachment_exc: raise InvalidJSONStructure( - "Unexpected authenticator attachment" + "Credential has unexpected authenticator attachment" ) from cred_attachment_exc else: cred_authenticator_attachment = None From 5b6af1240df1904701a3854cb3e7c5eb08179209 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 11:59:24 -0800 Subject: [PATCH 14/47] Appease mypy with this one weird trick --- .../parse_authentication_credential_json.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index 05038b1..e40355e 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -67,16 +67,14 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti cred_authenticator_attachment = None cred_type = json_val.get("type") - if isinstance(cred_type, str): - try: - cred_type = PublicKeyCredentialType(cred_type) - except ValueError as cred_type_exc: - raise InvalidJSONStructure("Unexpected credential type") from cred_type_exc - else: - cred_type = None + try: + # Simply try to get the single matching Enum. We'll set the literal value below assuming + # the code can get past here (this is basically a mypy optimization) + PublicKeyCredentialType(cred_type) + except ValueError as cred_type_exc: + raise InvalidJSONStructure("Credential had unexpected type") from cred_type_exc try: - # TODO: Write this authentication_credential = AuthenticationCredential( id=cred_id, raw_id=base64url_to_bytes(cred_raw_id), @@ -87,7 +85,7 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti user_handle=response_user_handle, ), authenticator_attachment=cred_authenticator_attachment, - type=cred_type, + type=PublicKeyCredentialType.PUBLIC_KEY, ) except Exception as exc: raise InvalidAuthenticationResponse( From e9cbfc974d40463b7c6929aeafa419c0cbf5e33a Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 11:59:58 -0800 Subject: [PATCH 15/47] Use InvalidJSONStructure in parse_client_data_json --- webauthn/helpers/exceptions.py | 4 ---- webauthn/helpers/parse_client_data_json.py | 16 ++++++---------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/webauthn/helpers/exceptions.py b/webauthn/helpers/exceptions.py index 523ba89..7776fcd 100644 --- a/webauthn/helpers/exceptions.py +++ b/webauthn/helpers/exceptions.py @@ -14,10 +14,6 @@ class UnsupportedPublicKeyType(Exception): pass -class InvalidClientDataJSONStructure(Exception): - pass - - class InvalidJSONStructure(Exception): pass diff --git a/webauthn/helpers/parse_client_data_json.py b/webauthn/helpers/parse_client_data_json.py index 5973ce7..a713788 100644 --- a/webauthn/helpers/parse_client_data_json.py +++ b/webauthn/helpers/parse_client_data_json.py @@ -2,7 +2,7 @@ from json.decoder import JSONDecodeError from .base64url_to_bytes import base64url_to_bytes -from .exceptions import InvalidClientDataJSONStructure +from .exceptions import InvalidJSONStructure from .structs import CollectedClientData, TokenBinding @@ -13,17 +13,15 @@ def parse_client_data_json(val: bytes) -> CollectedClientData: try: json_dict = json.loads(val) except JSONDecodeError: - raise InvalidClientDataJSONStructure("Unable to decode client_data_json bytes as JSON") + raise InvalidJSONStructure("Unable to decode client_data_json bytes as JSON") # Ensure required values are present in client data if "type" not in json_dict: - raise InvalidClientDataJSONStructure('client_data_json missing required property "type"') + raise InvalidJSONStructure('client_data_json missing required property "type"') if "challenge" not in json_dict: - raise InvalidClientDataJSONStructure( - 'client_data_json missing required property "challenge"' - ) + raise InvalidJSONStructure('client_data_json missing required property "challenge"') if "origin" not in json_dict: - raise InvalidClientDataJSONStructure('client_data_json missing required property "origin"') + raise InvalidJSONStructure('client_data_json missing required property "origin"') client_data = CollectedClientData( type=json_dict["type"], @@ -42,9 +40,7 @@ def parse_client_data_json(val: bytes) -> CollectedClientData: # Some U2F devices set a string to `token_binding`, in which case ignore it if type(token_binding_dict) is dict: if "status" not in token_binding_dict: - raise InvalidClientDataJSONStructure( - 'token_binding missing required property "status"' - ) + raise InvalidJSONStructure('token_binding missing required property "status"') status = token_binding_dict["status"] try: From 9dd6d53911c6477426d46c1a6df06993c55098b5 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 12:42:03 -0800 Subject: [PATCH 16/47] Create encode_cbor to abstract cbor2 use --- webauthn/helpers/__init__.py | 2 ++ webauthn/helpers/encode_cbor.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 webauthn/helpers/encode_cbor.py diff --git a/webauthn/helpers/__init__.py b/webauthn/helpers/__init__.py index 5a2ded0..8e28f71 100644 --- a/webauthn/helpers/__init__.py +++ b/webauthn/helpers/__init__.py @@ -3,6 +3,7 @@ from .bytes_to_base64url import bytes_to_base64url from .decode_credential_public_key import decode_credential_public_key from .decoded_public_key_to_cryptography import decoded_public_key_to_cryptography +from .encode_cbor import encode_cbor from .generate_challenge import generate_challenge from .generate_user_handle import generate_user_handle from .hash_by_alg import hash_by_alg @@ -25,6 +26,7 @@ "bytes_to_base64url", "decode_credential_public_key", "decoded_public_key_to_cryptography", + "encode_cbor", "generate_challenge", "generate_user_handle", "hash_by_alg", diff --git a/webauthn/helpers/encode_cbor.py b/webauthn/helpers/encode_cbor.py new file mode 100644 index 0000000..ba947f0 --- /dev/null +++ b/webauthn/helpers/encode_cbor.py @@ -0,0 +1,20 @@ +from typing import Any + +import cbor2 + +from .exceptions import InvalidCBORData + + +def encode_cbor(val: Any) -> bytes: + """ + Attempt to encode data into CBOR. + + Raises: + `helpers.exceptions.InvalidCBORData` if data cannot be decoded + """ + try: + to_return = cbor2.dumps(val) + except Exception as exc: + raise InvalidCBORData("Data could not be encoded to CBOR") from exc + + return to_return From b8ff3b61368dc818441353b76b58ba71e0a6d639 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 12:42:37 -0800 Subject: [PATCH 17/47] Abstract more cbor2 use --- tests/test_parse_authenticator_data.py | 6 +++--- tests/test_verify_registration_response.py | 12 ++++++++---- webauthn/helpers/decode_credential_public_key.py | 3 ++- webauthn/helpers/parse_authenticator_data.py | 7 +++---- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/test_parse_authenticator_data.py b/tests/test_parse_authenticator_data.py index 8f7f230..cf87f27 100644 --- a/tests/test_parse_authenticator_data.py +++ b/tests/test_parse_authenticator_data.py @@ -3,7 +3,7 @@ from unittest import TestCase import cbor2 -from webauthn.helpers import parse_authenticator_data, bytes_to_base64url +from webauthn.helpers import parse_authenticator_data, bytes_to_base64url, parse_cbor from webauthn.helpers.base64url_to_bytes import base64url_to_bytes @@ -153,7 +153,7 @@ def test_parses_attested_credential_data_and_extension_data(self) -> None: self.assertIsNotNone(extensions) assert extensions # Make mypy happy - parsed_extensions = cbor2.loads(extensions) + parsed_extensions = parse_cbor(extensions) self.assertEqual(parsed_extensions, {"credProtect": 1}) def test_parses_only_extension_data(self) -> None: @@ -167,7 +167,7 @@ def test_parses_only_extension_data(self) -> None: extensions = output.extensions self.assertIsNotNone(extensions) assert extensions # Make mypy happy - parsed_extensions = cbor2.loads(extensions) + parsed_extensions = parse_cbor(extensions) self.assertEqual( parsed_extensions, { diff --git a/tests/test_verify_registration_response.py b/tests/test_verify_registration_response.py index 64ce550..f030500 100644 --- a/tests/test_verify_registration_response.py +++ b/tests/test_verify_registration_response.py @@ -1,12 +1,12 @@ import json from unittest import TestCase -import cbor2 -from pydantic import ValidationError from webauthn.helpers import ( base64url_to_bytes, bytes_to_base64url, + encode_cbor, parse_registration_credential_json, + parse_cbor, ) from webauthn.helpers.exceptions import InvalidRegistrationResponse, InvalidCBORData from webauthn.helpers.known_root_certs import globalsign_r2 @@ -78,9 +78,13 @@ def test_raises_exception_on_unsupported_attestation_type(self) -> None: # Take the otherwise legitimate credential and mangle its attestationObject's # "fmt" to something it could never actually be - parsed_atte_obj = cbor2.loads(base64url_to_bytes(cred_json["response"]["attestationObject"])) # type: ignore + parsed_atte_obj: dict = parse_cbor( + base64url_to_bytes(cred_json["response"]["attestationObject"]) # type: ignore + ) parsed_atte_obj["fmt"] = "not_real_fmt" - cred_json["response"]["attestationObject"] = bytes_to_base64url(cbor2.dumps(parsed_atte_obj)) # type: ignore + cred_json["response"]["attestationObject"] = bytes_to_base64url( # type: ignore + encode_cbor(parsed_atte_obj) + ) credential = json.dumps(cred_json) challenge = base64url_to_bytes( diff --git a/webauthn/helpers/decode_credential_public_key.py b/webauthn/helpers/decode_credential_public_key.py index 473e8dd..077a856 100644 --- a/webauthn/helpers/decode_credential_public_key.py +++ b/webauthn/helpers/decode_credential_public_key.py @@ -5,6 +5,7 @@ from .cose import COSECRV, COSEKTY, COSEAlgorithmIdentifier, COSEKey from .exceptions import InvalidPublicKeyStructure, UnsupportedPublicKeyType +from .parse_cbor import parse_cbor @dataclass @@ -56,7 +57,7 @@ def decode_credential_public_key( y=key[33:65], ) - decoded_key: dict = cbor2.loads(key) + decoded_key: dict = parse_cbor(key) kty = decoded_key[COSEKey.KTY] alg = decoded_key[COSEKey.ALG] diff --git a/webauthn/helpers/parse_authenticator_data.py b/webauthn/helpers/parse_authenticator_data.py index b8faded..6bb5eb1 100644 --- a/webauthn/helpers/parse_authenticator_data.py +++ b/webauthn/helpers/parse_authenticator_data.py @@ -1,8 +1,7 @@ -import cbor2 - from .exceptions import InvalidAuthenticatorDataStructure from .structs import AttestedCredentialData, AuthenticatorData, AuthenticatorDataFlags from .parse_cbor import parse_cbor +from .encode_cbor import encode_cbor def parse_authenticator_data(val: bytes) -> AuthenticatorData: @@ -77,7 +76,7 @@ def parse_authenticator_data(val: bytes) -> AuthenticatorData: # Load the next CBOR-encoded value credential_public_key = parse_cbor(val[pointer:]) - credential_public_key_bytes = cbor2.dumps(credential_public_key) + credential_public_key_bytes = encode_cbor(credential_public_key) pointer += len(credential_public_key_bytes) attested_cred_data = AttestedCredentialData( @@ -89,7 +88,7 @@ def parse_authenticator_data(val: bytes) -> AuthenticatorData: if flags.ed is True: extension_object = parse_cbor(val[pointer:]) - extension_bytes = cbor2.dumps(extension_object) + extension_bytes = encode_cbor(extension_object) pointer += len(extension_bytes) authenticator_data.extensions = extension_bytes From 61b60448c5ed1e7d6b79e7dc54efda697ff4c321 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 12:42:41 -0800 Subject: [PATCH 18/47] Add comment --- webauthn/helpers/parse_authentication_credential_json.py | 1 + 1 file changed, 1 insertion(+) diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index e40355e..af6f48b 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -23,6 +23,7 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti except JSONDecodeError: raise InvalidJSONStructure("Unable to decode credential as JSON") + # Appease mypy assert isinstance(json_val, dict) cred_id = json_val.get("id") From dc3bb68c0f97561cdc58e3de90493ec5102c812c Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 12:47:05 -0800 Subject: [PATCH 19/47] Update tests --- tests/test_parse_client_data_json.py | 12 ++++++------ tests/test_verify_registration_response.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_parse_client_data_json.py b/tests/test_parse_client_data_json.py index 0fc8454..d515453 100644 --- a/tests/test_parse_client_data_json.py +++ b/tests/test_parse_client_data_json.py @@ -2,7 +2,7 @@ from unittest import TestCase from webauthn.helpers import base64url_to_bytes, bytes_to_base64url -from webauthn.helpers.exceptions import InvalidClientDataJSONStructure +from webauthn.helpers.exceptions import InvalidJSONStructure from webauthn.helpers.parse_client_data_json import parse_client_data_json from webauthn.helpers.structs import TokenBindingStatus @@ -28,7 +28,7 @@ def test_raises_exception_on_bad_json(self): client_data_bytes = b"not_real-JS0N" with self.assertRaisesRegex( - InvalidClientDataJSONStructure, + InvalidJSONStructure, "Unable to decode", ): parse_client_data_json(client_data_bytes) @@ -43,7 +43,7 @@ def test_requires_type(self): client_data_bytes = client_data_str.encode("utf-8") with self.assertRaisesRegex( - InvalidClientDataJSONStructure, + InvalidJSONStructure, 'missing required property "type"', ): parse_client_data_json(client_data_bytes) @@ -55,7 +55,7 @@ def test_requires_challenge(self): client_data_bytes = client_data_str.encode("utf-8") with self.assertRaisesRegex( - InvalidClientDataJSONStructure, + InvalidJSONStructure, 'missing required property "challenge"', ): parse_client_data_json(client_data_bytes) @@ -70,7 +70,7 @@ def test_requires_origin(self): client_data_bytes = client_data_str.encode("utf-8") with self.assertRaisesRegex( - InvalidClientDataJSONStructure, + InvalidJSONStructure, 'missing required property "origin"', ): parse_client_data_json(client_data_bytes) @@ -131,7 +131,7 @@ def test_require_status_in_token_binding_when_present(self): ) client_data_bytes = client_data_str.encode("utf-8") - with self.assertRaises(InvalidClientDataJSONStructure) as context: + with self.assertRaises(InvalidJSONStructure) as context: parse_client_data_json(client_data_bytes) assert 'missing required property "status"' in str(context.exception) diff --git a/tests/test_verify_registration_response.py b/tests/test_verify_registration_response.py index f030500..0b49d33 100644 --- a/tests/test_verify_registration_response.py +++ b/tests/test_verify_registration_response.py @@ -93,7 +93,7 @@ def test_raises_exception_on_unsupported_attestation_type(self) -> None: rp_id = "localhost" expected_origin = "http://localhost:5000" - with self.assertRaises(ValidationError): + with self.assertRaises(InvalidRegistrationResponse): verify_registration_response( credential=credential, expected_challenge=challenge, From d58f95e0cc635543ad7bc9a0ffd128bca1cf16db Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 12:47:18 -0800 Subject: [PATCH 20/47] Parse registration responses --- .../parse_registration_credential_json.py | 88 +++++++++++++++++-- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/webauthn/helpers/parse_registration_credential_json.py b/webauthn/helpers/parse_registration_credential_json.py index dd65ec1..cc7922d 100644 --- a/webauthn/helpers/parse_registration_credential_json.py +++ b/webauthn/helpers/parse_registration_credential_json.py @@ -1,8 +1,16 @@ import json -from typing import Callable, Union +from json.decoder import JSONDecodeError +from typing import Union, Optional, List -from .exceptions import InvalidRegistrationResponse -from .structs import RegistrationCredential +from .base64url_to_bytes import base64url_to_bytes +from .exceptions import InvalidRegistrationResponse, InvalidJSONStructure +from .structs import ( + AuthenticatorAttachment, + AuthenticatorAttestationResponse, + AuthenticatorTransport, + PublicKeyCredentialType, + RegistrationCredential, +) def parse_registration_credential_json(json_val: Union[str, dict]) -> RegistrationCredential: @@ -10,11 +18,79 @@ def parse_registration_credential_json(json_val: Union[str, dict]) -> Registrati Parse a JSON form of a registration credential, as either a stringified JSON object or a plain dict, into an instance of RegistrationCredential """ - if isinstance(json_val, dict): - json_val = json.dumps(json_val) + if isinstance(json_val, str): + try: + json_val = json.loads(json_val) + except JSONDecodeError: + raise InvalidJSONStructure("Unable to decode credential as JSON") + + # Appease mypy + assert isinstance(json_val, dict) + + cred_id = json_val.get("id") + if not isinstance(cred_id, str): + raise InvalidJSONStructure("Credential missing required id") + + cred_raw_id = json_val.get("rawId") + if not isinstance(cred_raw_id, str): + raise InvalidJSONStructure("Credential missing required rawId") + + cred_response = json_val.get("response") + if not isinstance(cred_response, dict): + raise InvalidJSONStructure("Credential missing required response") + + response_client_data_json = cred_response.get("clientDataJSON") + if not isinstance(response_client_data_json, str): + raise InvalidJSONStructure("Credential response missing required clientDataJSON") + + response_attestation_object = cred_response.get("attestationObject") + if not isinstance(response_attestation_object, str): + raise InvalidJSONStructure("Credential response missing required attestationObject") + + transports: Optional[List[AuthenticatorTransport]] = None + response_transports = cred_response.get("transports") + if isinstance(response_transports, list): + transports = [] + for val in response_transports: + try: + transport_enum = AuthenticatorTransport(val) + transports.append(transport_enum) + except ValueError: + pass + + print(f"transports: {transports}") + + cred_authenticator_attachment = json_val.get("authenticatorAttachment") + if isinstance(cred_authenticator_attachment, str): + try: + cred_authenticator_attachment = AuthenticatorAttachment(cred_authenticator_attachment) + except ValueError as cred_attachment_exc: + raise InvalidJSONStructure( + "Credential has unexpected authenticator attachment" + ) from cred_attachment_exc + else: + cred_authenticator_attachment = None + + cred_type = json_val.get("type") + try: + # Simply try to get the single matching Enum. We'll set the literal value below assuming + # the code can get past here (this is basically a mypy optimization) + PublicKeyCredentialType(cred_type) + except ValueError as cred_type_exc: + raise InvalidJSONStructure("Credential had unexpected type") from cred_type_exc try: - registration_credential = RegistrationCredential() + registration_credential = RegistrationCredential( + id=cred_id, + raw_id=base64url_to_bytes(cred_raw_id), + response=AuthenticatorAttestationResponse( + client_data_json=base64url_to_bytes(response_client_data_json), + attestation_object=base64url_to_bytes(response_attestation_object), + transports=transports, + ), + authenticator_attachment=cred_authenticator_attachment, + type=PublicKeyCredentialType.PUBLIC_KEY, + ) except Exception as exc: raise InvalidRegistrationResponse( "Unable to parse a registration credential from JSON data" From ad5be88bc03d7cfa5602695e80790de58534135e Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 13:34:33 -0800 Subject: [PATCH 21/47] Refactor options_to_json --- tests/test_options_to_json.py | 92 ++++++++++++++++------- webauthn/helpers/options_to_json.py | 110 +++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 29 deletions(-) diff --git a/tests/test_options_to_json.py b/tests/test_options_to_json.py index 65c99e4..2177353 100644 --- a/tests/test_options_to_json.py +++ b/tests/test_options_to_json.py @@ -10,12 +10,15 @@ AuthenticatorTransport, PublicKeyCredentialDescriptor, ResidentKeyRequirement, + UserVerificationRequirement, ) -from webauthn import generate_registration_options +from webauthn import generate_registration_options, generate_authentication_options class TestWebAuthnOptionsToJSON(TestCase): - def test_converts_options_to_JSON(self) -> None: + maxDiff = None + + def test_converts_registration_options_to_JSON(self) -> None: options = generate_registration_options( rp_id="example.com", rp_name="Example Co", @@ -37,25 +40,28 @@ def test_converts_options_to_JSON(self) -> None: output = options_to_json(options) - assert json.loads(output) == { - "rp": {"name": "Example Co", "id": "example.com"}, - "user": { - "id": "QUJBVjZRV1BCRVk5V09UT0ExQTQ", - "name": "lee", - "displayName": "Lee", - }, - "challenge": "MTIzNDU2Nzg5MA", - "pubKeyCredParams": [{"type": "public-key", "alg": -36}], - "timeout": 120000, - "excludeCredentials": [{"type": "public-key", "id": "MTIzNDU2Nzg5MA"}], - "authenticatorSelection": { - "authenticatorAttachment": "platform", - "residentKey": "required", - "requireResidentKey": True, - "userVerification": "preferred", + self.assertEqual( + json.loads(output), + { + "rp": {"name": "Example Co", "id": "example.com"}, + "user": { + "id": "QUJBVjZRV1BCRVk5V09UT0ExQTQ", + "name": "lee", + "displayName": "Lee", + }, + "challenge": "MTIzNDU2Nzg5MA", + "pubKeyCredParams": [{"type": "public-key", "alg": -36}], + "timeout": 120000, + "excludeCredentials": [{"type": "public-key", "id": "MTIzNDU2Nzg5MA"}], + "authenticatorSelection": { + "authenticatorAttachment": "platform", + "residentKey": "required", + "requireResidentKey": True, + "userVerification": "preferred", + }, + "attestation": "direct", }, - "attestation": "direct", - } + ) def test_includes_optional_value_when_set(self) -> None: options = generate_registration_options( @@ -73,10 +79,44 @@ def test_includes_optional_value_when_set(self) -> None: output = options_to_json(options) - assert json.loads(output)["excludeCredentials"] == [ + self.assertEqual( + json.loads(output)["excludeCredentials"], + [ + { + "id": "MTIzNDU2Nzg5MA", + "transports": ["usb"], + "type": "public-key", + } + ], + ) + + def test_converts_authentication_options_to_JSON(self) -> None: + options = generate_authentication_options( + rp_id="example.com", + challenge=b"1234567890", + allow_credentials=[ + PublicKeyCredentialDescriptor(id=b"1234567890"), + ], + timeout=120000, + user_verification=UserVerificationRequirement.DISCOURAGED, + ) + + output = options_to_json(options) + + self.assertEqual( + json.loads(output), { - "id": "MTIzNDU2Nzg5MA", - "transports": ["usb"], - "type": "public-key", - } - ] + "rpId": "example.com", + "challenge": "MTIzNDU2Nzg5MA", + "allowCredentials": [{"type": "public-key", "id": "MTIzNDU2Nzg5MA"}], + "timeout": 120000, + "userVerification": "discouraged", + }, + ) + + def test_raises_on_bad_input(self) -> None: + class FooClass: + pass + + with self.assertRaisesRegex(TypeError, "not instance"): + options_to_json(FooClass()) # type: ignore diff --git a/webauthn/helpers/options_to_json.py b/webauthn/helpers/options_to_json.py index 7614b81..f1a6777 100644 --- a/webauthn/helpers/options_to_json.py +++ b/webauthn/helpers/options_to_json.py @@ -1,9 +1,11 @@ -from typing import Union +import json +from typing import Union, Dict, Any from .structs import ( PublicKeyCredentialCreationOptions, PublicKeyCredentialRequestOptions, ) +from .bytes_to_base64url import bytes_to_base64url def options_to_json( @@ -15,6 +17,108 @@ def options_to_json( """ Prepare options for transmission to the front end as JSON """ - # TODO: Write this + if isinstance(options, PublicKeyCredentialCreationOptions): + _rp = {"name": options.rp.name} + if options.rp.id: + _rp["id"] = options.rp.id - return {} + _user = { + "id": bytes_to_base64url(options.user.id), + "name": options.user.name, + "displayName": options.user.display_name, + } + + reg_to_return: Dict[str, Any] = { + "rp": _rp, + "user": _user, + "challenge": bytes_to_base64url(options.challenge), + "pubKeyCredParams": [ + {"type": param.type, "alg": param.alg} for param in options.pub_key_cred_params + ], + } + + # Begin handling optional values + + if options.timeout is not None: + reg_to_return["timeout"] = options.timeout + + if options.exclude_credentials is not None: + _excluded = options.exclude_credentials + json_excluded = [] + + for cred in _excluded: + json_excluded_cred: Dict[str, Any] = { + "id": bytes_to_base64url(cred.id), + "type": cred.type.value, + } + + if cred.transports: + json_excluded_cred["transports"] = [ + transport.value for transport in cred.transports + ] + + json_excluded.append(json_excluded_cred) + + reg_to_return["excludeCredentials"] = json_excluded + + if options.authenticator_selection is not None: + _selection = options.authenticator_selection + json_selection: Dict[str, Any] = {} + + if _selection.authenticator_attachment is not None: + json_selection[ + "authenticatorAttachment" + ] = _selection.authenticator_attachment.value + + if _selection.resident_key is not None: + json_selection["residentKey"] = _selection.resident_key.value + + if _selection.require_resident_key is not None: + json_selection["requireResidentKey"] = _selection.require_resident_key + + if _selection.user_verification is not None: + json_selection["userVerification"] = _selection.user_verification.value + + reg_to_return["authenticatorSelection"] = json_selection + + if options.attestation is not None: + reg_to_return["attestation"] = options.attestation.value + + return json.dumps(reg_to_return) + + if isinstance(options, PublicKeyCredentialRequestOptions): + auth_to_return: Dict[str, Any] = {"challenge": bytes_to_base64url(options.challenge)} + + if options.timeout is not None: + auth_to_return["timeout"] = options.timeout + + if options.rp_id is not None: + auth_to_return["rpId"] = options.rp_id + + if options.allow_credentials is not None: + _allowed = options.allow_credentials + json_allowed = [] + + for cred in _allowed: + json_allowed_cred: Dict[str, Any] = { + "id": bytes_to_base64url(cred.id), + "type": cred.type.value, + } + + if cred.transports: + json_allowed_cred["transports"] = [ + transport.value for transport in cred.transports + ] + + json_allowed.append(json_allowed_cred) + + auth_to_return["allowCredentials"] = json_allowed + + if options.user_verification: + auth_to_return["userVerification"] = options.user_verification.value + + return json.dumps(auth_to_return) + + raise TypeError( + "Options was not instance of PublicKeyCredentialCreationOptions or PublicKeyCredentialRequestOptions" + ) From 8ef29d3d5de0b935ffe4d9b08b15e0e19d02180b Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 14:03:03 -0800 Subject: [PATCH 22/47] Create byteslike_to_bytes for bytes shenanigans --- webauthn/helpers/__init__.py | 2 ++ webauthn/helpers/byteslike_to_bytes.py | 11 +++++++++++ 2 files changed, 13 insertions(+) create mode 100644 webauthn/helpers/byteslike_to_bytes.py diff --git a/webauthn/helpers/__init__.py b/webauthn/helpers/__init__.py index 8e28f71..8f963e5 100644 --- a/webauthn/helpers/__init__.py +++ b/webauthn/helpers/__init__.py @@ -1,6 +1,7 @@ from .aaguid_to_string import aaguid_to_string from .base64url_to_bytes import base64url_to_bytes from .bytes_to_base64url import bytes_to_base64url +from .byteslike_to_bytes import byteslike_to_bytes from .decode_credential_public_key import decode_credential_public_key from .decoded_public_key_to_cryptography import decoded_public_key_to_cryptography from .encode_cbor import encode_cbor @@ -24,6 +25,7 @@ "aaguid_to_string", "base64url_to_bytes", "bytes_to_base64url", + "byteslike_to_bytes", "decode_credential_public_key", "decoded_public_key_to_cryptography", "encode_cbor", diff --git a/webauthn/helpers/byteslike_to_bytes.py b/webauthn/helpers/byteslike_to_bytes.py new file mode 100644 index 0000000..7016ed0 --- /dev/null +++ b/webauthn/helpers/byteslike_to_bytes.py @@ -0,0 +1,11 @@ +from typing import Union + + +def byteslike_to_bytes(val: Union[bytes, memoryview]) -> bytes: + """ + Massage bytes subclasses into bytes for ease of concatenation, comparison, etc... + """ + if isinstance(val, memoryview): + val = val.tobytes() + + return bytes(val) From 0bb12019180d2d16fcfc6bb1797cd04f93d5891b Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 14:11:24 -0800 Subject: [PATCH 23/47] Handle things like memoryviews --- .../verify_authentication_response.py | 15 ++++++--- webauthn/helpers/parse_authenticator_data.py | 5 +++ webauthn/helpers/parse_client_data_json.py | 4 +++ .../verify_registration_response.py | 32 +++++++++++-------- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/webauthn/authentication/verify_authentication_response.py b/webauthn/authentication/verify_authentication_response.py index ec510c3..6674f2e 100644 --- a/webauthn/authentication/verify_authentication_response.py +++ b/webauthn/authentication/verify_authentication_response.py @@ -6,6 +6,7 @@ from webauthn.helpers import ( bytes_to_base64url, + byteslike_to_bytes, decode_credential_public_key, decoded_public_key_to_cryptography, parse_authenticator_data, @@ -91,7 +92,11 @@ def verify_authentication_response( response = credential.response - client_data = parse_client_data_json(response.client_data_json) + client_data_bytes = byteslike_to_bytes(response.client_data_json) + authenticator_data_bytes = byteslike_to_bytes(response.authenticator_data) + signature_bytes = byteslike_to_bytes(response.signature) + + client_data = parse_client_data_json(client_data_bytes) if client_data.type != ClientDataType.WEBAUTHN_GET: raise InvalidAuthenticationResponse( @@ -121,7 +126,7 @@ def verify_authentication_response( f'Unexpected token_binding status of "{status}", expected one of "{",".join(expected_token_binding_statuses)}"' ) - auth_data = parse_authenticator_data(response.authenticator_data) # TODO: Issue #173 + auth_data = parse_authenticator_data(authenticator_data_bytes) # Generate a hash of the expected RP ID for comparison expected_rp_id_hash = hashlib.sha256() @@ -150,10 +155,10 @@ def verify_authentication_response( ) client_data_hash = hashlib.sha256() - client_data_hash.update(response.client_data_json) + client_data_hash.update(client_data_bytes) client_data_hash_bytes = client_data_hash.digest() - signature_base = response.authenticator_data + client_data_hash_bytes + signature_base = authenticator_data_bytes + client_data_hash_bytes try: decoded_public_key = decode_credential_public_key(credential_public_key) @@ -162,7 +167,7 @@ def verify_authentication_response( verify_signature( public_key=crypto_public_key, signature_alg=decoded_public_key.alg, - signature=response.signature, + signature=signature_bytes, data=signature_base, ) except InvalidSignature: diff --git a/webauthn/helpers/parse_authenticator_data.py b/webauthn/helpers/parse_authenticator_data.py index 6bb5eb1..82742de 100644 --- a/webauthn/helpers/parse_authenticator_data.py +++ b/webauthn/helpers/parse_authenticator_data.py @@ -1,3 +1,6 @@ +from typing import Union + +from .byteslike_to_bytes import byteslike_to_bytes from .exceptions import InvalidAuthenticatorDataStructure from .structs import AttestedCredentialData, AuthenticatorData, AuthenticatorDataFlags from .parse_cbor import parse_cbor @@ -8,6 +11,8 @@ def parse_authenticator_data(val: bytes) -> AuthenticatorData: """ Turn `response.attestationObject.authData` into structured data """ + val = byteslike_to_bytes(val) + # Don't bother parsing if there aren't enough bytes for at least: # - rpIdHash (32 bytes) # - flags (1 byte) diff --git a/webauthn/helpers/parse_client_data_json.py b/webauthn/helpers/parse_client_data_json.py index a713788..e5eb187 100644 --- a/webauthn/helpers/parse_client_data_json.py +++ b/webauthn/helpers/parse_client_data_json.py @@ -1,7 +1,9 @@ import json from json.decoder import JSONDecodeError +from typing import Union from .base64url_to_bytes import base64url_to_bytes +from .byteslike_to_bytes import byteslike_to_bytes from .exceptions import InvalidJSONStructure from .structs import CollectedClientData, TokenBinding @@ -10,6 +12,8 @@ def parse_client_data_json(val: bytes) -> CollectedClientData: """ Break apart `response.clientDataJSON` buffer into structured data """ + val = byteslike_to_bytes(val) + try: json_dict = json.loads(val) except JSONDecodeError: diff --git a/webauthn/registration/verify_registration_response.py b/webauthn/registration/verify_registration_response.py index 03723e6..014327b 100644 --- a/webauthn/registration/verify_registration_response.py +++ b/webauthn/registration/verify_registration_response.py @@ -5,6 +5,7 @@ from webauthn.helpers import ( aaguid_to_string, bytes_to_base64url, + byteslike_to_bytes, decode_credential_public_key, parse_attestation_object, parse_client_data_json, @@ -114,7 +115,10 @@ def verify_registration_response( response = credential.response - client_data = parse_client_data_json(response.client_data_json) + client_data_bytes = byteslike_to_bytes(response.client_data_json) + attestation_object_bytes = byteslike_to_bytes(response.attestation_object) + + client_data = parse_client_data_json(client_data_bytes) if client_data.type != ClientDataType.WEBAUTHN_CREATE: raise InvalidRegistrationResponse( @@ -144,7 +148,7 @@ def verify_registration_response( f'Unexpected token_binding status of "{status}", expected one of "{",".join(expected_token_binding_statuses)}"' ) - attestation_object = parse_attestation_object(response.attestation_object) # TODO: Issue #173 + attestation_object = parse_attestation_object(attestation_object_bytes) auth_data = attestation_object.auth_data @@ -215,7 +219,7 @@ def verify_registration_response( elif attestation_object.fmt == AttestationFormat.FIDO_U2F: verified = verify_fido_u2f( attestation_statement=attestation_object.att_stmt, - client_data_json=response.client_data_json, + client_data_json=client_data_bytes, rp_id_hash=auth_data.rp_id_hash, credential_id=attested_credential_data.credential_id, credential_public_key=attested_credential_data.credential_public_key, @@ -225,39 +229,39 @@ def verify_registration_response( elif attestation_object.fmt == AttestationFormat.PACKED: verified = verify_packed( attestation_statement=attestation_object.att_stmt, - attestation_object=response.attestation_object, - client_data_json=response.client_data_json, + attestation_object=attestation_object_bytes, + client_data_json=client_data_bytes, credential_public_key=attested_credential_data.credential_public_key, pem_root_certs_bytes=pem_root_certs_bytes, ) elif attestation_object.fmt == AttestationFormat.TPM: verified = verify_tpm( attestation_statement=attestation_object.att_stmt, - attestation_object=response.attestation_object, - client_data_json=response.client_data_json, + attestation_object=attestation_object_bytes, + client_data_json=client_data_bytes, credential_public_key=attested_credential_data.credential_public_key, pem_root_certs_bytes=pem_root_certs_bytes, ) elif attestation_object.fmt == AttestationFormat.APPLE: verified = verify_apple( attestation_statement=attestation_object.att_stmt, - attestation_object=response.attestation_object, - client_data_json=response.client_data_json, + attestation_object=attestation_object_bytes, + client_data_json=client_data_bytes, credential_public_key=attested_credential_data.credential_public_key, pem_root_certs_bytes=pem_root_certs_bytes, ) elif attestation_object.fmt == AttestationFormat.ANDROID_SAFETYNET: verified = verify_android_safetynet( attestation_statement=attestation_object.att_stmt, - attestation_object=response.attestation_object, - client_data_json=response.client_data_json, + attestation_object=attestation_object_bytes, + client_data_json=client_data_bytes, pem_root_certs_bytes=pem_root_certs_bytes, ) elif attestation_object.fmt == AttestationFormat.ANDROID_KEY: verified = verify_android_key( attestation_statement=attestation_object.att_stmt, - attestation_object=response.attestation_object, - client_data_json=response.client_data_json, + attestation_object=attestation_object_bytes, + client_data_json=client_data_bytes, credential_public_key=attested_credential_data.credential_public_key, pem_root_certs_bytes=pem_root_certs_bytes, ) @@ -282,7 +286,7 @@ def verify_registration_response( fmt=attestation_object.fmt, credential_type=credential.type, user_verified=auth_data.flags.uv, - attestation_object=response.attestation_object, + attestation_object=attestation_object_bytes, credential_device_type=parsed_backup_flags.credential_device_type, credential_backed_up=parsed_backup_flags.credential_backed_up, ) From 5c86c3d13c571d42d3a821b23151fbb82a079e67 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 14:11:29 -0800 Subject: [PATCH 24/47] Remove print statement --- webauthn/helpers/parse_registration_credential_json.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/webauthn/helpers/parse_registration_credential_json.py b/webauthn/helpers/parse_registration_credential_json.py index cc7922d..aec0a22 100644 --- a/webauthn/helpers/parse_registration_credential_json.py +++ b/webauthn/helpers/parse_registration_credential_json.py @@ -58,8 +58,6 @@ def parse_registration_credential_json(json_val: Union[str, dict]) -> Registrati except ValueError: pass - print(f"transports: {transports}") - cred_authenticator_attachment = json_val.get("authenticatorAttachment") if isinstance(cred_authenticator_attachment, str): try: From dbb22a9ea838acec2a6b81221595085384bfb215 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 14:11:42 -0800 Subject: [PATCH 25/47] Remove Pydantic from examples --- examples/authentication.py | 6 +----- examples/registration.py | 10 +++------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/examples/authentication.py b/examples/authentication.py index d555ae7..c25837d 100644 --- a/examples/authentication.py +++ b/examples/authentication.py @@ -5,7 +5,6 @@ base64url_to_bytes, ) from webauthn.helpers.structs import ( - PYDANTIC_V2, PublicKeyCredentialDescriptor, UserVerificationRequirement, ) @@ -66,8 +65,5 @@ require_user_verification=True, ) print("\n[Authentication Verification]") -if PYDANTIC_V2: - print(authentication_verification.model_dump_json(indent=2)) -else: - print(authentication_verification.json(indent=2)) +print(authentication_verification) assert authentication_verification.new_sign_count == 1 diff --git a/examples/registration.py b/examples/registration.py index bfe1f8a..a239a02 100644 --- a/examples/registration.py +++ b/examples/registration.py @@ -6,7 +6,6 @@ ) from webauthn.helpers.cose import COSEAlgorithmIdentifier from webauthn.helpers.structs import ( - PYDANTIC_V2, AttestationConveyancePreference, AuthenticatorAttachment, AuthenticatorSelectionCriteria, @@ -63,11 +62,11 @@ "response": { "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVkBZ0mWDeWIDoxodDQXD2R2YFuP5K65ooYyx5lc87qDHZdjRQAAAAAAAAAAAAAAAAAAAAAAAAAAACBmggo_UlC8p2tiPVtNQ8nZ5NSxst4WS_5fnElA2viTq6QBAwM5AQAgWQEA31dtHqc70D_h7XHQ6V_nBs3Tscu91kBL7FOw56_VFiaKYRH6Z4KLr4J0S12hFJ_3fBxpKfxyMfK66ZMeAVbOl_wemY4S5Xs4yHSWy21Xm_dgWhLJjZ9R1tjfV49kDPHB_ssdvP7wo3_NmoUPYMgK-edgZ_ehttp_I6hUUCnVaTvn_m76b2j9yEPReSwl-wlGsabYG6INUhTuhSOqG-UpVVQdNJVV7GmIPHCA2cQpJBDZBohT4MBGme_feUgm4sgqVCWzKk6CzIKIz5AIVnspLbu05SulAVnSTB3NxTwCLNJR_9v9oSkvphiNbmQBVQH1tV_psyi9HM1Jtj9VJVKMeyFDAQAB", "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiQ2VUV29nbWcwY2NodWlZdUZydjhEWFhkTVpTSVFSVlpKT2dhX3hheVZWRWNCajBDdzN5NzN5aEQ0RmtHU2UtUnJQNmhQSkpBSW0zTFZpZW40aFhFTGciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", - "transports": ["internal"] + "transports": ["internal"], }, "type": "public-key", "clientExtensionResults": {}, - "authenticatorAttachment": "platform" + "authenticatorAttachment": "platform", }, expected_challenge=base64url_to_bytes( "CeTWogmg0cchuiYuFrv8DXXdMZSIQRVZJOga_xayVVEcBj0Cw3y73yhD4FkGSe-RrP6hPJJAIm3LVien4hXELg" @@ -78,10 +77,7 @@ ) print("\n[Registration Verification - None]") -if PYDANTIC_V2: - print(registration_verification.model_dump_json(indent=2)) -else: - print(registration_verification.json(indent=2)) +print(registration_verification) assert registration_verification.credential_id == base64url_to_bytes( "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s" ) From d403696956c3f587cad591c0359f4b1b146d751c Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 14:28:43 -0800 Subject: [PATCH 26/47] Allow user.id and user_handle to be str --- tests/test_bytes_subclass_support.py | 4 ++-- webauthn/helpers/options_to_json.py | 7 +++++-- webauthn/helpers/structs.py | 11 +++-------- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/test_bytes_subclass_support.py b/tests/test_bytes_subclass_support.py index 0acf267..b2f841f 100644 --- a/tests/test_bytes_subclass_support.py +++ b/tests/test_bytes_subclass_support.py @@ -99,7 +99,7 @@ def test_supports_strings_for_bytes(self) -> None: authenticator_data=bytes(), client_data_json=bytes(), signature=bytes(), - user_handle="some_user_handle_string", # type: ignore + user_handle="some_user_handle_string", ) - self.assertEqual(response.user_handle, b"some_user_handle_string") + self.assertEqual(response.user_handle, "some_user_handle_string") diff --git a/webauthn/helpers/options_to_json.py b/webauthn/helpers/options_to_json.py index f1a6777..cd11be2 100644 --- a/webauthn/helpers/options_to_json.py +++ b/webauthn/helpers/options_to_json.py @@ -22,11 +22,14 @@ def options_to_json( if options.rp.id: _rp["id"] = options.rp.id - _user = { - "id": bytes_to_base64url(options.user.id), + _user: Dict[str, Any] = { "name": options.user.name, "displayName": options.user.display_name, } + if isinstance(options.user.id, bytes): + _user["id"] = bytes_to_base64url(options.user.id) + else: + _user["id"] = options.user.id reg_to_return: Dict[str, Any] = { "rp": _rp, diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index a129e04..0b79987 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -1,13 +1,8 @@ from enum import Enum from dataclasses import dataclass, field -from typing import Callable, List, Literal, Optional, Any, Dict +from typing import List, Literal, Optional, Union - -from .base64url_to_bytes import base64url_to_bytes -from .bytes_to_base64url import bytes_to_base64url from .cose import COSEAlgorithmIdentifier -from .json_loads_base64url_to_bytes import json_loads_base64url_to_bytes -from .snake_case_to_camel_case import snake_case_to_camel_case ################ @@ -199,7 +194,7 @@ class PublicKeyCredentialUserEntity: https://www.w3.org/TR/webauthn-2/#dictdef-publickeycredentialuserentity """ - id: bytes + id: Union[bytes, str] name: str display_name: str @@ -495,7 +490,7 @@ class AuthenticatorAssertionResponse: client_data_json: bytes authenticator_data: bytes signature: bytes - user_handle: Optional[bytes] = None + user_handle: Optional[Union[bytes, str]] = None @dataclass From 48c94b135ce5ce2fef02afea030554c9e68b0c69 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 14:30:12 -0800 Subject: [PATCH 27/47] Don't assume base64url on user_handle --- webauthn/helpers/parse_authentication_credential_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index af6f48b..dcef075 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -52,7 +52,7 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti response_user_handle = cred_response.get("userHandle") if isinstance(response_user_handle, str): - response_user_handle = base64url_to_bytes(response_user_handle) + response_user_handle = response_user_handle else: response_user_handle = None From 9db31a681bee80f65afcf34a31051c5f5eacea25 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 14:33:18 -0800 Subject: [PATCH 28/47] Update token binding test --- ...t_verify_registration_response_fido_u2f.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/tests/test_verify_registration_response_fido_u2f.py b/tests/test_verify_registration_response_fido_u2f.py index 2b078e0..c2db7ee 100644 --- a/tests/test_verify_registration_response_fido_u2f.py +++ b/tests/test_verify_registration_response_fido_u2f.py @@ -3,6 +3,7 @@ from webauthn.helpers import base64url_to_bytes from webauthn.helpers.structs import AttestationFormat from webauthn import verify_registration_response +from webauthn.helpers.exceptions import InvalidRegistrationResponse class TestVerifyRegistrationResponseFIDOU2F(TestCase): @@ -81,17 +82,15 @@ def test_verify_attestation_with_unsupported_token_binding_status(self) -> None: rp_id = "duo.test" expected_origin = "https://api-duo1.duo.test" - verification = verify_registration_response( - credential=credential, - expected_challenge=challenge, - expected_origin=expected_origin, - expected_rp_id=rp_id, - ) - - assert verification.fmt == AttestationFormat.FIDO_U2F - assert verification.credential_id == base64url_to_bytes( - "JeC3qgQjIVysq88GxhGUYyDl4oZeW8mLWd7luJWQvnrm-wxGZ5mzf2bBCaUDq7D2qr4aQezvzfoFIF880ciAsQ", - ) + with self.assertRaisesRegex( + InvalidRegistrationResponse, "Unexpected token_binding status" + ): + verify_registration_response( + credential=credential, + expected_challenge=challenge, + expected_origin=expected_origin, + expected_rp_id=rp_id, + ) def test_verify_attestation_with_unsupported_token_binding(self) -> None: # Credential contains `clientDataJSON: { tokenBinding: "unused" }` From 05ac8875c176b404b88bdbf6605132aa1f44d2eb Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 14:42:00 -0800 Subject: [PATCH 29/47] Refactor check for empty attStmt on None --- .../verify_registration_response.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/webauthn/registration/verify_registration_response.py b/webauthn/registration/verify_registration_response.py index 014327b..65a0dc7 100644 --- a/webauthn/registration/verify_registration_response.py +++ b/webauthn/registration/verify_registration_response.py @@ -1,5 +1,5 @@ import hashlib -from dataclasses import dataclass +from dataclasses import dataclass, asdict from typing import List, Mapping, Optional, Union from webauthn.helpers import ( @@ -200,16 +200,12 @@ def verify_registration_response( pem_root_certs_bytes.extend(custom_certs) if attestation_object.fmt == AttestationFormat.NONE: - # A "none" attestation should not contain _anything_ in its attestation - # statement - # TODO: Rewrite this - # if PYDANTIC_V2: - # num_att_stmt_fields_set = len(attestation_object.att_stmt.model_fields_set) # type: ignore[attr-defined] - # else: - # num_att_stmt_fields_set = len(attestation_object.att_stmt.__fields_set__) - num_att_stmt_fields_set = 0 - - if num_att_stmt_fields_set > 0: + # A "none" attestation should not contain _anything_ in its attestation statement + any_att_stmt_fields_set = any( + [field is not None for field in asdict(attestation_object.att_stmt).values()] + ) + + if any_att_stmt_fields_set: raise InvalidRegistrationResponse( "None attestation had unexpected attestation statement" ) From d5ea72b91e92fe4b1b00266bd23beeef4bb0809f Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 14:50:24 -0800 Subject: [PATCH 30/47] Refactor SafetyNet JWS parsing --- .../registration/formats/android_safetynet.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/webauthn/registration/formats/android_safetynet.py b/webauthn/registration/formats/android_safetynet.py index 2dbc3b7..cef6bdf 100644 --- a/webauthn/registration/formats/android_safetynet.py +++ b/webauthn/registration/formats/android_safetynet.py @@ -1,6 +1,7 @@ import base64 from dataclasses import dataclass import hashlib +import json from typing import List from cryptography import x509 @@ -84,13 +85,22 @@ def verify_android_safetynet( if len(jws_parts) != 3: raise InvalidRegistrationResponse("Response JWS did not have three parts (SafetyNet)") - # TODO: Rewrite this - # if PYDANTIC_V2: - # header = SafetyNetJWSHeader.model_validate_json(base64url_to_bytes(jws_parts[0])) # type: ignore[attr-defined] - # payload = SafetyNetJWSPayload.model_validate_json(base64url_to_bytes(jws_parts[1])) # type: ignore[attr-defined] - # else: - # header = SafetyNetJWSHeader.parse_raw(base64url_to_bytes(jws_parts[0])) - # payload = SafetyNetJWSPayload.parse_raw(base64url_to_bytes(jws_parts[1])) + header_json = json.loads(base64url_to_bytes(jws_parts[0])) + payload_json = json.loads(base64url_to_bytes(jws_parts[1])) + + header = SafetyNetJWSHeader( + alg=header_json.get("alg", ""), + x5c=header_json.get("x5c", []), + ) + payload = SafetyNetJWSPayload( + nonce=payload_json.get("nonce", ""), + timestamp_ms=payload_json.get("timestampMs", 0), + apk_package_name=payload_json.get("apkPackageName", ""), + apk_digest_sha256=payload_json.get("apkDigestSha256", ""), + cts_profile_match=payload_json.get("ctsProfileMatch", False), + apk_certificate_digest_sha256=payload_json.get("apkCertificateDigestSha256", []), + basic_integrity=payload_json.get("basicIntegrity", False), + ) signature_bytes_str: str = jws_parts[2] From c458d9297a92d45cbe555c19d2e0735e572e8bbe Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 5 Jan 2024 14:55:04 -0800 Subject: [PATCH 31/47] Remove Pydantic checks from CI --- .github/workflows/build_and_test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index afed84b..159ebc7 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -16,7 +16,6 @@ jobs: strategy: matrix: python-version: ['3.8', '3.9', '3.10', '3.11'] - pydantic-version: ['>=1.0,<2.0', '>=2.0,<3.0'] steps: - uses: actions/checkout@v3 @@ -28,7 +27,6 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install 'pydantic${{ matrix.pydantic-version }}' - name: Test with unittest run: | python -m unittest From 3aa5381b0040634c945580e170f375183d2b2b29 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 09:50:14 -0800 Subject: [PATCH 32/47] Replace fancy single-quote --- webauthn/helpers/generate_user_handle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webauthn/helpers/generate_user_handle.py b/webauthn/helpers/generate_user_handle.py index 08bad5b..5978cd2 100644 --- a/webauthn/helpers/generate_user_handle.py +++ b/webauthn/helpers/generate_user_handle.py @@ -12,6 +12,6 @@ def generate_user_handle() -> bytes: See https://www.w3.org/TR/webauthn-2/#sctn-user-handle-privacy: "It is RECOMMENDED to let the user handle be 64 random bytes, and store this value - in the user’s account." + in the user's account." """ return secrets.token_bytes(64) From 0f0ea3a8527feef487e5244b2a9f3e809e4b47f6 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 11:46:06 -0800 Subject: [PATCH 33/47] Properly raise on non-dict JSON parse --- webauthn/helpers/parse_authentication_credential_json.py | 4 ++-- webauthn/helpers/parse_registration_credential_json.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index dcef075..12e2086 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -23,8 +23,8 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti except JSONDecodeError: raise InvalidJSONStructure("Unable to decode credential as JSON") - # Appease mypy - assert isinstance(json_val, dict) + if not isinstance(json_val, dict): + raise InvalidJSONStructure("Credential is not a JSON object") cred_id = json_val.get("id") if not isinstance(cred_id, str): diff --git a/webauthn/helpers/parse_registration_credential_json.py b/webauthn/helpers/parse_registration_credential_json.py index aec0a22..47b8a8c 100644 --- a/webauthn/helpers/parse_registration_credential_json.py +++ b/webauthn/helpers/parse_registration_credential_json.py @@ -24,8 +24,8 @@ def parse_registration_credential_json(json_val: Union[str, dict]) -> Registrati except JSONDecodeError: raise InvalidJSONStructure("Unable to decode credential as JSON") - # Appease mypy - assert isinstance(json_val, dict) + if not isinstance(json_val, dict): + raise InvalidJSONStructure("Credential is not a JSON object") cred_id = json_val.get("id") if not isinstance(cred_id, str): From 21433cb1226a2ae3eb9e3555d8cceb270b7fb3a5 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 11:46:37 -0800 Subject: [PATCH 34/47] Raise credential type verification up --- .../parse_authentication_credential_json.py | 16 ++++++++-------- .../parse_registration_credential_json.py | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index 12e2086..b81bec7 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -50,6 +50,14 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti if not isinstance(response_signature, str): raise InvalidJSONStructure("Credential response missing required signature") + cred_type = json_val.get("type") + try: + # Simply try to get the single matching Enum. We'll set the literal value below assuming + # the code can get past here (this is basically a mypy optimization) + PublicKeyCredentialType(cred_type) + except ValueError as cred_type_exc: + raise InvalidJSONStructure("Credential had unexpected type") from cred_type_exc + response_user_handle = cred_response.get("userHandle") if isinstance(response_user_handle, str): response_user_handle = response_user_handle @@ -67,14 +75,6 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti else: cred_authenticator_attachment = None - cred_type = json_val.get("type") - try: - # Simply try to get the single matching Enum. We'll set the literal value below assuming - # the code can get past here (this is basically a mypy optimization) - PublicKeyCredentialType(cred_type) - except ValueError as cred_type_exc: - raise InvalidJSONStructure("Credential had unexpected type") from cred_type_exc - try: authentication_credential = AuthenticationCredential( id=cred_id, diff --git a/webauthn/helpers/parse_registration_credential_json.py b/webauthn/helpers/parse_registration_credential_json.py index 47b8a8c..4cd4289 100644 --- a/webauthn/helpers/parse_registration_credential_json.py +++ b/webauthn/helpers/parse_registration_credential_json.py @@ -47,6 +47,14 @@ def parse_registration_credential_json(json_val: Union[str, dict]) -> Registrati if not isinstance(response_attestation_object, str): raise InvalidJSONStructure("Credential response missing required attestationObject") + cred_type = json_val.get("type") + try: + # Simply try to get the single matching Enum. We'll set the literal value below assuming + # the code can get past here (this is basically a mypy optimization) + PublicKeyCredentialType(cred_type) + except ValueError as cred_type_exc: + raise InvalidJSONStructure("Credential had unexpected type") from cred_type_exc + transports: Optional[List[AuthenticatorTransport]] = None response_transports = cred_response.get("transports") if isinstance(response_transports, list): @@ -69,14 +77,6 @@ def parse_registration_credential_json(json_val: Union[str, dict]) -> Registrati else: cred_authenticator_attachment = None - cred_type = json_val.get("type") - try: - # Simply try to get the single matching Enum. We'll set the literal value below assuming - # the code can get past here (this is basically a mypy optimization) - PublicKeyCredentialType(cred_type) - except ValueError as cred_type_exc: - raise InvalidJSONStructure("Credential had unexpected type") from cred_type_exc - try: registration_credential = RegistrationCredential( id=cred_id, From 2a2ac637261f8f8e1f7ac3731d027f52e57e06bd Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 11:46:50 -0800 Subject: [PATCH 35/47] Tweak error messages --- webauthn/helpers/parse_authentication_credential_json.py | 2 +- webauthn/helpers/parse_registration_credential_json.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index b81bec7..326fc79 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -90,7 +90,7 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti ) except Exception as exc: raise InvalidAuthenticationResponse( - "Unable to parse an authentication credential from JSON data" + "Unable to parse authentication credential from JSON data" ) from exc return authentication_credential diff --git a/webauthn/helpers/parse_registration_credential_json.py b/webauthn/helpers/parse_registration_credential_json.py index 4cd4289..9cb6210 100644 --- a/webauthn/helpers/parse_registration_credential_json.py +++ b/webauthn/helpers/parse_registration_credential_json.py @@ -72,7 +72,7 @@ def parse_registration_credential_json(json_val: Union[str, dict]) -> Registrati cred_authenticator_attachment = AuthenticatorAttachment(cred_authenticator_attachment) except ValueError as cred_attachment_exc: raise InvalidJSONStructure( - "Credential has unexpected authenticator attachment" + "Credential has unexpected authenticatorAttachment" ) from cred_attachment_exc else: cred_authenticator_attachment = None @@ -91,7 +91,7 @@ def parse_registration_credential_json(json_val: Union[str, dict]) -> Registrati ) except Exception as exc: raise InvalidRegistrationResponse( - "Unable to parse a registration credential from JSON data" + "Unable to parse registration credential from JSON data" ) from exc return registration_credential From defb54c1e4e41c888b73fbc3ffdcd50b29c15b66 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 11:47:13 -0800 Subject: [PATCH 36/47] Add registration response parsing tests --- ...test_parse_registration_credential_json.py | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 tests/test_parse_registration_credential_json.py diff --git a/tests/test_parse_registration_credential_json.py b/tests/test_parse_registration_credential_json.py new file mode 100644 index 0000000..94e0f0b --- /dev/null +++ b/tests/test_parse_registration_credential_json.py @@ -0,0 +1,301 @@ +import json +from unittest import TestCase + +from webauthn.helpers import base64url_to_bytes, bytes_to_base64url +from webauthn.helpers.exceptions import InvalidJSONStructure, InvalidRegistrationResponse +from webauthn.helpers.structs import AuthenticatorTransport, AuthenticatorAttachment +from webauthn.helpers.parse_registration_credential_json import parse_registration_credential_json + + +class TestParseClientDataJSON(TestCase): + def test_raises_on_non_dict_json(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"): + parse_registration_credential_json("[0]") + + def test_raises_on_missing_id(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required id"): + parse_registration_credential_json({}) + + def test_raises_on_missing_raw_id(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required rawId"): + parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + } + ) + + def test_raises_on_missing_response(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required response"): + parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + } + ) + + def test_raises_on_missing_client_data_json(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required clientDataJSON"): + parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": {}, + } + ) + + def test_raises_on_missing_attestation_object(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required attestationObject"): + parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "clientDataJSON": "...", + }, + } + ) + + def test_validates_credential_type(self) -> None: + parsed = parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "attestationObject": "...", + "clientDataJSON": "...", + }, + "type": "public-key", + } + ) + + def test_raises_on_invalid_credential_type(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "unexpected type"): + parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "attestationObject": "...", + "clientDataJSON": "...", + }, + "type": "not-a-public-key", + } + ) + + def test_parses_transports(self) -> None: + parsed = parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAK3OAAI1vMYKZIsLJfHwVQMAIFH-jHEbNNz948Ezg2yqJF5T6_kME1M0b-NZf85uvdvwpQECAyYgASFYIOmR1v1KhciLZLM_DDxy67MDa3J1vsiQWyzl20P0sy6fIlggita-HvZVinRVURaWCr-GJDm9iQ-Z1f5WfRhOA3CwZcU", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiUENYcURTMDFJbkRTeFAwbDhCMnVDcWxoR1BseEw4VHJSeDdpbHpjczIwNHplTklvMlJ0U3RkbFVSMWhfaW5WQzVPYkNjOElNT1JSYl9jWWNJMDNNeFEiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0", + "transports": ["internal", "hybrid"], + }, + "type": "public-key", + } + ) + + self.assertEqual( + parsed.response.transports, + [AuthenticatorTransport.INTERNAL, AuthenticatorTransport.HYBRID], + ) + + def test_handles_missing_transports(self) -> None: + parsed = parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAK3OAAI1vMYKZIsLJfHwVQMAIFH-jHEbNNz948Ezg2yqJF5T6_kME1M0b-NZf85uvdvwpQECAyYgASFYIOmR1v1KhciLZLM_DDxy67MDa3J1vsiQWyzl20P0sy6fIlggita-HvZVinRVURaWCr-GJDm9iQ-Z1f5WfRhOA3CwZcU", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiUENYcURTMDFJbkRTeFAwbDhCMnVDcWxoR1BseEw4VHJSeDdpbHpjczIwNHplTklvMlJ0U3RkbFVSMWhfaW5WQzVPYkNjOElNT1JSYl9jWWNJMDNNeFEiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0", + }, + "type": "public-key", + } + ) + + self.assertIsNone(parsed.response.transports) + + def test_ignores_non_list_transports(self) -> None: + parsed = parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAK3OAAI1vMYKZIsLJfHwVQMAIFH-jHEbNNz948Ezg2yqJF5T6_kME1M0b-NZf85uvdvwpQECAyYgASFYIOmR1v1KhciLZLM_DDxy67MDa3J1vsiQWyzl20P0sy6fIlggita-HvZVinRVURaWCr-GJDm9iQ-Z1f5WfRhOA3CwZcU", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiUENYcURTMDFJbkRTeFAwbDhCMnVDcWxoR1BseEw4VHJSeDdpbHpjczIwNHplTklvMlJ0U3RkbFVSMWhfaW5WQzVPYkNjOElNT1JSYl9jWWNJMDNNeFEiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0", + # Pretend someone got clever on the front end + "transports": "usb|nfc|ble", + }, + "type": "public-key", + } + ) + + self.assertIsNone(parsed.response.transports) + + def test_handles_authenticator_attachment(self) -> None: + parsed = parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAK3OAAI1vMYKZIsLJfHwVQMAIFH-jHEbNNz948Ezg2yqJF5T6_kME1M0b-NZf85uvdvwpQECAyYgASFYIOmR1v1KhciLZLM_DDxy67MDa3J1vsiQWyzl20P0sy6fIlggita-HvZVinRVURaWCr-GJDm9iQ-Z1f5WfRhOA3CwZcU", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiUENYcURTMDFJbkRTeFAwbDhCMnVDcWxoR1BseEw4VHJSeDdpbHpjczIwNHplTklvMlJ0U3RkbFVSMWhfaW5WQzVPYkNjOElNT1JSYl9jWWNJMDNNeFEiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0", + }, + "authenticatorAttachment": "platform", + "type": "public-key", + } + ) + + self.assertEqual(parsed.authenticator_attachment, AuthenticatorAttachment.PLATFORM) + + def test_handles_bad_authenticator_attachment(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "unexpected authenticatorAttachment"): + parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAK3OAAI1vMYKZIsLJfHwVQMAIFH-jHEbNNz948Ezg2yqJF5T6_kME1M0b-NZf85uvdvwpQECAyYgASFYIOmR1v1KhciLZLM_DDxy67MDa3J1vsiQWyzl20P0sy6fIlggita-HvZVinRVURaWCr-GJDm9iQ-Z1f5WfRhOA3CwZcU", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiUENYcURTMDFJbkRTeFAwbDhCMnVDcWxoR1BseEw4VHJSeDdpbHpjczIwNHplTklvMlJ0U3RkbFVSMWhfaW5WQzVPYkNjOElNT1JSYl9jWWNJMDNNeFEiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0", + }, + "authenticatorAttachment": "badValue", + "type": "public-key", + } + ) + + def test_raises_on_non_base64url_raw_id(self) -> None: + with self.assertRaisesRegex( + InvalidRegistrationResponse, "Unable to parse registration credential" + ): + parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "baddd", + "response": { + "attestationObject": "...", + "clientDataJSON": "...", + }, + "type": "public-key", + } + ) + + def test_raises_on_non_base64url_attestation_object(self) -> None: + with self.assertRaisesRegex( + InvalidRegistrationResponse, "Unable to parse registration credential" + ): + parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "attestationObject": "baddd", + "clientDataJSON": "...", + }, + "type": "public-key", + } + ) + + def test_raises_on_non_base64url_client_data_json(self) -> None: + with self.assertRaisesRegex( + InvalidRegistrationResponse, "Unable to parse registration credential" + ): + parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "attestationObject": "...", + "clientDataJSON": "baddd", + }, + "type": "public-key", + } + ) + + def test_success_from_dict(self) -> None: + # A bit more complex than a basic response, but it should get parsed all the same + parsed = parse_registration_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAK3OAAI1vMYKZIsLJfHwVQMAIFH-jHEbNNz948Ezg2yqJF5T6_kME1M0b-NZf85uvdvwpQECAyYgASFYIOmR1v1KhciLZLM_DDxy67MDa3J1vsiQWyzl20P0sy6fIlggita-HvZVinRVURaWCr-GJDm9iQ-Z1f5WfRhOA3CwZcU", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiUENYcURTMDFJbkRTeFAwbDhCMnVDcWxoR1BseEw4VHJSeDdpbHpjczIwNHplTklvMlJ0U3RkbFVSMWhfaW5WQzVPYkNjOElNT1JSYl9jWWNJMDNNeFEiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0", + "transports": ["internal"], + "publicKeyAlgorithm": -7, + "publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6ZHW_UqFyItksz8MPHLrswNrcnW-yJBbLOXbQ_SzLp-K1r4e9lWKdFVRFpYKv4YkOb2JD5nV_lZ9GE4DcLBlxQ", + "authenticatorData": "dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAK3OAAI1vMYKZIsLJfHwVQMAIFH-jHEbNNz948Ezg2yqJF5T6_kME1M0b-NZf85uvdvwpQECAyYgASFYIOmR1v1KhciLZLM_DDxy67MDa3J1vsiQWyzl20P0sy6fIlggita-HvZVinRVURaWCr-GJDm9iQ-Z1f5WfRhOA3CwZcU", + }, + "type": "public-key", + "clientExtensionResults": {"credProps": {"rk": True}}, + "authenticatorAttachment": "platform", + } + ) + + self.assertEqual(parsed.id, "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A") + self.assertEqual( + parsed.raw_id, + base64url_to_bytes("Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A"), + ) + self.assertEqual( + parsed.response.attestation_object, + base64url_to_bytes( + "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAK3OAAI1vMYKZIsLJfHwVQMAIFH-jHEbNNz948Ezg2yqJF5T6_kME1M0b-NZf85uvdvwpQECAyYgASFYIOmR1v1KhciLZLM_DDxy67MDa3J1vsiQWyzl20P0sy6fIlggita-HvZVinRVURaWCr-GJDm9iQ-Z1f5WfRhOA3CwZcU" + ), + ) + self.assertEqual( + parsed.response.client_data_json, + base64url_to_bytes( + "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiUENYcURTMDFJbkRTeFAwbDhCMnVDcWxoR1BseEw4VHJSeDdpbHpjczIwNHplTklvMlJ0U3RkbFVSMWhfaW5WQzVPYkNjOElNT1JSYl9jWWNJMDNNeFEiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0" + ), + ) + self.assertEqual( + parsed.response.transports, + [AuthenticatorTransport.INTERNAL], + ) + self.assertEqual(parsed.type, "public-key") + self.assertEqual(parsed.authenticator_attachment, AuthenticatorAttachment.PLATFORM) + + def test_success_from_str(self) -> None: + # Same dict as above, just stringified + parsed = parse_registration_credential_json( + """{ + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAK3OAAI1vMYKZIsLJfHwVQMAIFH-jHEbNNz948Ezg2yqJF5T6_kME1M0b-NZf85uvdvwpQECAyYgASFYIOmR1v1KhciLZLM_DDxy67MDa3J1vsiQWyzl20P0sy6fIlggita-HvZVinRVURaWCr-GJDm9iQ-Z1f5WfRhOA3CwZcU", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiUENYcURTMDFJbkRTeFAwbDhCMnVDcWxoR1BseEw4VHJSeDdpbHpjczIwNHplTklvMlJ0U3RkbFVSMWhfaW5WQzVPYkNjOElNT1JSYl9jWWNJMDNNeFEiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0", + "transports": ["internal"], + "publicKeyAlgorithm": -7, + "publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6ZHW_UqFyItksz8MPHLrswNrcnW-yJBbLOXbQ_SzLp-K1r4e9lWKdFVRFpYKv4YkOb2JD5nV_lZ9GE4DcLBlxQ", + "authenticatorData": "dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAK3OAAI1vMYKZIsLJfHwVQMAIFH-jHEbNNz948Ezg2yqJF5T6_kME1M0b-NZf85uvdvwpQECAyYgASFYIOmR1v1KhciLZLM_DDxy67MDa3J1vsiQWyzl20P0sy6fIlggita-HvZVinRVURaWCr-GJDm9iQ-Z1f5WfRhOA3CwZcU" + }, + "type": "public-key", + "clientExtensionResults": {"credProps": {"rk": true}}, + "authenticatorAttachment": "platform" + }""" + ) + + self.assertEqual(parsed.id, "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A") + self.assertEqual( + parsed.raw_id, + base64url_to_bytes("Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A"), + ) + self.assertEqual( + parsed.response.attestation_object, + base64url_to_bytes( + "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAK3OAAI1vMYKZIsLJfHwVQMAIFH-jHEbNNz948Ezg2yqJF5T6_kME1M0b-NZf85uvdvwpQECAyYgASFYIOmR1v1KhciLZLM_DDxy67MDa3J1vsiQWyzl20P0sy6fIlggita-HvZVinRVURaWCr-GJDm9iQ-Z1f5WfRhOA3CwZcU" + ), + ) + self.assertEqual( + parsed.response.client_data_json, + base64url_to_bytes( + "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiUENYcURTMDFJbkRTeFAwbDhCMnVDcWxoR1BseEw4VHJSeDdpbHpjczIwNHplTklvMlJ0U3RkbFVSMWhfaW5WQzVPYkNjOElNT1JSYl9jWWNJMDNNeFEiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0" + ), + ) + self.assertEqual( + parsed.response.transports, + [AuthenticatorTransport.INTERNAL], + ) + self.assertEqual(parsed.type, "public-key") + self.assertEqual(parsed.authenticator_attachment, AuthenticatorAttachment.PLATFORM) From c6f30b770bff665ae08d7cf2bc1081db2635b02c Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 12:03:06 -0800 Subject: [PATCH 37/47] Clean up unused imports --- tests/test_parse_registration_credential_json.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_parse_registration_credential_json.py b/tests/test_parse_registration_credential_json.py index 94e0f0b..9ec169b 100644 --- a/tests/test_parse_registration_credential_json.py +++ b/tests/test_parse_registration_credential_json.py @@ -1,7 +1,6 @@ -import json from unittest import TestCase -from webauthn.helpers import base64url_to_bytes, bytes_to_base64url +from webauthn.helpers import base64url_to_bytes from webauthn.helpers.exceptions import InvalidJSONStructure, InvalidRegistrationResponse from webauthn.helpers.structs import AuthenticatorTransport, AuthenticatorAttachment from webauthn.helpers.parse_registration_credential_json import parse_registration_credential_json From 043aa4215dcd41a90c58223dbfc5567b127324ef Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 12:17:41 -0800 Subject: [PATCH 38/47] Add tests for parsing authentication response --- ...st_parse_authentication_credential_json.py | 307 ++++++++++++++++++ .../parse_authentication_credential_json.py | 6 +- 2 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 tests/test_parse_authentication_credential_json.py diff --git a/tests/test_parse_authentication_credential_json.py b/tests/test_parse_authentication_credential_json.py new file mode 100644 index 0000000..e53d278 --- /dev/null +++ b/tests/test_parse_authentication_credential_json.py @@ -0,0 +1,307 @@ +from unittest import TestCase + +from webauthn.helpers import base64url_to_bytes +from webauthn.helpers.exceptions import InvalidJSONStructure, InvalidAuthenticationResponse +from webauthn.helpers.structs import AuthenticatorTransport, AuthenticatorAttachment +from webauthn.helpers.parse_authentication_credential_json import ( + parse_authentication_credential_json, +) + + +class TestParseClientDataJSON(TestCase): + def test_raises_on_non_dict_json(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"): + parse_authentication_credential_json("[0]") + + def test_raises_on_missing_id(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required id"): + parse_authentication_credential_json({}) + + def test_raises_on_missing_raw_id(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required rawId"): + parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + } + ) + + def test_raises_on_missing_response(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required response"): + parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + } + ) + + def test_raises_on_missing_client_data_json(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required clientDataJSON"): + parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": {}, + } + ) + + def test_raises_on_missing_authenticator_data(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required authenticatorData"): + parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "clientDataJSON": "...", + }, + } + ) + + def test_raises_on_missing_signature(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required signature"): + parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "authenticatorData": "...", + "clientDataJSON": "...", + }, + } + ) + + def test_validates_credential_type(self) -> None: + parsed = parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "authenticatorData": "...", + "clientDataJSON": "...", + "signature": "...", + }, + "type": "public-key", + } + ) + + def test_raises_on_invalid_credential_type(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "unexpected type"): + parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "authenticatorData": "...", + "clientDataJSON": "...", + "signature": "...", + }, + "type": "not-a-public-key", + } + ) + + def test_handles_authenticator_attachment(self) -> None: + parsed = parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "authenticatorData": "dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvABAAAAAA", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiSjlyUFpWWnFWODlUSW53bzV3cU11R3dlZjdET0pZRi1OVHlMQnhHV2pjZi16amFzOFRTUTlMbXI3em4wSmpkMTQyMU1sV0ItS2JYdEs5RW5sN19JM3ciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ", + "signature": "MEQCIBYTvMC-3pw88hoiYwdmPCHmPdz__tuhkFrfq-E03NvSAiBzelRNe6FCgsYL6_x6xmUlWM_ULmxRi6cX5iPZPiDrxA", + }, + "authenticatorAttachment": "platform", + "type": "public-key", + } + ) + + self.assertEqual(parsed.authenticator_attachment, AuthenticatorAttachment.PLATFORM) + + def test_handles_bad_authenticator_attachment(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "unexpected authenticatorAttachment"): + parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "authenticatorData": "dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvABAAAAAA", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiSjlyUFpWWnFWODlUSW53bzV3cU11R3dlZjdET0pZRi1OVHlMQnhHV2pjZi16amFzOFRTUTlMbXI3em4wSmpkMTQyMU1sV0ItS2JYdEs5RW5sN19JM3ciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ", + "signature": "MEQCIBYTvMC-3pw88hoiYwdmPCHmPdz__tuhkFrfq-E03NvSAiBzelRNe6FCgsYL6_x6xmUlWM_ULmxRi6cX5iPZPiDrxA", + }, + "authenticatorAttachment": "badValue", + "type": "public-key", + } + ) + + def test_handles_user_handle(self) -> None: + parsed = parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "authenticatorData": "...", + "clientDataJSON": "...", + "signature": "...", + "userHandle": "bW1pbGxlcg", + }, + "type": "public-key", + } + ) + + self.assertEqual(parsed.response.user_handle, "bW1pbGxlcg") + + def test_handles_missing_user_handle(self) -> None: + parsed = parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "authenticatorData": "...", + "clientDataJSON": "...", + "signature": "...", + }, + "type": "public-key", + } + ) + + self.assertIsNone(parsed.response.user_handle) + + def test_raises_on_non_base64url_raw_id(self) -> None: + with self.assertRaisesRegex( + InvalidAuthenticationResponse, "Unable to parse authentication credential" + ): + parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "baddd", + "response": { + "authenticatorData": "...", + "clientDataJSON": "...", + "signature": "...", + }, + "type": "public-key", + } + ) + + def test_raises_on_non_base64url_authenticator_data(self) -> None: + with self.assertRaisesRegex( + InvalidAuthenticationResponse, "Unable to parse authentication credential" + ): + parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "authenticatorData": "baddd", + "clientDataJSON": "...", + "signature": "...", + }, + "type": "public-key", + } + ) + + def test_raises_on_non_base64url_client_data_json(self) -> None: + with self.assertRaisesRegex( + InvalidAuthenticationResponse, "Unable to parse authentication credential" + ): + parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "authenticatorData": "...", + "clientDataJSON": "baddd", + "signature": "...", + }, + "type": "public-key", + } + ) + + def test_raises_on_non_base64url_signature(self) -> None: + with self.assertRaisesRegex( + InvalidAuthenticationResponse, "Unable to parse authentication credential" + ): + parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "authenticatorData": "...", + "clientDataJSON": "...", + "signature": "baddd", + }, + "type": "public-key", + } + ) + + def test_success_from_dict(self) -> None: + # A bit more complex than a basic response, but it should get parsed all the same + parsed = parse_authentication_credential_json( + { + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "authenticatorData": "dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvABAAAAAA", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiSjlyUFpWWnFWODlUSW53bzV3cU11R3dlZjdET0pZRi1OVHlMQnhHV2pjZi16amFzOFRTUTlMbXI3em4wSmpkMTQyMU1sV0ItS2JYdEs5RW5sN19JM3ciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ", + "signature": "MEQCIBYTvMC-3pw88hoiYwdmPCHmPdz__tuhkFrfq-E03NvSAiBzelRNe6FCgsYL6_x6xmUlWM_ULmxRi6cX5iPZPiDrxA", + "userHandle": "bW1pbGxlcg", + }, + "type": "public-key", + "clientExtensionResults": {}, + "authenticatorAttachment": "platform", + } + ) + + self.assertEqual(parsed.id, "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A") + self.assertEqual( + parsed.raw_id, + base64url_to_bytes("Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A"), + ) + self.assertEqual( + parsed.response.authenticator_data, + base64url_to_bytes("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvABAAAAAA"), + ) + self.assertEqual( + parsed.response.client_data_json, + base64url_to_bytes( + "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiSjlyUFpWWnFWODlUSW53bzV3cU11R3dlZjdET0pZRi1OVHlMQnhHV2pjZi16amFzOFRTUTlMbXI3em4wSmpkMTQyMU1sV0ItS2JYdEs5RW5sN19JM3ciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ" + ), + ) + self.assertEqual(parsed.response.user_handle, "bW1pbGxlcg") + self.assertEqual(parsed.type, "public-key") + self.assertEqual(parsed.authenticator_attachment, AuthenticatorAttachment.PLATFORM) + + def test_success_from_str(self) -> None: + # Same dict as above, just stringified + parsed = parse_authentication_credential_json( + """{ + "id": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "rawId": "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A", + "response": { + "authenticatorData": "dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvABAAAAAA", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiSjlyUFpWWnFWODlUSW53bzV3cU11R3dlZjdET0pZRi1OVHlMQnhHV2pjZi16amFzOFRTUTlMbXI3em4wSmpkMTQyMU1sV0ItS2JYdEs5RW5sN19JM3ciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ", + "signature": "MEQCIBYTvMC-3pw88hoiYwdmPCHmPdz__tuhkFrfq-E03NvSAiBzelRNe6FCgsYL6_x6xmUlWM_ULmxRi6cX5iPZPiDrxA", + "userHandle": "bW1pbGxlcg" + }, + "type": "public-key", + "clientExtensionResults": {}, + "authenticatorAttachment": "platform" + }""" + ) + + self.assertEqual(parsed.id, "Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A") + self.assertEqual( + parsed.raw_id, + base64url_to_bytes("Uf6McRs03P3jwTODbKokXlPr-QwTUzRv41l_zm692_A"), + ) + self.assertEqual( + parsed.response.authenticator_data, + base64url_to_bytes("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvABAAAAAA"), + ) + self.assertEqual( + parsed.response.client_data_json, + base64url_to_bytes( + "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiSjlyUFpWWnFWODlUSW53bzV3cU11R3dlZjdET0pZRi1OVHlMQnhHV2pjZi16amFzOFRTUTlMbXI3em4wSmpkMTQyMU1sV0ItS2JYdEs5RW5sN19JM3ciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ" + ), + ) + self.assertEqual(parsed.response.user_handle, "bW1pbGxlcg") + self.assertEqual(parsed.type, "public-key") + self.assertEqual(parsed.authenticator_attachment, AuthenticatorAttachment.PLATFORM) diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index 326fc79..c4d288c 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -59,10 +59,6 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti raise InvalidJSONStructure("Credential had unexpected type") from cred_type_exc response_user_handle = cred_response.get("userHandle") - if isinstance(response_user_handle, str): - response_user_handle = response_user_handle - else: - response_user_handle = None cred_authenticator_attachment = json_val.get("authenticatorAttachment") if isinstance(cred_authenticator_attachment, str): @@ -70,7 +66,7 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti cred_authenticator_attachment = AuthenticatorAttachment(cred_authenticator_attachment) except ValueError as cred_attachment_exc: raise InvalidJSONStructure( - "Credential has unexpected authenticator attachment" + "Credential has unexpected authenticatorAttachment" ) from cred_attachment_exc else: cred_authenticator_attachment = None From c3c53dd3e957f6eca4933e241336a76e97266520 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 12:31:24 -0800 Subject: [PATCH 39/47] Add comment about user_handle --- webauthn/helpers/parse_authentication_credential_json.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index c4d288c..c88b84b 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -58,6 +58,10 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> Authenti except ValueError as cred_type_exc: raise InvalidJSONStructure("Credential had unexpected type") from cred_type_exc + # Pass on whatever we might have received back for `userHandle`, it's more important for the RP + # than response verification. This SHOULD be the same UTF-8 string specified as + # `user_id` when calling `generate_registration_options()`, unless something on the front end + # is acting up. response_user_handle = cred_response.get("userHandle") cred_authenticator_attachment = json_val.get("authenticatorAttachment") From 4bf9cf527bdbd5fc14f13306cc260568026eb8b4 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 14:44:48 -0800 Subject: [PATCH 40/47] Remove json_loads_base64url_to_bytes --- webauthn/helpers/__init__.py | 2 -- .../helpers/json_loads_base64url_to_bytes.py | 35 ------------------- 2 files changed, 37 deletions(-) delete mode 100644 webauthn/helpers/json_loads_base64url_to_bytes.py diff --git a/webauthn/helpers/__init__.py b/webauthn/helpers/__init__.py index 8f963e5..751d5d5 100644 --- a/webauthn/helpers/__init__.py +++ b/webauthn/helpers/__init__.py @@ -8,7 +8,6 @@ from .generate_challenge import generate_challenge from .generate_user_handle import generate_user_handle from .hash_by_alg import hash_by_alg -from .json_loads_base64url_to_bytes import json_loads_base64url_to_bytes from .options_to_json import options_to_json from .parse_attestation_object import parse_attestation_object from .parse_authentication_credential_json import parse_authentication_credential_json @@ -32,7 +31,6 @@ "generate_challenge", "generate_user_handle", "hash_by_alg", - "json_loads_base64url_to_bytes", "options_to_json", "parse_attestation_object", "parse_authenticator_data", diff --git a/webauthn/helpers/json_loads_base64url_to_bytes.py b/webauthn/helpers/json_loads_base64url_to_bytes.py deleted file mode 100644 index 2efe502..0000000 --- a/webauthn/helpers/json_loads_base64url_to_bytes.py +++ /dev/null @@ -1,35 +0,0 @@ -import json -from typing import Any, Union - -from .base64url_to_bytes import base64url_to_bytes - - -def _object_hook_base64url_to_bytes(orig_dict: dict) -> dict: - """ - A function for the `object_hook` argument in json.loads() that knows which fields in - an incoming JSON string need to be converted from Base64URL to bytes. - """ - # Registration and Authentication - if "rawId" in orig_dict: - orig_dict["rawId"] = base64url_to_bytes(orig_dict["rawId"]) - if "clientDataJSON" in orig_dict: - orig_dict["clientDataJSON"] = base64url_to_bytes(orig_dict["clientDataJSON"]) - # Registration - if "attestationObject" in orig_dict: - orig_dict["attestationObject"] = base64url_to_bytes(orig_dict["attestationObject"]) - # Authentication - if "authenticatorData" in orig_dict: - orig_dict["authenticatorData"] = base64url_to_bytes(orig_dict["authenticatorData"]) - if "signature" in orig_dict: - orig_dict["signature"] = base64url_to_bytes(orig_dict["signature"]) - if "userHandle" in orig_dict: - orig_dict["userHandle"] = base64url_to_bytes(orig_dict["userHandle"]) - return orig_dict - - -def json_loads_base64url_to_bytes(input: Union[str, bytes]) -> Any: - """ - Wrap `json.loads()` with a custom object_hook that knows which dict keys to convert - from Base64URL to bytes when converting from JSON to a Python class - """ - return json.loads(input, object_hook=_object_hook_base64url_to_bytes) From 8f971be174360aa8ee4945cb3e9e02234b95c761 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 15:25:35 -0800 Subject: [PATCH 41/47] Raise when required string values are empty --- tests/test_generate_authentication_options.py | 6 ++++ tests/test_generate_registration_options.py | 36 +++++++++++++++++++ .../generate_authentication_options.py | 3 ++ .../generate_registration_options.py | 12 +++++++ 4 files changed, 57 insertions(+) diff --git a/tests/test_generate_authentication_options.py b/tests/test_generate_authentication_options.py index fc432c1..8b7ade6 100644 --- a/tests/test_generate_authentication_options.py +++ b/tests/test_generate_authentication_options.py @@ -39,3 +39,9 @@ def test_generates_options_with_custom_values(self) -> None: PublicKeyCredentialDescriptor(id=b"12345"), ] assert options.user_verification == UserVerificationRequirement.REQUIRED + + def test_raises_on_empty_rp_id(self) -> None: + with self.assertRaisesRegex(ValueError, "rp_id"): + generate_authentication_options( + rp_id="", + ) diff --git a/tests/test_generate_registration_options.py b/tests/test_generate_registration_options.py index 9f31c07..36df06b 100644 --- a/tests/test_generate_registration_options.py +++ b/tests/test_generate_registration_options.py @@ -85,3 +85,39 @@ def test_generates_options_with_custom_values(self) -> None: require_resident_key=True, ) assert options.attestation == AttestationConveyancePreference.DIRECT + + def test_raises_on_empty_rp_id(self) -> None: + with self.assertRaisesRegex(ValueError, "rp_id"): + generate_registration_options( + rp_id="", + rp_name="Example Co", + user_id="blah", + user_name="blah", + ) + + def test_raises_on_empty_rp_name(self) -> None: + with self.assertRaisesRegex(ValueError, "rp_name"): + generate_registration_options( + rp_id="example.com", + rp_name="", + user_id="blah", + user_name="blah", + ) + + def test_raises_on_empty_user_id(self) -> None: + with self.assertRaisesRegex(ValueError, "user_id"): + generate_registration_options( + rp_id="example.com", + rp_name="Example Co", + user_id="", + user_name="blah", + ) + + def test_raises_on_empty_user_name(self) -> None: + with self.assertRaisesRegex(ValueError, "user_name"): + generate_registration_options( + rp_id="example.com", + rp_name="Example Co", + user_id="blah", + user_name="", + ) diff --git a/webauthn/authentication/generate_authentication_options.py b/webauthn/authentication/generate_authentication_options.py index 1601a07..8443a06 100644 --- a/webauthn/authentication/generate_authentication_options.py +++ b/webauthn/authentication/generate_authentication_options.py @@ -29,6 +29,9 @@ def generate_authentication_options( Authentication options ready for the browser. Consider using `helpers.options_to_json()` in this library to quickly convert the options to JSON. """ + if not rp_id: + raise ValueError("rp_id cannot be an empty string") + ######## # Set defaults for required values ######## diff --git a/webauthn/registration/generate_registration_options.py b/webauthn/registration/generate_registration_options.py index edd93bd..6fda613 100644 --- a/webauthn/registration/generate_registration_options.py +++ b/webauthn/registration/generate_registration_options.py @@ -72,6 +72,18 @@ def generate_registration_options( Registration options ready for the browser. Consider using `helpers.options_to_json()` in this library to quickly convert the options to JSON. """ + if not rp_id: + raise ValueError("rp_id cannot be an empty string") + + if not rp_name: + raise ValueError("rp_name cannot be an empty string") + + if not user_id: + raise ValueError("user_id cannot be an empty string") + + if not user_name: + raise ValueError("user_name cannot be an empty string") + ######## # Set defaults for required values ######## From 963ba2e39588dee4938bf5a81a11f6e0d1ccd880 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 15:27:38 -0800 Subject: [PATCH 42/47] Revert PublicKeyCredentialUserEntity id Union --- webauthn/helpers/structs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index 0b79987..c571d84 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -194,7 +194,7 @@ class PublicKeyCredentialUserEntity: https://www.w3.org/TR/webauthn-2/#dictdef-publickeycredentialuserentity """ - id: Union[bytes, str] + id: bytes name: str display_name: str From 6c0c5449e4c0c032511dae9f51316c4b6cf85e2e Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 15:31:00 -0800 Subject: [PATCH 43/47] Revert typing on user_handle --- tests/test_bytes_subclass_support.py | 13 ------------- webauthn/helpers/structs.py | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/tests/test_bytes_subclass_support.py b/tests/test_bytes_subclass_support.py index b2f841f..3fe50d6 100644 --- a/tests/test_bytes_subclass_support.py +++ b/tests/test_bytes_subclass_support.py @@ -90,16 +90,3 @@ def base64url_to_memoryview(data: str) -> memoryview: ) assert verification.new_sign_count == 7 - - def test_supports_strings_for_bytes(self) -> None: - """ - Preserve the ability to pass strings for `bytes` fields - """ - response = AuthenticatorAssertionResponse( - authenticator_data=bytes(), - client_data_json=bytes(), - signature=bytes(), - user_handle="some_user_handle_string", - ) - - self.assertEqual(response.user_handle, "some_user_handle_string") diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index c571d84..a81f5e6 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -490,7 +490,7 @@ class AuthenticatorAssertionResponse: client_data_json: bytes authenticator_data: bytes signature: bytes - user_handle: Optional[Union[bytes, str]] = None + user_handle: Optional[bytes] = None @dataclass From f65c30d89908e324b76e390b23bb20c07bd0e2f2 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 15:51:30 -0800 Subject: [PATCH 44/47] Add .git-blame-ignore-revs --- .git-blame-ignore-revs | 2 ++ .vscode/settings.json | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..e4117b0 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# "Run Black over everything" +b134c58a394353e02c4f40808bf104f51578e7df diff --git a/.vscode/settings.json b/.vscode/settings.json index 3bf08d7..c20a0fa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,5 +9,9 @@ "editor.formatOnSaveMode": "file", "editor.formatOnSave": true, }, - "python.analysis.typeCheckingMode": "basic" + "python.analysis.typeCheckingMode": "basic", + "gitlens.advanced.blame.customArguments": [ + "--ignore-revs-file", + ".git-blame-ignore-revs" + ] } From 64a0579be773f2ac783f8b50f1046fccc09389a8 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 15:57:24 -0800 Subject: [PATCH 45/47] Run Black on setup.py --- setup.py | 47 +++++++++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/setup.py b/setup.py index 1baf7af..023f0b1 100644 --- a/setup.py +++ b/setup.py @@ -9,48 +9,47 @@ def read(*parts): - with codecs.open(os.path.join(HERE, *parts), 'r') as fp: + with codecs.open(os.path.join(HERE, *parts), "r") as fp: return fp.read() def find_version(*file_paths): version_file = read(*file_paths) - version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", - version_file, re.M) + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) if version_match: return version_match.group(1) - raise RuntimeError('Unable to find version string.') + raise RuntimeError("Unable to find version string.") -LONG_DESCRIPTION = read('README.md') -VERSION = find_version('webauthn', '__init__.py') +LONG_DESCRIPTION = read("README.md") +VERSION = find_version("webauthn", "__init__.py") setup( - name='webauthn', + name="webauthn", packages=find_packages(exclude=["tests"]), include_package_data=True, package_data={"webauthn": ["py.typed"]}, version=VERSION, - description='Pythonic WebAuthn', + description="Pythonic WebAuthn", long_description=LONG_DESCRIPTION, - long_description_content_type='text/markdown', - keywords='webauthn fido2', - author='Duo Labs', - author_email='labs@duo.com', - url='https://github.com/duo-labs/py_webauthn', - download_url='https://github.com/duo-labs/py_webauthn/archive/{}.tar.gz'.format(VERSION), - license='BSD', + long_description_content_type="text/markdown", + keywords="webauthn fido2", + author="Duo Labs", + author_email="labs@duo.com", + url="https://github.com/duo-labs/py_webauthn", + download_url="https://github.com/duo-labs/py_webauthn/archive/{}.tar.gz".format(VERSION), + license="BSD", classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Programming Language :: Python :: 3' + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", ], install_requires=[ - 'asn1crypto>=1.4.0', - 'cbor2>=5.4.6', - 'cryptography>=41.0.4', - 'pyOpenSSL>=23.2.0', - ] + "asn1crypto>=1.4.0", + "cbor2>=5.4.6", + "cryptography>=41.0.4", + "pyOpenSSL>=23.2.0", + ], ) From 6fd8ee636554f4bc26bb8903e1cf7f7b3eaa0208 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 15:57:51 -0800 Subject: [PATCH 46/47] Update to the latest cryptography and pyOpenSSL --- requirements.txt | 4 ++-- setup.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 1c334c4..07bbaa4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ black==21.9b0 cbor2==5.5.0 cffi==1.15.0 click==8.0.3 -cryptography==41.0.4 +cryptography==41.0.7 mccabe==0.6.1 mypy==1.4.1 mypy-extensions==1.0.0 @@ -12,7 +12,7 @@ platformdirs==2.4.0 pycodestyle==2.8.0 pycparser==2.20 pyflakes==2.4.0 -pyOpenSSL==23.2.0 +pyOpenSSL==23.3.0 regex==2021.10.8 six==1.16.0 toml==0.10.2 diff --git a/setup.py b/setup.py index 023f0b1..6e1bf32 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ def find_version(*file_paths): install_requires=[ "asn1crypto>=1.4.0", "cbor2>=5.4.6", - "cryptography>=41.0.4", - "pyOpenSSL>=23.2.0", + "cryptography>=41.0.7", + "pyOpenSSL>=23.3.0", ], ) From 939c486e441d933a9b325f2426c49a590fd3aa2e Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 8 Jan 2024 15:59:54 -0800 Subject: [PATCH 47/47] Ignore another formatting commit --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index e4117b0..75db9cd 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,2 +1,4 @@ # "Run Black over everything" b134c58a394353e02c4f40808bf104f51578e7df +# "Run Black on setup.py" +64a0579be773f2ac783f8b50f1046fccc09389a8