From 5aea0d9821a558f5eddc36596a2cecae85add34d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigurd=20Lj=C3=B8dal?= <544451+ljodal@users.noreply.github.com> Date: Wed, 2 Aug 2023 13:30:41 +0200 Subject: [PATCH 1/9] Support both Pydantic v1 and v2 This is another attempt at supporting both Pydantic v1 and v2, but without using the v1 module. Instead we configure WebAuthnBaseModel slightly differently based on the pydantic version. This contains a slight behavior change in that trailing = in base64 encoded values are not removed, because pydantic v2 doesn't support that. If that is a problem (I'm not familiar with the spec) I can try to see if I can remove them somehow by doing some post-processing of the serialized values provided by pydantic --- .github/workflows/build_and_test.yml | 4 +- tests/test_bytes_to_base64url.py | 2 +- tests/test_decode_credential_public_key.py | 10 +- tests/test_options_to_json.py | 8 +- tests/test_verify_authentication_response.py | 8 +- tests/test_verify_registration_response.py | 7 +- .../verify_authentication_response.py | 4 +- webauthn/helpers/bytes_to_base64url.py | 2 +- webauthn/helpers/structs.py | 100 ++++++++++++------ .../verify_registration_response.py | 4 +- 10 files changed, 95 insertions(+), 54 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 176d002..afed84b 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -15,7 +15,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10'] + 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 @@ -27,6 +28,7 @@ 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 diff --git a/tests/test_bytes_to_base64url.py b/tests/test_bytes_to_base64url.py index b847b93..6f7e369 100644 --- a/tests/test_bytes_to_base64url.py +++ b/tests/test_bytes_to_base64url.py @@ -7,4 +7,4 @@ class TestWebAuthnBytesToBase64URL(TestCase): def test_converts_buffer_to_base64url_string(self) -> None: output = bytes_to_base64url(bytes([1, 2, 3, 4, 5])) - assert output == "AQIDBAU" + assert output == "AQIDBAU=" diff --git a/tests/test_decode_credential_public_key.py b/tests/test_decode_credential_public_key.py index fcc2184..529ed12 100644 --- a/tests/test_decode_credential_public_key.py +++ b/tests/test_decode_credential_public_key.py @@ -24,12 +24,12 @@ def test_decodes_ec2_public_key(self) -> None: assert ( decoded.x and bytes_to_base64url(decoded.x) - == "MMcEPFOpY_jJlmcBrnbgvq4-7CGKt5TBEPmxdjpTaDE" + == "MMcEPFOpY_jJlmcBrnbgvq4-7CGKt5TBEPmxdjpTaDE=" ) assert ( decoded.y and bytes_to_base64url(decoded.y) - == "xuwbECbDdNfTTegnc174oYdusZiMmJgct0yI_ulrJGI" + == "xuwbECbDdNfTTegnc174oYdusZiMmJgct0yI_ulrJGI=" ) def test_decode_rsa_public_key(self) -> None: @@ -46,7 +46,7 @@ def test_decode_rsa_public_key(self) -> None: assert ( decoded.n and bytes_to_base64url(decoded.n) - == "8X6V649G2vwB99CSf_luwR0jj7oDg_GhA3TQSnNYIwfQJldxT5dmi9H8IjjCrTP28iNuKl29hc3Mowux1FZB0bc5AEJ2oV3JCOMGP9NZKGmOosF7iBN2GtGY7Nomcs-ruBv2mxp1nTm6mv5B8XNwh0e18uTA5AJCsl-k6lNLYB2XBIQ3fy2-TjSQ8IOMLypWQbWWBJXzLmepaJ6EWe6kf_NaxpA2chWsaekZcr8xG6OIo3iGh0Mpags_qBZtN4n2TDn0R2LheLk4yQ0R_oOAVtX963Yuw0x5NYSZyMNSMi_1RSEPTYn5AILmIzQskglDaWJYtnjKz4QLuXWCRRYyDQ" + == "8X6V649G2vwB99CSf_luwR0jj7oDg_GhA3TQSnNYIwfQJldxT5dmi9H8IjjCrTP28iNuKl29hc3Mowux1FZB0bc5AEJ2oV3JCOMGP9NZKGmOosF7iBN2GtGY7Nomcs-ruBv2mxp1nTm6mv5B8XNwh0e18uTA5AJCsl-k6lNLYB2XBIQ3fy2-TjSQ8IOMLypWQbWWBJXzLmepaJ6EWe6kf_NaxpA2chWsaekZcr8xG6OIo3iGh0Mpags_qBZtN4n2TDn0R2LheLk4yQ0R_oOAVtX963Yuw0x5NYSZyMNSMi_1RSEPTYn5AILmIzQskglDaWJYtnjKz4QLuXWCRRYyDQ==" ) def test_decode_uncompressed_ec2_public_key(self) -> None: @@ -63,10 +63,10 @@ def test_decode_uncompressed_ec2_public_key(self) -> None: assert ( decoded.x and bytes_to_base64url(decoded.x) - == "FrEpm55XKvkgIN-izKDHBF-VJ09Rw2F5mFOFcJ5MVM0" + == "FrEpm55XKvkgIN-izKDHBF-VJ09Rw2F5mFOFcJ5MVM0=" ) assert ( decoded.y and bytes_to_base64url(decoded.y) - == "o0EM9dj0V-xJ1JwpE2XZ_8NRIt5KVvr71Zl0rB8BWOs" + == "o0EM9dj0V-xJ1JwpE2XZ_8NRIt5KVvr71Zl0rB8BWOs=" ) diff --git a/tests/test_options_to_json.py b/tests/test_options_to_json.py index 65c99e4..86bed99 100644 --- a/tests/test_options_to_json.py +++ b/tests/test_options_to_json.py @@ -40,14 +40,14 @@ def test_converts_options_to_JSON(self) -> None: assert json.loads(output) == { "rp": {"name": "Example Co", "id": "example.com"}, "user": { - "id": "QUJBVjZRV1BCRVk5V09UT0ExQTQ", + "id": "QUJBVjZRV1BCRVk5V09UT0ExQTQ=", "name": "lee", "displayName": "Lee", }, - "challenge": "MTIzNDU2Nzg5MA", + "challenge": "MTIzNDU2Nzg5MA==", "pubKeyCredParams": [{"type": "public-key", "alg": -36}], "timeout": 120000, - "excludeCredentials": [{"type": "public-key", "id": "MTIzNDU2Nzg5MA"}], + "excludeCredentials": [{"type": "public-key", "id": "MTIzNDU2Nzg5MA=="}], "authenticatorSelection": { "authenticatorAttachment": "platform", "residentKey": "required", @@ -75,7 +75,7 @@ def test_includes_optional_value_when_set(self) -> None: assert json.loads(output)["excludeCredentials"] == [ { - "id": "MTIzNDU2Nzg5MA", + "id": "MTIzNDU2Nzg5MA==", "transports": ["usb"], "type": "public-key", } diff --git a/tests/test_verify_authentication_response.py b/tests/test_verify_authentication_response.py index accaef0..83565e9 100644 --- a/tests/test_verify_authentication_response.py +++ b/tests/test_verify_authentication_response.py @@ -19,7 +19,7 @@ def test_verify_authentication_response_with_EC2_public_key(self): }, "type": "public-key", "clientExtensionResults": {} - }""" + }""", ) challenge = base64url_to_bytes( "xi30GPGAFYRxVDpY1sM10DaLzVQG66nv-_7RUazH0vI2YvG8LYgDEnvN5fZZNVuvEDuMi9te3VLqb42N0fkLGA" @@ -45,7 +45,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 = AuthenticationCredential.parse_raw( @@ -60,7 +60,7 @@ def test_verify_authentication_response_with_RSA_public_key(self): }, "type": "public-key", "clientExtensionResults": {} - }""" + }""", ) challenge = base64url_to_bytes( "iPmAi1Pp1XL6oAgq3PWZtZPnZa1zFUDoGbaQ0_KvVG1lF2s3Rt_3o4uSzccy0tmcTIpTTT4BU1T-I4maavndjQ" @@ -96,7 +96,7 @@ def test_raises_exception_on_incorrect_public_key(self): }, "type": "public-key", "clientExtensionResults": {} - }""" + }""", ) challenge = base64url_to_bytes( "zsfiMZj16TUVCrT5tDRYXdYlUrJp7zn_UNd5NmBocPc4I2dKZbeEWpwBAwA4s6oHkVX6_ly_jgp743dyiWHYYw" diff --git a/tests/test_verify_registration_response.py b/tests/test_verify_registration_response.py index bb7dbd6..614e3f3 100644 --- a/tests/test_verify_registration_response.py +++ b/tests/test_verify_registration_response.py @@ -2,6 +2,7 @@ from unittest import TestCase import cbor2 +from pydantic import ValidationError from webauthn.helpers import base64url_to_bytes, bytes_to_base64url from webauthn.helpers.exceptions import InvalidRegistrationResponse from webauthn.helpers.known_root_certs import globalsign_r2 @@ -59,7 +60,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 = { @@ -87,9 +88,7 @@ def test_raises_exception_on_unsupported_attestation_type(self) -> None: rp_id = "localhost" expected_origin = "http://localhost:5000" - with self.assertRaisesRegex( - Exception, "value is not a valid enumeration member" - ): + with self.assertRaises(ValidationError): verify_registration_response( credential=credential, expected_challenge=challenge, diff --git a/webauthn/authentication/verify_authentication_response.py b/webauthn/authentication/verify_authentication_response.py index c16f0ff..0f14d38 100644 --- a/webauthn/authentication/verify_authentication_response.py +++ b/webauthn/authentication/verify_authentication_response.py @@ -4,7 +4,7 @@ from cryptography.exceptions import InvalidSignature from webauthn.helpers import ( - bytes_to_base64url, + base64url_to_bytes, decode_credential_public_key, decoded_public_key_to_cryptography, parse_authenticator_data, @@ -69,7 +69,7 @@ def verify_authentication_response( """ # FIDO-specific check - if bytes_to_base64url(credential.raw_id) != credential.id: + if credential.raw_id != base64url_to_bytes(credential.id): raise InvalidAuthenticationResponse("id and raw_id were not equivalent") # FIDO-specific check diff --git a/webauthn/helpers/bytes_to_base64url.py b/webauthn/helpers/bytes_to_base64url.py index ab7eeff..e323bc9 100644 --- a/webauthn/helpers/bytes_to_base64url.py +++ b/webauthn/helpers/bytes_to_base64url.py @@ -5,4 +5,4 @@ def bytes_to_base64url(val: bytes) -> str: """ Base64URL-encode the provided bytes """ - return urlsafe_b64encode(val).decode("utf-8").replace("=", "") + return urlsafe_b64encode(val).decode("utf-8") # .replace("=", "") diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index 5aaac61..3bb586d 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -1,8 +1,18 @@ from enum import Enum -from typing import List, Literal, Optional +from typing import List, Literal, Optional, Any -from pydantic import BaseModel, validator -from pydantic.fields import ModelField +from webauthn.helpers.base64url_to_bytes import base64url_to_bytes + + +try: + from pydantic import BaseModel, field_validator, ConfigDict, ValidationInfo + + PYDANTIC_V2 = True +except ImportError: + from pydantic import BaseModel, validator + from pydantic.fields import ModelField + + PYDANTIC_V2 = False from .bytes_to_base64url import bytes_to_base64url from .cose import COSEAlgorithmIdentifier @@ -10,6 +20,24 @@ 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 @@ -24,37 +52,49 @@ class WebAuthnBaseModel(BaseModel): - Converts camelCase properties to snake_case """ - 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 + if PYDANTIC_V2: + model_config = ConfigDict( + alias_generator=snake_case_to_camel_case, + populate_by_name=True, + ser_json_bytes="base64", + ) - @validator("*", pre=True, allow_reuse=True) - def _validate_bytes_fields(cls, v, field: ModelField): - """ - 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 + @field_validator("*", mode="before") + def _pydantic_v2_validate_bytes_fields( + cls, v: Any, info: ValidationInfo + ) -> Any: + field = cls.model_fields[info.field_name] - if isinstance(v, bytes): - """ - Return raw bytes from subclasses as well + 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) - `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. + 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) + def _pydantic_v1_validate_bytes_fields(cls, v: Any, field: ModelField) -> Any: """ - 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 + 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) ################ diff --git a/webauthn/registration/verify_registration_response.py b/webauthn/registration/verify_registration_response.py index 46bd5f6..2e65074 100644 --- a/webauthn/registration/verify_registration_response.py +++ b/webauthn/registration/verify_registration_response.py @@ -3,7 +3,7 @@ from webauthn.helpers import ( aaguid_to_string, - bytes_to_base64url, + base64url_to_bytes, decode_credential_public_key, parse_attestation_object, parse_client_data_json, @@ -95,7 +95,7 @@ def verify_registration_response( verified = False # FIDO-specific check - if bytes_to_base64url(credential.raw_id) != credential.id: + if credential.raw_id != base64url_to_bytes(credential.id): raise InvalidRegistrationResponse("id and raw_id were not equivalent") # FIDO-specific check From 71dec1a0729135d4db7cb3dbb358a7129812c5c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigurd=20Lj=C3=B8dal?= <544451+ljodal@users.noreply.github.com> Date: Wed, 2 Aug 2023 13:48:46 +0200 Subject: [PATCH 2/9] Clean up import and unrelated changes --- tests/test_verify_authentication_response.py | 8 ++++---- tests/test_verify_registration_response.py | 2 +- webauthn/helpers/bytes_to_base64url.py | 2 +- webauthn/helpers/structs.py | 3 +-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/test_verify_authentication_response.py b/tests/test_verify_authentication_response.py index 83565e9..accaef0 100644 --- a/tests/test_verify_authentication_response.py +++ b/tests/test_verify_authentication_response.py @@ -19,7 +19,7 @@ def test_verify_authentication_response_with_EC2_public_key(self): }, "type": "public-key", "clientExtensionResults": {} - }""", + }""" ) challenge = base64url_to_bytes( "xi30GPGAFYRxVDpY1sM10DaLzVQG66nv-_7RUazH0vI2YvG8LYgDEnvN5fZZNVuvEDuMi9te3VLqb42N0fkLGA" @@ -45,7 +45,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 = AuthenticationCredential.parse_raw( @@ -60,7 +60,7 @@ def test_verify_authentication_response_with_RSA_public_key(self): }, "type": "public-key", "clientExtensionResults": {} - }""", + }""" ) challenge = base64url_to_bytes( "iPmAi1Pp1XL6oAgq3PWZtZPnZa1zFUDoGbaQ0_KvVG1lF2s3Rt_3o4uSzccy0tmcTIpTTT4BU1T-I4maavndjQ" @@ -96,7 +96,7 @@ def test_raises_exception_on_incorrect_public_key(self): }, "type": "public-key", "clientExtensionResults": {} - }""", + }""" ) challenge = base64url_to_bytes( "zsfiMZj16TUVCrT5tDRYXdYlUrJp7zn_UNd5NmBocPc4I2dKZbeEWpwBAwA4s6oHkVX6_ly_jgp743dyiWHYYw" diff --git a/tests/test_verify_registration_response.py b/tests/test_verify_registration_response.py index 614e3f3..7dab1df 100644 --- a/tests/test_verify_registration_response.py +++ b/tests/test_verify_registration_response.py @@ -60,7 +60,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 = { diff --git a/webauthn/helpers/bytes_to_base64url.py b/webauthn/helpers/bytes_to_base64url.py index e323bc9..b3bd321 100644 --- a/webauthn/helpers/bytes_to_base64url.py +++ b/webauthn/helpers/bytes_to_base64url.py @@ -5,4 +5,4 @@ def bytes_to_base64url(val: bytes) -> str: """ Base64URL-encode the provided bytes """ - return urlsafe_b64encode(val).decode("utf-8") # .replace("=", "") + return urlsafe_b64encode(val).decode("utf-8") diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index 3bb586d..2ef5443 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -1,8 +1,6 @@ from enum import Enum from typing import List, Literal, Optional, Any -from webauthn.helpers.base64url_to_bytes import base64url_to_bytes - try: from pydantic import BaseModel, field_validator, ConfigDict, ValidationInfo @@ -14,6 +12,7 @@ PYDANTIC_V2 = False +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 dc366bec089dd611b5235700aa6dc8936a99f838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigurd=20Lj=C3=B8dal?= <544451+ljodal@users.noreply.github.com> Date: Wed, 2 Aug 2023 13:56:03 +0200 Subject: [PATCH 3/9] Update setup.py and requirements.txt --- requirements.txt | 6 ++++-- setup.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2399d93..0d48191 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +annotated-types==0.5.0 asn1crypto==1.4.0 black==21.9b0 cbor2==5.4.2.post1 @@ -11,11 +12,12 @@ pathspec==0.9.0 platformdirs==2.4.0 pycodestyle==2.8.0 pycparser==2.20 -pydantic==1.10.11 +pydantic==2.1.1 +pydantic_core==2.4.0 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.2.0 +typing_extensions==4.7.1 diff --git a/setup.py b/setup.py index 0bbeed3..98d2120 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,7 @@ def find_version(*file_paths): 'asn1crypto>=1.4.0', 'cbor2>=5.4.2.post1', 'cryptography>=41.0.1', - 'pydantic>=1.10.11,<2.0a0', + 'pydantic>=1.10.11', 'pyOpenSSL>=23.2.0', ] ) From 56f72a4effab1326d43d3938a7952ad668503e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigurd=20Lj=C3=B8dal?= <544451+ljodal@users.noreply.github.com> Date: Mon, 7 Aug 2023 11:17:00 +0200 Subject: [PATCH 4/9] Strip trailing = from serialized structs --- tests/test_bytes_to_base64url.py | 2 +- tests/test_decode_credential_public_key.py | 10 +++++----- tests/test_options_to_json.py | 8 ++++---- webauthn/helpers/bytes_to_base64url.py | 2 +- webauthn/helpers/structs.py | 21 ++++++++++++++++++++- 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/tests/test_bytes_to_base64url.py b/tests/test_bytes_to_base64url.py index 6f7e369..b847b93 100644 --- a/tests/test_bytes_to_base64url.py +++ b/tests/test_bytes_to_base64url.py @@ -7,4 +7,4 @@ class TestWebAuthnBytesToBase64URL(TestCase): def test_converts_buffer_to_base64url_string(self) -> None: output = bytes_to_base64url(bytes([1, 2, 3, 4, 5])) - assert output == "AQIDBAU=" + assert output == "AQIDBAU" diff --git a/tests/test_decode_credential_public_key.py b/tests/test_decode_credential_public_key.py index 529ed12..fcc2184 100644 --- a/tests/test_decode_credential_public_key.py +++ b/tests/test_decode_credential_public_key.py @@ -24,12 +24,12 @@ def test_decodes_ec2_public_key(self) -> None: assert ( decoded.x and bytes_to_base64url(decoded.x) - == "MMcEPFOpY_jJlmcBrnbgvq4-7CGKt5TBEPmxdjpTaDE=" + == "MMcEPFOpY_jJlmcBrnbgvq4-7CGKt5TBEPmxdjpTaDE" ) assert ( decoded.y and bytes_to_base64url(decoded.y) - == "xuwbECbDdNfTTegnc174oYdusZiMmJgct0yI_ulrJGI=" + == "xuwbECbDdNfTTegnc174oYdusZiMmJgct0yI_ulrJGI" ) def test_decode_rsa_public_key(self) -> None: @@ -46,7 +46,7 @@ def test_decode_rsa_public_key(self) -> None: assert ( decoded.n and bytes_to_base64url(decoded.n) - == "8X6V649G2vwB99CSf_luwR0jj7oDg_GhA3TQSnNYIwfQJldxT5dmi9H8IjjCrTP28iNuKl29hc3Mowux1FZB0bc5AEJ2oV3JCOMGP9NZKGmOosF7iBN2GtGY7Nomcs-ruBv2mxp1nTm6mv5B8XNwh0e18uTA5AJCsl-k6lNLYB2XBIQ3fy2-TjSQ8IOMLypWQbWWBJXzLmepaJ6EWe6kf_NaxpA2chWsaekZcr8xG6OIo3iGh0Mpags_qBZtN4n2TDn0R2LheLk4yQ0R_oOAVtX963Yuw0x5NYSZyMNSMi_1RSEPTYn5AILmIzQskglDaWJYtnjKz4QLuXWCRRYyDQ==" + == "8X6V649G2vwB99CSf_luwR0jj7oDg_GhA3TQSnNYIwfQJldxT5dmi9H8IjjCrTP28iNuKl29hc3Mowux1FZB0bc5AEJ2oV3JCOMGP9NZKGmOosF7iBN2GtGY7Nomcs-ruBv2mxp1nTm6mv5B8XNwh0e18uTA5AJCsl-k6lNLYB2XBIQ3fy2-TjSQ8IOMLypWQbWWBJXzLmepaJ6EWe6kf_NaxpA2chWsaekZcr8xG6OIo3iGh0Mpags_qBZtN4n2TDn0R2LheLk4yQ0R_oOAVtX963Yuw0x5NYSZyMNSMi_1RSEPTYn5AILmIzQskglDaWJYtnjKz4QLuXWCRRYyDQ" ) def test_decode_uncompressed_ec2_public_key(self) -> None: @@ -63,10 +63,10 @@ def test_decode_uncompressed_ec2_public_key(self) -> None: assert ( decoded.x and bytes_to_base64url(decoded.x) - == "FrEpm55XKvkgIN-izKDHBF-VJ09Rw2F5mFOFcJ5MVM0=" + == "FrEpm55XKvkgIN-izKDHBF-VJ09Rw2F5mFOFcJ5MVM0" ) assert ( decoded.y and bytes_to_base64url(decoded.y) - == "o0EM9dj0V-xJ1JwpE2XZ_8NRIt5KVvr71Zl0rB8BWOs=" + == "o0EM9dj0V-xJ1JwpE2XZ_8NRIt5KVvr71Zl0rB8BWOs" ) diff --git a/tests/test_options_to_json.py b/tests/test_options_to_json.py index 86bed99..65c99e4 100644 --- a/tests/test_options_to_json.py +++ b/tests/test_options_to_json.py @@ -40,14 +40,14 @@ def test_converts_options_to_JSON(self) -> None: assert json.loads(output) == { "rp": {"name": "Example Co", "id": "example.com"}, "user": { - "id": "QUJBVjZRV1BCRVk5V09UT0ExQTQ=", + "id": "QUJBVjZRV1BCRVk5V09UT0ExQTQ", "name": "lee", "displayName": "Lee", }, - "challenge": "MTIzNDU2Nzg5MA==", + "challenge": "MTIzNDU2Nzg5MA", "pubKeyCredParams": [{"type": "public-key", "alg": -36}], "timeout": 120000, - "excludeCredentials": [{"type": "public-key", "id": "MTIzNDU2Nzg5MA=="}], + "excludeCredentials": [{"type": "public-key", "id": "MTIzNDU2Nzg5MA"}], "authenticatorSelection": { "authenticatorAttachment": "platform", "residentKey": "required", @@ -75,7 +75,7 @@ def test_includes_optional_value_when_set(self) -> None: assert json.loads(output)["excludeCredentials"] == [ { - "id": "MTIzNDU2Nzg5MA==", + "id": "MTIzNDU2Nzg5MA", "transports": ["usb"], "type": "public-key", } diff --git a/webauthn/helpers/bytes_to_base64url.py b/webauthn/helpers/bytes_to_base64url.py index b3bd321..1db9767 100644 --- a/webauthn/helpers/bytes_to_base64url.py +++ b/webauthn/helpers/bytes_to_base64url.py @@ -5,4 +5,4 @@ def bytes_to_base64url(val: bytes) -> str: """ Base64URL-encode the provided bytes """ - return urlsafe_b64encode(val).decode("utf-8") + return urlsafe_b64encode(val).decode("utf-8").rstrip("=") diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index 2ef5443..172fb0b 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -1,5 +1,7 @@ from enum import Enum -from typing import List, Literal, Optional, Any +from typing import Callable, List, Literal, Optional, Any, Dict + +from pydantic import model_serializer try: @@ -75,6 +77,23 @@ def _pydantic_v2_validate_bytes_fields( return _to_bytes(v) + @model_serializer(mode="wrap", when_used="json") + def clean_up_base64( + 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(): + value = serialized.get(name) + if field_info.annotation is bytes and isinstance(value, str): + serialized[name] = value.rstrip("=") + + return serialized + else: class Config: From 1a535c7be720bef0bc3dfff6349bbf07f004d942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigurd=20Lj=C3=B8dal?= <544451+ljodal@users.noreply.github.com> Date: Mon, 7 Aug 2023 11:18:01 +0200 Subject: [PATCH 5/9] Upgrade mypy plugin (required by pydantic plugin) --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0d48191..b5fdd21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,8 +6,8 @@ cffi==1.15.0 click==8.0.3 cryptography==41.0.1 mccabe==0.6.1 -mypy==0.910 -mypy-extensions==0.4.3 +mypy==1.4.1 +mypy-extensions==1.0.0 pathspec==0.9.0 platformdirs==2.4.0 pycodestyle==2.8.0 From 7df6aca10329d10439133a91179d91b14bf05579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigurd=20Lj=C3=B8dal?= <544451+ljodal@users.noreply.github.com> Date: Mon, 7 Aug 2023 11:33:06 +0200 Subject: [PATCH 6/9] Move import inside try-except --- webauthn/helpers/structs.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index 172fb0b..ff9b5a2 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -1,11 +1,15 @@ from enum import Enum from typing import Callable, List, Literal, Optional, Any, Dict -from pydantic import model_serializer - try: - from pydantic import BaseModel, field_validator, ConfigDict, ValidationInfo + from pydantic import ( + BaseModel, + field_validator, + ConfigDict, + ValidationInfo, + model_serializer, + ) PYDANTIC_V2 = True except ImportError: From ecf34463493da4941c7715d73a9247f9f93dcf09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigurd=20Lj=C3=B8dal?= <544451+ljodal@users.noreply.github.com> Date: Mon, 7 Aug 2023 11:42:16 +0200 Subject: [PATCH 7/9] Silence mypy warnings --- webauthn/helpers/structs.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index ff9b5a2..5c0536a 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -3,18 +3,18 @@ try: - from pydantic import ( + from pydantic import ( # type: ignore[attr-defined] BaseModel, field_validator, ConfigDict, - ValidationInfo, + FieldValidationInfo, model_serializer, ) PYDANTIC_V2 = True except ImportError: from pydantic import BaseModel, validator - from pydantic.fields import ModelField + from pydantic.fields import ModelField # type: ignore[attr-defined] PYDANTIC_V2 = False @@ -58,7 +58,7 @@ class WebAuthnBaseModel(BaseModel): """ if PYDANTIC_V2: - model_config = ConfigDict( + model_config = ConfigDict( # type: ignore[typeddict-unknown-key] alias_generator=snake_case_to_camel_case, populate_by_name=True, ser_json_bytes="base64", @@ -66,9 +66,9 @@ class WebAuthnBaseModel(BaseModel): @field_validator("*", mode="before") def _pydantic_v2_validate_bytes_fields( - cls, v: Any, info: ValidationInfo + cls, v: Any, info: FieldValidationInfo ) -> Any: - field = cls.model_fields[info.field_name] + field = cls.model_fields[info.field_name] # type: ignore[attr-defined] if field.annotation != bytes: return v @@ -91,7 +91,7 @@ def clean_up_base64( serialized = serializer(self) - for name, field_info in self.model_fields.items(): + 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("=") @@ -106,7 +106,7 @@ class Config: alias_generator = snake_case_to_camel_case allow_population_by_field_name = True - @validator("*", pre=True, allow_reuse=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 From 8d87b2f8a04a081612eacf44fbfb71818d6f0c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigurd=20Lj=C3=B8dal?= <544451+ljodal@users.noreply.github.com> Date: Mon, 7 Aug 2023 15:54:22 +0200 Subject: [PATCH 8/9] Revert flipped credential id checks --- webauthn/authentication/verify_authentication_response.py | 4 ++-- webauthn/registration/verify_registration_response.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/webauthn/authentication/verify_authentication_response.py b/webauthn/authentication/verify_authentication_response.py index 0f14d38..c16f0ff 100644 --- a/webauthn/authentication/verify_authentication_response.py +++ b/webauthn/authentication/verify_authentication_response.py @@ -4,7 +4,7 @@ from cryptography.exceptions import InvalidSignature from webauthn.helpers import ( - base64url_to_bytes, + bytes_to_base64url, decode_credential_public_key, decoded_public_key_to_cryptography, parse_authenticator_data, @@ -69,7 +69,7 @@ def verify_authentication_response( """ # FIDO-specific check - if credential.raw_id != base64url_to_bytes(credential.id): + if bytes_to_base64url(credential.raw_id) != credential.id: raise InvalidAuthenticationResponse("id and raw_id were not equivalent") # FIDO-specific check diff --git a/webauthn/registration/verify_registration_response.py b/webauthn/registration/verify_registration_response.py index 2e65074..46bd5f6 100644 --- a/webauthn/registration/verify_registration_response.py +++ b/webauthn/registration/verify_registration_response.py @@ -3,7 +3,7 @@ from webauthn.helpers import ( aaguid_to_string, - base64url_to_bytes, + bytes_to_base64url, decode_credential_public_key, parse_attestation_object, parse_client_data_json, @@ -95,7 +95,7 @@ def verify_registration_response( verified = False # FIDO-specific check - if credential.raw_id != base64url_to_bytes(credential.id): + if bytes_to_base64url(credential.raw_id) != credential.id: raise InvalidRegistrationResponse("id and raw_id were not equivalent") # FIDO-specific check From 4bbbbbd0eea3c9c95f11f3a3bb99a233958a209c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigurd=20Lj=C3=B8dal?= <544451+ljodal@users.noreply.github.com> Date: Mon, 7 Aug 2023 16:08:38 +0200 Subject: [PATCH 9/9] Change serializer method name to match validators --- 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 5c0536a..4bc3cd6 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -82,7 +82,7 @@ def _pydantic_v2_validate_bytes_fields( return _to_bytes(v) @model_serializer(mode="wrap", when_used="json") - def clean_up_base64( + def _pydantic_v2_serialize_bytes_fields( self, serializer: Callable[..., Dict[str, Any]] ) -> Dict[str, Any]: """