diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..75db9cd --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,4 @@ +# "Run Black over everything" +b134c58a394353e02c4f40808bf104f51578e7df +# "Run Black on setup.py" +64a0579be773f2ac783f8b50f1046fccc09389a8 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 diff --git a/.vscode/settings.json b/.vscode/settings.json index 3b46fe2..c20a0fa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,17 @@ { + "black-formatter.args": [ + "--line-length", "99" + ], + "mypy-type-checker.path": ["venv/bin/mypy"], "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter" - } + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnPaste": false, + "editor.formatOnSaveMode": "file", + "editor.formatOnSave": true, + }, + "python.analysis.typeCheckingMode": "basic", + "gitlens.advanced.blame.customArguments": [ + "--ignore-revs-file", + ".git-blame-ignore-revs" + ] } 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" ) 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 diff --git a/requirements.txt b/requirements.txt index fc26dcc..07bbaa4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,9 @@ -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 +cryptography==41.0.7 mccabe==0.6.1 mypy==1.4.1 mypy-extensions==1.0.0 @@ -12,12 +11,10 @@ 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 +pyOpenSSL==23.3.0 regex==2021.10.8 six==1.16.0 toml==0.10.2 tomli==1.2.1 -typing_extensions==4.7.1 +typing_extensions==4.9.0 diff --git a/setup.py b/setup.py index 6e0b8b9..6e1bf32 100644 --- a/setup.py +++ b/setup.py @@ -9,49 +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', - 'pydantic>=1.10.11', - 'pyOpenSSL>=23.2.0', - ] + "asn1crypto>=1.4.0", + "cbor2>=5.4.6", + "cryptography>=41.0.7", + "pyOpenSSL>=23.3.0", + ], ) diff --git a/tests/test_bytes_subclass_support.py b/tests/test_bytes_subclass_support.py index 1e0b4e3..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' # type: ignore - ) - - 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_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 2969df4..36df06b 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,12 +78,46 @@ 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, 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/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/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/tests/test_parse_authenticator_data.py b/tests/test_parse_authenticator_data.py index f4e3d0d..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 @@ -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,15 +146,15 @@ 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 self.assertIsNotNone(extensions) assert extensions # Make mypy happy - parsed_extensions = cbor2.loads(extensions) - self.assertEqual(parsed_extensions, {'credProtect': 1}) + parsed_extensions = parse_cbor(extensions) + self.assertEqual(parsed_extensions, {"credProtect": 1}) def test_parses_only_extension_data(self) -> None: # Pulled from Conformance Testing suite @@ -167,12 +167,12 @@ 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, { - '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_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_parse_registration_credential_json.py b/tests/test_parse_registration_credential_json.py new file mode 100644 index 0000000..9ec169b --- /dev/null +++ b/tests/test_parse_registration_credential_json.py @@ -0,0 +1,300 @@ +from unittest import TestCase + +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 + + +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) 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..0b49d33 100644 --- a/tests/test_verify_registration_response.py +++ b/tests/test_verify_registration_response.py @@ -1,9 +1,13 @@ import json from unittest import TestCase -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, + encode_cbor, + parse_registration_credential_json, + parse_cbor, +) 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 = { @@ -74,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( @@ -85,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, @@ -228,13 +236,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 +264,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/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" }` 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/authentication/verify_authentication_response.py b/webauthn/authentication/verify_authentication_response.py index 9298f71..6674f2e 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 @@ -5,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, @@ -20,11 +22,11 @@ CredentialDeviceType, PublicKeyCredentialType, TokenBindingStatus, - WebAuthnBaseModel, ) -class VerifiedAuthentication(WebAuthnBaseModel): +@dataclass +class VerifiedAuthentication: """ Information about a verified authentication of which an RP can make use """ @@ -90,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( @@ -98,9 +104,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: @@ -122,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() @@ -133,9 +137,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( @@ -153,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) @@ -165,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/__init__.py b/webauthn/helpers/__init__.py index 5a2ded0..751d5d5 100644 --- a/webauthn/helpers/__init__.py +++ b/webauthn/helpers/__init__.py @@ -1,12 +1,13 @@ 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 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 @@ -23,12 +24,13 @@ "aaguid_to_string", "base64url_to_bytes", "bytes_to_base64url", + "byteslike_to_bytes", "decode_credential_public_key", "decoded_public_key_to_cryptography", + "encode_cbor", "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/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) diff --git a/webauthn/helpers/decode_credential_public_key.py b/webauthn/helpers/decode_credential_public_key.py index e94852d..077a856 100644 --- a/webauthn/helpers/decode_credential_public_key.py +++ b/webauthn/helpers/decode_credential_public_key.py @@ -1,20 +1,23 @@ 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 +from .parse_cbor import parse_cbor -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 +25,8 @@ class DecodedEC2PublicKey(BaseModel): y: bytes -class DecodedRSAPublicKey(BaseModel): +@dataclass +class DecodedRSAPublicKey: kty: COSEKTY alg: COSEAlgorithmIdentifier n: bytes @@ -53,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/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/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 diff --git a/webauthn/helpers/exceptions.py b/webauthn/helpers/exceptions.py index 1f58ded..7776fcd 100644 --- a/webauthn/helpers/exceptions.py +++ b/webauthn/helpers/exceptions.py @@ -14,7 +14,7 @@ class UnsupportedPublicKeyType(Exception): pass -class InvalidClientDataJSONStructure(Exception): +class InvalidJSONStructure(Exception): pass 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) 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 24e6c92..0000000 --- a/webauthn/helpers/json_loads_base64url_to_bytes.py +++ /dev/null @@ -1,39 +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) diff --git a/webauthn/helpers/options_to_json.py b/webauthn/helpers/options_to_json.py index 658b073..cd11be2 100644 --- a/webauthn/helpers/options_to_json.py +++ b/webauthn/helpers/options_to_json.py @@ -1,10 +1,11 @@ -from typing import Union +import json +from typing import Union, Dict, Any from .structs import ( - PYDANTIC_V2, PublicKeyCredentialCreationOptions, PublicKeyCredentialRequestOptions, ) +from .bytes_to_base64url import bytes_to_base64url def options_to_json( @@ -16,18 +17,111 @@ 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, - ) - - else: - json_options = options.json( - by_alias=True, - exclude_unset=False, - exclude_none=True, - ) - - return json_options + if isinstance(options, PublicKeyCredentialCreationOptions): + _rp = {"name": options.rp.name} + if options.rp.id: + _rp["id"] = options.rp.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, + "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" + ) diff --git a/webauthn/helpers/parse_authentication_credential_json.py b/webauthn/helpers/parse_authentication_credential_json.py index 1aad49a..c88b84b 100644 --- a/webauthn/helpers/parse_authentication_credential_json.py +++ b/webauthn/helpers/parse_authentication_credential_json.py @@ -1,9 +1,15 @@ import json -from typing import Callable, Union -from pydantic import ValidationError +from json.decoder import JSONDecodeError +from typing import Union -from .exceptions import InvalidAuthenticationResponse -from .structs import PYDANTIC_V2, 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: @@ -11,19 +17,80 @@ 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, str): + try: + json_val = json.loads(json_val) + except JSONDecodeError: + raise InvalidJSONStructure("Unable to decode credential as JSON") - if isinstance(json_val, dict): - json_val = json.dumps(json_val) + 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): + 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_authenticator_data = cred_response.get("authenticatorData") + if not isinstance(response_authenticator_data, str): + raise InvalidJSONStructure("Credential response missing required authenticatorData") + + response_signature = cred_response.get("signature") + 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 + + # 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") + 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 authenticatorAttachment" + ) from cred_attachment_exc + else: + cred_authenticator_attachment = None try: - authentication_credential = parsing_method(json_val) - except ValidationError as exc: + 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=PublicKeyCredentialType.PUBLIC_KEY, + ) + 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_authenticator_data.py b/webauthn/helpers/parse_authenticator_data.py index 7d58de1..82742de 100644 --- a/webauthn/helpers/parse_authenticator_data.py +++ b/webauthn/helpers/parse_authenticator_data.py @@ -1,14 +1,18 @@ -import cbor2 +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 +from .encode_cbor import encode_cbor 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) @@ -77,7 +81,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,12 +93,12 @@ 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 # 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 9ca0bf7..e22ee25 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_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 152b517..e5eb187 100644 --- a/webauthn/helpers/parse_client_data_json.py +++ b/webauthn/helpers/parse_client_data_json.py @@ -1,10 +1,10 @@ import json from json.decoder import JSONDecodeError - -from pydantic import ValidationError +from typing import Union from .base64url_to_bytes import base64url_to_bytes -from .exceptions import InvalidClientDataJSONStructure +from .byteslike_to_bytes import byteslike_to_bytes +from .exceptions import InvalidJSONStructure from .structs import CollectedClientData, TokenBinding @@ -12,26 +12,20 @@ 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: - 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"], @@ -50,9 +44,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: @@ -65,7 +57,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..9cb6210 100644 --- a/webauthn/helpers/parse_registration_credential_json.py +++ b/webauthn/helpers/parse_registration_credential_json.py @@ -1,9 +1,16 @@ import json -from typing import Callable, Union -from pydantic import ValidationError +from json.decoder import JSONDecodeError +from typing import Union, Optional, List -from .exceptions import InvalidRegistrationResponse -from .structs import PYDANTIC_V2, 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: @@ -11,19 +18,80 @@ 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, str): + try: + json_val = json.loads(json_val) + except JSONDecodeError: + raise InvalidJSONStructure("Unable to decode credential as JSON") - if isinstance(json_val, dict): - json_val = json.dumps(json_val) + 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): + 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") + + 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): + transports = [] + for val in response_transports: + try: + transport_enum = AuthenticatorTransport(val) + transports.append(transport_enum) + except ValueError: + pass + + 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 authenticatorAttachment" + ) from cred_attachment_exc + else: + cred_authenticator_attachment = None try: - registration_credential = parsing_method(json_val) - except ValidationError as exc: + 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" + "Unable to parse registration credential from JSON data" ) from exc return registration_credential diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index 5bada95..a81f5e6 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -1,137 +1,8 @@ from enum import Enum -from typing import Callable, List, Literal, Optional, Any, Dict +from dataclasses import dataclass, field +from typing import List, Literal, Optional, Union - -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 -from .json_loads_base64url_to_bytes import json_loads_base64url_to_bytes -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) ################ @@ -286,7 +157,8 @@ class TokenBindingStatus(str, Enum): SUPPORTED = "supported" -class TokenBinding(WebAuthnBaseModel): +@dataclass +class TokenBinding: """ https://www.w3.org/TR/webauthn-2/#dictdef-tokenbinding """ @@ -295,7 +167,8 @@ class TokenBinding(WebAuthnBaseModel): id: Optional[str] = None -class PublicKeyCredentialRpEntity(WebAuthnBaseModel): +@dataclass +class PublicKeyCredentialRpEntity: """Information about the Relying Party. Attributes: @@ -309,7 +182,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 +199,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 +214,8 @@ class PublicKeyCredentialParameters(WebAuthnBaseModel): alg: COSEAlgorithmIdentifier -class PublicKeyCredentialDescriptor(WebAuthnBaseModel): +@dataclass +class PublicKeyCredentialDescriptor: """Information about a generated credential. Attributes: @@ -351,13 +227,12 @@ class PublicKeyCredentialDescriptor(WebAuthnBaseModel): """ id: bytes - type: Literal[ - PublicKeyCredentialType.PUBLIC_KEY - ] = PublicKeyCredentialType.PUBLIC_KEY + type: Literal[PublicKeyCredentialType.PUBLIC_KEY] = PublicKeyCredentialType.PUBLIC_KEY 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 +252,8 @@ class AuthenticatorSelectionCriteria(WebAuthnBaseModel): ] = UserVerificationRequirement.PREFERRED -class CollectedClientData(WebAuthnBaseModel): +@dataclass +class CollectedClientData: """Decoded ClientDataJSON Attributes: @@ -404,7 +280,8 @@ class CollectedClientData(WebAuthnBaseModel): ################ -class PublicKeyCredentialCreationOptions(WebAuthnBaseModel): +@dataclass +class PublicKeyCredentialCreationOptions: """Registration Options. Attributes: @@ -430,7 +307,8 @@ class PublicKeyCredentialCreationOptions(WebAuthnBaseModel): attestation: AttestationConveyancePreference = AttestationConveyancePreference.NONE -class AuthenticatorAttestationResponse(WebAuthnBaseModel): +@dataclass +class AuthenticatorAttestationResponse: """The `response` property on a registration credential. Attributes: @@ -447,7 +325,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: @@ -463,12 +342,11 @@ class RegistrationCredential(WebAuthnBaseModel): 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 -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 +365,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 +388,8 @@ class AuthenticatorDataFlags(WebAuthnBaseModel): ed: bool -class AttestedCredentialData(WebAuthnBaseModel): +@dataclass +class AttestedCredentialData: """Information about a credential. Attributes: @@ -525,7 +405,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 +427,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 +441,7 @@ class AttestationObject(WebAuthnBaseModel): fmt: AttestationFormat auth_data: AuthenticatorData - att_stmt: AttestationStatement = AttestationStatement() + att_stmt: AttestationStatement = field(default_factory=AttestationStatement) ################ @@ -569,7 +451,8 @@ class AttestationObject(WebAuthnBaseModel): ################ -class PublicKeyCredentialRequestOptions(WebAuthnBaseModel): +@dataclass +class PublicKeyCredentialRequestOptions: """Authentication Options. Attributes: @@ -585,13 +468,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]] = None user_verification: Optional[ UserVerificationRequirement ] = UserVerificationRequirement.PREFERRED -class AuthenticatorAssertionResponse(WebAuthnBaseModel): +@dataclass +class AuthenticatorAssertionResponse: """The `response` property on an authentication credential. Attributes: @@ -609,7 +493,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: @@ -625,9 +510,7 @@ class AuthenticationCredential(WebAuthnBaseModel): 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 8da9f47..cef6bdf 100644 --- a/webauthn/registration/formats/android_safetynet.py +++ b/webauthn/registration/formats/android_safetynet.py @@ -1,5 +1,7 @@ import base64 +from dataclasses import dataclass import hashlib +import json from typing import List from cryptography import x509 @@ -20,17 +22,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 @@ -69,30 +73,34 @@ 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)") - 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] @@ -125,18 +133,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: @@ -174,9 +178,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..6fda613 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 = [ @@ -75,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 ######## diff --git a/webauthn/registration/verify_registration_response.py b/webauthn/registration/verify_registration_response.py index 0aab6d9..65a0dc7 100644 --- a/webauthn/registration/verify_registration_response.py +++ b/webauthn/registration/verify_registration_response.py @@ -1,9 +1,11 @@ import hashlib +from dataclasses import dataclass, asdict from typing import List, Mapping, Optional, Union from webauthn.helpers import ( aaguid_to_string, bytes_to_base64url, + byteslike_to_bytes, decode_credential_public_key, parse_attestation_object, parse_client_data_json, @@ -13,14 +15,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 +31,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: @@ -70,12 +71,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() @@ -118,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( @@ -126,9 +126,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: @@ -150,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 @@ -171,21 +169,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") @@ -208,14 +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 - 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__) - - 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" ) @@ -225,7 +215,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, @@ -235,39 +225,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, ) @@ -292,7 +282,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, )