From 67dc43f14f75294712f4ab6c024169e326fa0983 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 29 Sep 2023 09:57:56 -0700 Subject: [PATCH 01/10] Support `dict`'s for `credential` during registration --- .../parse_registration_credential_json.py | 12 ++++++++-- .../verify_registration_response.py | 24 ++++++++++++------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/webauthn/helpers/parse_registration_credential_json.py b/webauthn/helpers/parse_registration_credential_json.py index 11a3c51..4e7b9f6 100644 --- a/webauthn/helpers/parse_registration_credential_json.py +++ b/webauthn/helpers/parse_registration_credential_json.py @@ -1,16 +1,24 @@ -from typing import Callable +import json +from typing import Callable, Union from pydantic import ValidationError from .exceptions import InvalidRegistrationResponse from .structs import PYDANTIC_V2, RegistrationCredential -def parse_registration_credential_json(json_val: str) -> RegistrationCredential: +def parse_registration_credential_json(json_val: Union[str, dict]) -> RegistrationCredential: + """ + 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: diff --git a/webauthn/registration/verify_registration_response.py b/webauthn/registration/verify_registration_response.py index 562b884..0aab6d9 100644 --- a/webauthn/registration/verify_registration_response.py +++ b/webauthn/registration/verify_registration_response.py @@ -65,7 +65,7 @@ class VerifiedRegistration(WebAuthnBaseModel): def verify_registration_response( *, - credential: Union[str, RegistrationCredential], + credential: Union[str, dict, RegistrationCredential], expected_challenge: bytes, expected_rp_id: str, expected_origin: Union[str, List[str]], @@ -80,12 +80,20 @@ def verify_registration_response( """Verify an authenticator's response to navigator.credentials.create() Args: - `credential`: The value returned from `navigator.credentials.create()`. - `expected_challenge`: The challenge passed to the authenticator within the preceding registration options. - `expected_rp_id`: The Relying Party's unique identifier as specified in the precending registration options. - `expected_origin`: The domain, with HTTP protocol (e.g. "https://domain.here"), on which the registration should have occurred. Can also be a list of expected origins. - (optional) `require_user_verification`: Whether or not to require that the authenticator verified the user. - (optional) `supported_pub_key_algs`: A list of public key algorithm IDs the RP chooses to restrict support to. Defaults to all supported algorithm IDs. + - `credential`: The value returned from `navigator.credentials.create()`. Can be either a + stringified JSON object, a plain dict, or an instance of RegistrationCredential + - `expected_challenge`: The challenge passed to the authenticator within the preceding + registration options. + - `expected_rp_id`: The Relying Party's unique identifier as specified in the precending + registration options. + - `expected_origin`: The domain, with HTTP protocol (e.g. "https://domain.here"), on which + the registration should have occurred. Can also be a list of expected origins. + - (optional) `require_user_verification`: Whether or not to require that the authenticator + verified the user. + - (optional) `supported_pub_key_algs`: A list of public key algorithm IDs the RP chooses to + restrict support to. Defaults to all supported algorithm IDs. + - (optional) `pem_root_certs_bytes_by_fmt`: A list of root certificates, in PEM format, to + be used to validate the certificate chains for specific attestation statement formats. Returns: Information about the authenticator and registration @@ -93,7 +101,7 @@ def verify_registration_response( Raises: `helpers.exceptions.InvalidRegistrationResponse` if the response cannot be verified """ - if isinstance(credential, str): + if isinstance(credential, str) or isinstance(credential, dict): credential = parse_registration_credential_json(credential) verified = False From 471a6b67a61259b853ed58237d57e5adef3ae745 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 29 Sep 2023 09:59:17 -0700 Subject: [PATCH 02/10] Support `dict`'s for `credential` during authentication --- .../verify_authentication_response.py | 25 ++++++++++++------- .../parse_authentication_credential_json.py | 12 +++++++-- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/webauthn/authentication/verify_authentication_response.py b/webauthn/authentication/verify_authentication_response.py index 55fe0ae..9298f71 100644 --- a/webauthn/authentication/verify_authentication_response.py +++ b/webauthn/authentication/verify_authentication_response.py @@ -43,7 +43,7 @@ class VerifiedAuthentication(WebAuthnBaseModel): def verify_authentication_response( *, - credential: Union[str, AuthenticationCredential], + credential: Union[str, dict, AuthenticationCredential], expected_challenge: bytes, expected_rp_id: str, expected_origin: Union[str, List[str]], @@ -54,13 +54,20 @@ def verify_authentication_response( """Verify a response from navigator.credentials.get() Args: - `credential`: The value returned from `navigator.credentials.get()`. - `expected_challenge`: The challenge passed to the authenticator within the preceding authentication options. - `expected_rp_id`: The Relying Party's unique identifier as specified in the precending authentication options. - `expected_origin`: The domain, with HTTP protocol (e.g. "https://domain.here"), on which the authentication ceremony should have occurred. - `credential_public_key`: The public key for the credential's ID as provided in a preceding authenticator registration ceremony. - `credential_current_sign_count`: The current known number of times the authenticator was used. - (optional) `require_user_verification`: Whether or not to require that the authenticator verified the user. + - `credential`: The value returned from `navigator.credentials.get()`. Can be either a + stringified JSON object, a plain dict, or an instance of RegistrationCredential + - `expected_challenge`: The challenge passed to the authenticator within the preceding + authentication options. + - `expected_rp_id`: The Relying Party's unique identifier as specified in the preceding + authentication options. + - `expected_origin`: The domain, with HTTP protocol (e.g. "https://domain.here"), on which + the authentication ceremony should have occurred. + - `credential_public_key`: The public key for the credential's ID as provided in a + preceding authenticator registration ceremony. + - `credential_current_sign_count`: The current known number of times the authenticator was + used. + - (optional) `require_user_verification`: Whether or not to require that the authenticator + verified the user. Returns: Information about the authenticator @@ -68,7 +75,7 @@ def verify_authentication_response( Raises: `helpers.exceptions.InvalidAuthenticationResponse` if the response cannot be verified """ - if isinstance(credential, str): + if isinstance(credential, str) or isinstance(credential, dict): credential = parse_authentication_credential_json(credential) # FIDO-specific check diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index 5d5d8b8..ca963d8 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -1,16 +1,24 @@ -from typing import Callable +import json +from typing import Callable, Union from pydantic import ValidationError from .exceptions import InvalidAuthenticationResponse from .structs import PYDANTIC_V2, AuthenticationCredential -def parse_authentication_credential_json(json_val: str) -> AuthenticationCredential: +def parse_authentication_credential_json(json_val: Union[str, dict]) -> AuthenticationCredential: + """ + Parse a JSON form of an authentication credential, as either a stringified JSON object or a + plain dict, into an instance of RegistrationCredential + """ 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: From 452f6ee909d0ef988b1ec8f4e6fedf0d05be95df Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 29 Sep 2023 09:59:24 -0700 Subject: [PATCH 03/10] Update examples --- examples/authentication.py | 1 + examples/registration.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/authentication.py b/examples/authentication.py index fe3a054..d555ae7 100644 --- a/examples/authentication.py +++ b/examples/authentication.py @@ -40,6 +40,7 @@ # Authentication Response Verification authentication_verification = verify_authentication_response( + # Demonstrating the ability to handle a stringified JSON version of the WebAuthn response credential="""{ "id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", "rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", diff --git a/examples/registration.py b/examples/registration.py index 6ac162c..bfe1f8a 100644 --- a/examples/registration.py +++ b/examples/registration.py @@ -56,7 +56,8 @@ # Registration Response Verification registration_verification = verify_registration_response( - credential="""{ + # Demonstrating the ability to handle a plain dict version of the WebAuthn response + credential={ "id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", "rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", "response": { @@ -67,7 +68,7 @@ "type": "public-key", "clientExtensionResults": {}, "authenticatorAttachment": "platform" - }""", + }, expected_challenge=base64url_to_bytes( "CeTWogmg0cchuiYuFrv8DXXdMZSIQRVZJOga_xayVVEcBj0Cw3y73yhD4FkGSe-RrP6hPJJAIm3LVien4hXELg" ), From 61ce10c7e118feecb51488b3f4f19ac2478abad3 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 29 Sep 2023 10:03:03 -0700 Subject: [PATCH 04/10] Add tests --- tests/test_verify_authentication_response.py | 35 ++++++++++++++++++++ tests/test_verify_registration_response.py | 30 +++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/tests/test_verify_authentication_response.py b/tests/test_verify_authentication_response.py index f7fcb42..acdbc1a 100644 --- a/tests/test_verify_authentication_response.py +++ b/tests/test_verify_authentication_response.py @@ -255,3 +255,38 @@ def test_supports_already_parsed_credential(self) -> None: ) assert verification.new_sign_count == 1 + + def test_supports_dict_credential(self) -> None: + credential = { + "id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", + "rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", + "response": { + "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAQ", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVBtQWkxUHAxWEw2b0FncTNQV1p0WlBuWmExekZVRG9HYmFRMF9LdlZHMWxGMnMzUnRfM280dVN6Y2N5MHRtY1RJcFRUVDRCVTFULUk0bWFhdm5kalEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", + "signature": "iOHKX3erU5_OYP_r_9HLZ-CexCE4bQRrxM8WmuoKTDdhAnZSeTP0sjECjvjfeS8MJzN1ArmvV0H0C3yy_FdRFfcpUPZzdZ7bBcmPh1XPdxRwY747OrIzcTLTFQUPdn1U-izCZtP_78VGw9pCpdMsv4CUzZdJbEcRtQuRS03qUjqDaovoJhOqEBmxJn9Wu8tBi_Qx7A33RbYjlfyLm_EDqimzDZhyietyop6XUcpKarKqVH0M6mMrM5zTjp8xf3W7odFCadXEJg-ERZqFM0-9Uup6kJNLbr6C5J4NDYmSm3HCSA6lp2iEiMPKU8Ii7QZ61kybXLxsX4w4Dm3fOLjmDw", + "userHandle": "T1RWa1l6VXdPRFV0WW1NNVlTMDBOVEkxTFRnd056Z3RabVZpWVdZNFpEVm1ZMk5p" + }, + "type": "public-key", + "clientExtensionResults": {} + } + challenge = base64url_to_bytes( + "iPmAi1Pp1XL6oAgq3PWZtZPnZa1zFUDoGbaQ0_KvVG1lF2s3Rt_3o4uSzccy0tmcTIpTTT4BU1T-I4maavndjQ" + ) + expected_rp_id = "localhost" + expected_origin = "http://localhost:5000" + credential_public_key = base64url_to_bytes( + "pAEDAzkBACBZAQDfV20epzvQP-HtcdDpX-cGzdOxy73WQEvsU7Dnr9UWJophEfpngouvgnRLXaEUn_d8HGkp_HIx8rrpkx4BVs6X_B6ZjhLlezjIdJbLbVeb92BaEsmNn1HW2N9Xj2QM8cH-yx28_vCjf82ahQ9gyAr552Bn96G22n8jqFRQKdVpO-f-bvpvaP3IQ9F5LCX7CUaxptgbog1SFO6FI6ob5SlVVB00lVXsaYg8cIDZxCkkENkGiFPgwEaZ7995SCbiyCpUJbMqToLMgojPkAhWeyktu7TlK6UBWdJMHc3FPAIs0lH_2_2hKS-mGI1uZAFVAfW1X-mzKL0czUm2P1UlUox7IUMBAAE" + ) + sign_count = 0 + + verification = verify_authentication_response( + credential=credential, + expected_challenge=challenge, + expected_rp_id=expected_rp_id, + expected_origin=expected_origin, + credential_public_key=credential_public_key, + credential_current_sign_count=sign_count, + require_user_verification=True, + ) + + assert verification.new_sign_count == 1 diff --git a/tests/test_verify_registration_response.py b/tests/test_verify_registration_response.py index 6279e26..e8949cf 100644 --- a/tests/test_verify_registration_response.py +++ b/tests/test_verify_registration_response.py @@ -221,3 +221,33 @@ def test_supports_already_parsed_credential(self) -> None: ) assert verification.fmt == AttestationFormat.NONE + + def test_supports_dict_credential(self) -> None: + credential = { + "id": "9y1xA8Tmg1FEmT-c7_fvWZ_uoTuoih3OvR45_oAK-cwHWhAbXrl2q62iLVTjiyEZ7O7n-CROOY494k7Q3xrs_w", + "rawId": "9y1xA8Tmg1FEmT-c7_fvWZ_uoTuoih3OvR45_oAK-cwHWhAbXrl2q62iLVTjiyEZ7O7n-CROOY494k7Q3xrs_w", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAFwAAAAAAAAAAAAAAAAAAAAAAQPctcQPE5oNRRJk_nO_371mf7qE7qIodzr0eOf6ACvnMB1oQG165dqutoi1U44shGezu5_gkTjmOPeJO0N8a7P-lAQIDJiABIVggSFbUJF-42Ug3pdM8rDRFu_N5oiVEysPDB6n66r_7dZAiWCDUVnB39FlGypL-qAoIO9xWHtJygo2jfDmHl-_eKFRLDA", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiVHdON240V1R5R0tMYzRaWS1xR3NGcUtuSE00bmdscXN5VjBJQ0psTjJUTzlYaVJ5RnRya2FEd1V2c3FsLWdrTEpYUDZmbkYxTWxyWjUzTW00UjdDdnciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" + }, + "type": "public-key", + "clientExtensionResults": {}, + "transports": [ + "cable" + ] + } + + challenge = base64url_to_bytes( + "TwN7n4WTyGKLc4ZY-qGsFqKnHM4nglqsyV0ICJlN2TO9XiRyFtrkaDwUvsql-gkLJXP6fnF1MlrZ53Mm4R7Cvw" + ) + rp_id = "localhost" + expected_origin = "http://localhost:5000" + + verification = verify_registration_response( + credential=credential, + expected_challenge=challenge, + expected_origin=expected_origin, + expected_rp_id=rp_id, + ) + + assert verification.fmt == AttestationFormat.NONE From 41420fdb437ebf82e76baf938ba13f1f97b0e516 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 29 Sep 2023 10:04:33 -0700 Subject: [PATCH 05/10] Update cryptography lib --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4974bd0..8f29982 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ black==21.9b0 cbor2==5.4.2.post1 cffi==1.15.0 click==8.0.3 -cryptography==41.0.3 +cryptography==41.0.4 mccabe==0.6.1 mypy==1.4.1 mypy-extensions==1.0.0 From dfb9c650d49bb6c8949fb19cc8a2a751689678f7 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 29 Sep 2023 10:08:27 -0700 Subject: [PATCH 06/10] Update cbor2 lib --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8f29982..5e3796d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ annotated-types==0.5.0 asn1crypto==1.4.0 black==21.9b0 -cbor2==5.4.2.post1 +cbor2==5.4.6 cffi==1.15.0 click==8.0.3 cryptography==41.0.4 From a38a8a82d74998fc6d0295b4989bff4ccbd83842 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 29 Sep 2023 10:08:35 -0700 Subject: [PATCH 07/10] Sync install_requires in setup.py --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 98d2120..6e0b8b9 100644 --- a/setup.py +++ b/setup.py @@ -49,8 +49,8 @@ def find_version(*file_paths): ], install_requires=[ 'asn1crypto>=1.4.0', - 'cbor2>=5.4.2.post1', - 'cryptography>=41.0.1', + 'cbor2>=5.4.6', + 'cryptography>=41.0.4', 'pydantic>=1.10.11', 'pyOpenSSL>=23.2.0', ] From ab681ea1e50c65070e57d9f15f9416a63c599ce2 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 29 Sep 2023 10:53:00 -0700 Subject: [PATCH 08/10] Update pydantic libs --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5e3796d..fc26dcc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,8 +12,8 @@ pathspec==0.9.0 platformdirs==2.4.0 pycodestyle==2.8.0 pycparser==2.20 -pydantic==2.1.1 -pydantic_core==2.4.0 +pydantic==2.4.2 +pydantic_core==2.10.1 pyflakes==2.4.0 pyOpenSSL==23.2.0 regex==2021.10.8 From 7f16d4db947521982360a5ed02abe156c4c9dfd2 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 29 Sep 2023 10:53:13 -0700 Subject: [PATCH 09/10] Fix mypy issue related to FieldValidationInfo --- webauthn/helpers/structs.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index 4bc3cd6..5bada95 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -66,8 +66,23 @@ class WebAuthnBaseModel(BaseModel): @field_validator("*", mode="before") def _pydantic_v2_validate_bytes_fields( - cls, v: Any, info: FieldValidationInfo + 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: From e8f9ed4b381b42e10c195d69d540fc1c43a188cb Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 29 Sep 2023 11:03:52 -0700 Subject: [PATCH 10/10] Fix typo in comment --- 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 ca963d8..1aad49a 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -9,7 +9,7 @@ def parse_authentication_credential_json(json_val: Union[str, dict]) -> AuthenticationCredential: """ Parse a JSON form of an authentication credential, as either a stringified JSON object or a - plain dict, into an instance of RegistrationCredential + plain dict, into an instance of AuthenticationCredential """ if PYDANTIC_V2: parsing_method: Callable = AuthenticationCredential.model_validate_json # type: ignore[attr-defined]