├── .editorconfig ├── .git-blame-ignore-revs ├── .github └── workflows │ ├── build_and_test.yml │ └── publish.yml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── authentication.py └── registration.py ├── mypy.ini ├── pyproject.toml ├── requirements.txt ├── tests ├── __init__.py ├── helpers │ ├── __init__.py │ └── x509store.py ├── test_aaguid_to_string.py ├── test_base64url_to_bytes.py ├── test_bytes_subclass_support.py ├── test_bytes_to_base64url.py ├── test_decode_credential_public_key.py ├── test_generate_authentication_options.py ├── test_generate_challenge.py ├── test_generate_registration_options.py ├── test_generate_user_handle.py ├── test_map_tpm_manufacturer_id.py ├── test_options_to_json.py ├── test_options_to_json_dict.py ├── test_parse_attestation_object.py ├── test_parse_authentication_credential_json.py ├── test_parse_authentication_options.py ├── test_parse_authenticator_data.py ├── test_parse_backup_flags.py ├── test_parse_client_data_json.py ├── test_parse_registration_credential_json.py ├── test_parse_registration_options_json.py ├── test_snake_case_to_camel_case.py ├── test_structs.py ├── test_tpm_parse_cert_info.py ├── test_tpm_parse_pub_area.py ├── test_validate_certificate_chain.py ├── test_verify_authentication_response.py ├── test_verify_registration_response.py ├── test_verify_registration_response_android_key.py ├── test_verify_registration_response_android_safetynet.py ├── test_verify_registration_response_apple.py ├── test_verify_registration_response_fido_u2f.py ├── test_verify_registration_response_packed.py ├── test_verify_registration_response_tpm.py └── test_verify_safetynet_timestamp.py └── webauthn ├── __init__.py ├── authentication ├── __init__.py ├── generate_authentication_options.py └── verify_authentication_response.py ├── helpers ├── __init__.py ├── aaguid_to_string.py ├── algorithms.py ├── asn1 │ ├── __init__.py │ └── android_key.py ├── base64url_to_bytes.py ├── bytes_to_base64url.py ├── byteslike_to_bytes.py ├── cose.py ├── decode_credential_public_key.py ├── decoded_public_key_to_cryptography.py ├── encode_cbor.py ├── exceptions.py ├── generate_challenge.py ├── generate_user_handle.py ├── hash_by_alg.py ├── known_root_certs.py ├── options_to_json.py ├── options_to_json_dict.py ├── parse_attestation_object.py ├── parse_attestation_statement.py ├── parse_authentication_credential_json.py ├── parse_authentication_options_json.py ├── parse_authenticator_data.py ├── parse_backup_flags.py ├── parse_cbor.py ├── parse_client_data_json.py ├── parse_registration_credential_json.py ├── parse_registration_options_json.py ├── pem_cert_bytes_to_open_ssl_x509.py ├── snake_case_to_camel_case.py ├── structs.py ├── tpm │ ├── __init__.py │ ├── map_tpm_manufacturer.py │ ├── parse_cert_info.py │ ├── parse_pub_area.py │ └── structs.py ├── validate_certificate_chain.py ├── verify_safetynet_timestamp.py └── verify_signature.py ├── py.typed └── registration ├── __init__.py ├── formats ├── __init__.py ├── android_key.py ├── android_safetynet.py ├── apple.py ├── fido_u2f.py ├── packed.py └── tpm.py ├── generate_registration_options.py └── verify_registration_response.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # "Run Black over everything" 2 | b134c58a394353e02c4f40808bf104f51578e7df 3 | # "Run Black on setup.py" 4 | 64a0579be773f2ac783f8b50f1046fccc09389a8 5 | -------------------------------------------------------------------------------- /.github/workflows/build_and_test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Run Unit Tests 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements.txt 30 | - name: Test with unittest 31 | run: | 32 | python -m unittest 33 | - name: Run mypy 34 | run: | 35 | python -m mypy webauthn 36 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Package to PyPI 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install build twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python -m build 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | .static_storage/ 57 | .media/ 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv*/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # PyWebAuthn 108 | py_webauthn.env 109 | webauthn.db 110 | .DS_Store 111 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "black-formatter.args": [ 3 | "--line-length", "99" 4 | ], 5 | "mypy-type-checker.path": ["venv/bin/mypy"], 6 | "[python]": { 7 | "editor.defaultFormatter": "ms-python.black-formatter", 8 | "editor.formatOnPaste": false, 9 | "editor.formatOnSaveMode": "file", 10 | "editor.formatOnSave": true, 11 | }, 12 | "python.analysis.typeCheckingMode": "basic", 13 | "gitlens.advanced.blame.customArguments": [ 14 | "--ignore-revs-file", 15 | ".git-blame-ignore-revs" 16 | ], 17 | "python.analysis.autoImportCompletions": true 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2021 Duo Security, Inc. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions 5 | are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 3. Neither the name of the copyright holder nor the names of its 13 | contributors may be used to endorse or promote products derived from 14 | this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 17 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 18 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 19 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 20 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 21 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 22 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 23 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 24 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 25 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # py_webauthn 2 | [![PyPI](https://img.shields.io/pypi/v/webauthn.svg)](https://pypi.python.org/pypi/webauthn) [![GitHub license](https://img.shields.io/badge/license-BSD-blue.svg)](https://raw.githubusercontent.com/duo-labs/py_webauthn/master/LICENSE) ![Pythonic WebAuthn](https://img.shields.io/badge/Pythonic-WebAuthn-brightgreen?logo=python&logoColor=white) 3 | 4 | A Python3 implementation of the server-side of the [WebAuthn API](https://www.w3.org/TR/webauthn-2/) focused on making it easy to leverage the power of WebAuthn. 5 | 6 | This library supports all FIDO2-compliant authenticators, including security keys, Touch ID, Face ID, Windows Hello, Android biometrics...and pretty much everything else. 7 | 8 | ## Installation 9 | 10 | This module is available on **PyPI**: 11 | 12 | `pip install webauthn` 13 | 14 | ## Requirements 15 | 16 | - Python 3.9 and up 17 | 18 | ## Usage 19 | 20 | The library exposes just a few core methods on the root `webauthn` module: 21 | 22 | - `generate_registration_options()` 23 | - `verify_registration_response()` 24 | - `generate_authentication_options()` 25 | - `verify_authentication_response()` 26 | 27 | Two additional helper methods are also exposed: 28 | 29 | - `options_to_json()` 30 | - `base64url_to_bytes()` 31 | 32 | Additional data structures are available on `webauthn.helpers.structs`. These dataclasses are useful for constructing inputs to the methods above, and for providing type hinting to help ensure consistency in the shape of data being passed around. 33 | 34 | Generally, the library makes the following assumptions about how a Relying Party implementing this library will interface with a webpage that will handle calling the WebAuthn API: 35 | 36 | - JSON is the preferred data type for transmitting registration and authentication options from the server to the webpage to feed to `navigator.credentials.create()` and `navigator.credentials.get()` respectively. 37 | - JSON is the preferred data type for transmitting WebAuthn responses from the browser to the server. 38 | - Bytes are not directly transmittable in either direction as JSON, and so should be encoded to and decoded from [base64url to avoid introducing any more dependencies than those that are specified in the WebAuthn spec](https://www.w3.org/TR/webauthn-2/#sctn-dependencies). 39 | - See the [`WebAuthnBaseModel` struct](https://github.com/duo-labs/py_webauthn/blob/master/webauthn/helpers/structs.py#L13) for more information on how this is achieved 40 | 41 | The examples mentioned below include uses of the `options_to_json()` helper (see above) to show how easily `bytes` values in registration and authentication options can be encoded to base64url for transmission to the front end. 42 | 43 | The examples also include demonstrations of how to pass JSON-ified responses, using base64url encoding for `ArrayBuffer` values, into `parse_registration_credential_json` and `parse_authentication_credential_json` to be automatically parsed by the methods in this library. An RP can pair this with corresponding custom front end logic, or one of several frontend-specific libraries (like [@simplewebauthn/browser](https://www.npmjs.com/package/@simplewebauthn/browser), for example) to handle encoding and decoding such values to and from JSON. 44 | 45 | Other arguments into this library's methods that are defined as `bytes` are intended to be values stored entirely on the server. Such values can more easily exist as `bytes` without needing potentially extraneous encoding and decoding into other formats. Any encoding or decoding of such values in the name of storing them between steps in a WebAuthn ceremony is left up to the RP to achieve in an implementation-specific manner. 46 | 47 | ### Registration 48 | 49 | See **examples/registration.py** for practical examples of using `generate_registration_options()` and `verify_registration_response()`. 50 | 51 | You can also run these examples with the following: 52 | 53 | ```sh 54 | # See "Development" below for venv setup instructions 55 | venv $> python -m examples.registration 56 | ``` 57 | 58 | ### Authentication 59 | 60 | See **examples/authentication.py** for practical examples of using `generate_authentication_options()` and `verify_authentication_response()`. 61 | 62 | You can also run these examples with the following: 63 | 64 | ```sh 65 | # See "Development" below for venv setup instructions 66 | venv $> python -m examples.authentication 67 | ``` 68 | 69 | ## Development 70 | 71 | ### Installation 72 | 73 | Set up a virtual environment, and then install the project's requirements: 74 | 75 | ```sh 76 | $> python3 -m venv venv 77 | $> source venv/bin/activate 78 | venv $> pip install -r requirements.txt 79 | ``` 80 | 81 | ### Testing 82 | 83 | Python's unittest module can be used to execute everything in the **tests/** directory: 84 | 85 | ```sh 86 | venv $> python -m unittest 87 | ``` 88 | 89 | Auto-watching unittests can be achieved with a tool like nodemon. 90 | 91 | **All tests:** 92 | ```sh 93 | venv $> nodemon --exec "python -m unittest" --ext py 94 | ``` 95 | 96 | **An individual test file:** 97 | ```sh 98 | venv $> nodemon --exec "python -m unittest tests/test_aaguid_to_string.py" --ext py 99 | ``` 100 | 101 | ### Linting and Formatting 102 | 103 | Linting is handled via `mypy`: 104 | 105 | ```sh 106 | venv $> python -m mypy webauthn 107 | Success: no issues found in 52 source files 108 | ``` 109 | 110 | The entire library is formatted using `black`: 111 | 112 | ```sh 113 | venv $> python -m black webauthn --line-length=99 114 | All done! ✨ 🍰 ✨ 115 | 52 files left unchanged. 116 | ``` 117 | -------------------------------------------------------------------------------- /examples/authentication.py: -------------------------------------------------------------------------------- 1 | from webauthn import ( 2 | generate_authentication_options, 3 | verify_authentication_response, 4 | options_to_json, 5 | base64url_to_bytes, 6 | ) 7 | from webauthn.helpers.structs import ( 8 | PublicKeyCredentialDescriptor, 9 | UserVerificationRequirement, 10 | ) 11 | 12 | ################ 13 | # 14 | # Examples of using webauthn for authentication ceremonies 15 | # 16 | # Authentication responses are representative of WebAuthn credential responses 17 | # as they would be encoded for transmission from the browser to the RP as JSON. This 18 | # primarily means byte arrays are encoded as Base64URL on the client. 19 | # 20 | ################ 21 | 22 | # Simple Options 23 | simple_authentication_options = generate_authentication_options(rp_id="example.com") 24 | 25 | print("\n[Authentication Options - Simple]") 26 | print(options_to_json(simple_authentication_options)) 27 | 28 | # Complex Options 29 | complex_authentication_options = generate_authentication_options( 30 | rp_id="example.com", 31 | challenge=b"1234567890", 32 | timeout=12000, 33 | allow_credentials=[PublicKeyCredentialDescriptor(id=b"1234567890")], 34 | user_verification=UserVerificationRequirement.REQUIRED, 35 | ) 36 | 37 | print("\n[Authentication Options - Complex]") 38 | print(options_to_json(complex_authentication_options)) 39 | 40 | # Authentication Response Verification 41 | authentication_verification = verify_authentication_response( 42 | # Demonstrating the ability to handle a stringified JSON version of the WebAuthn response 43 | credential="""{ 44 | "id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", 45 | "rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", 46 | "response": { 47 | "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAQ", 48 | "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVBtQWkxUHAxWEw2b0FncTNQV1p0WlBuWmExekZVRG9HYmFRMF9LdlZHMWxGMnMzUnRfM280dVN6Y2N5MHRtY1RJcFRUVDRCVTFULUk0bWFhdm5kalEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", 49 | "signature": "iOHKX3erU5_OYP_r_9HLZ-CexCE4bQRrxM8WmuoKTDdhAnZSeTP0sjECjvjfeS8MJzN1ArmvV0H0C3yy_FdRFfcpUPZzdZ7bBcmPh1XPdxRwY747OrIzcTLTFQUPdn1U-izCZtP_78VGw9pCpdMsv4CUzZdJbEcRtQuRS03qUjqDaovoJhOqEBmxJn9Wu8tBi_Qx7A33RbYjlfyLm_EDqimzDZhyietyop6XUcpKarKqVH0M6mMrM5zTjp8xf3W7odFCadXEJg-ERZqFM0-9Uup6kJNLbr6C5J4NDYmSm3HCSA6lp2iEiMPKU8Ii7QZ61kybXLxsX4w4Dm3fOLjmDw", 50 | "userHandle": "T1RWa1l6VXdPRFV0WW1NNVlTMDBOVEkxTFRnd056Z3RabVZpWVdZNFpEVm1ZMk5p" 51 | }, 52 | "type": "public-key", 53 | "authenticatorAttachment": "cross-platform", 54 | "clientExtensionResults": {} 55 | }""", 56 | expected_challenge=base64url_to_bytes( 57 | "iPmAi1Pp1XL6oAgq3PWZtZPnZa1zFUDoGbaQ0_KvVG1lF2s3Rt_3o4uSzccy0tmcTIpTTT4BU1T-I4maavndjQ" 58 | ), 59 | expected_rp_id="localhost", 60 | expected_origin="http://localhost:5000", 61 | credential_public_key=base64url_to_bytes( 62 | "pAEDAzkBACBZAQDfV20epzvQP-HtcdDpX-cGzdOxy73WQEvsU7Dnr9UWJophEfpngouvgnRLXaEUn_d8HGkp_HIx8rrpkx4BVs6X_B6ZjhLlezjIdJbLbVeb92BaEsmNn1HW2N9Xj2QM8cH-yx28_vCjf82ahQ9gyAr552Bn96G22n8jqFRQKdVpO-f-bvpvaP3IQ9F5LCX7CUaxptgbog1SFO6FI6ob5SlVVB00lVXsaYg8cIDZxCkkENkGiFPgwEaZ7995SCbiyCpUJbMqToLMgojPkAhWeyktu7TlK6UBWdJMHc3FPAIs0lH_2_2hKS-mGI1uZAFVAfW1X-mzKL0czUm2P1UlUox7IUMBAAE" 63 | ), 64 | credential_current_sign_count=0, 65 | require_user_verification=True, 66 | ) 67 | print("\n[Authentication Verification]") 68 | print(authentication_verification) 69 | assert authentication_verification.new_sign_count == 1 70 | -------------------------------------------------------------------------------- /examples/registration.py: -------------------------------------------------------------------------------- 1 | from webauthn import ( 2 | generate_registration_options, 3 | verify_registration_response, 4 | options_to_json, 5 | base64url_to_bytes, 6 | ) 7 | from webauthn.helpers.cose import COSEAlgorithmIdentifier 8 | from webauthn.helpers.structs import ( 9 | AttestationConveyancePreference, 10 | AuthenticatorAttachment, 11 | AuthenticatorSelectionCriteria, 12 | PublicKeyCredentialDescriptor, 13 | PublicKeyCredentialHint, 14 | ResidentKeyRequirement, 15 | ) 16 | 17 | ################ 18 | # 19 | # Examples of using webauthn for registration ceremonies 20 | # 21 | ################ 22 | 23 | # Simple Options 24 | simple_registration_options = generate_registration_options( 25 | rp_id="example.com", 26 | rp_name="Example Co", 27 | user_name="bob", 28 | ) 29 | 30 | print("\n[Registration Options - Simple]") 31 | print(options_to_json(simple_registration_options)) 32 | 33 | # Complex Options 34 | complex_registration_options = generate_registration_options( 35 | rp_id="example.com", 36 | rp_name="Example Co", 37 | user_id=bytes([1, 2, 3, 4]), 38 | user_name="lee", 39 | user_display_name="Lee", 40 | attestation=AttestationConveyancePreference.DIRECT, 41 | authenticator_selection=AuthenticatorSelectionCriteria( 42 | authenticator_attachment=AuthenticatorAttachment.PLATFORM, 43 | resident_key=ResidentKeyRequirement.REQUIRED, 44 | ), 45 | challenge=bytes([1, 2, 3, 4, 5, 6, 7, 8, 9, 0]), 46 | exclude_credentials=[ 47 | PublicKeyCredentialDescriptor(id=b"1234567890"), 48 | ], 49 | supported_pub_key_algs=[COSEAlgorithmIdentifier.ECDSA_SHA_512], 50 | timeout=12000, 51 | hints=[PublicKeyCredentialHint.CLIENT_DEVICE], 52 | ) 53 | 54 | print("\n[Registration Options - Complex]") 55 | print(options_to_json(complex_registration_options)) 56 | 57 | # Registration Response Verification 58 | registration_verification = verify_registration_response( 59 | # Demonstrating the ability to handle a plain dict version of the WebAuthn response 60 | credential={ 61 | "id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", 62 | "rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", 63 | "response": { 64 | "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVkBZ0mWDeWIDoxodDQXD2R2YFuP5K65ooYyx5lc87qDHZdjRQAAAAAAAAAAAAAAAAAAAAAAAAAAACBmggo_UlC8p2tiPVtNQ8nZ5NSxst4WS_5fnElA2viTq6QBAwM5AQAgWQEA31dtHqc70D_h7XHQ6V_nBs3Tscu91kBL7FOw56_VFiaKYRH6Z4KLr4J0S12hFJ_3fBxpKfxyMfK66ZMeAVbOl_wemY4S5Xs4yHSWy21Xm_dgWhLJjZ9R1tjfV49kDPHB_ssdvP7wo3_NmoUPYMgK-edgZ_ehttp_I6hUUCnVaTvn_m76b2j9yEPReSwl-wlGsabYG6INUhTuhSOqG-UpVVQdNJVV7GmIPHCA2cQpJBDZBohT4MBGme_feUgm4sgqVCWzKk6CzIKIz5AIVnspLbu05SulAVnSTB3NxTwCLNJR_9v9oSkvphiNbmQBVQH1tV_psyi9HM1Jtj9VJVKMeyFDAQAB", 65 | "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiQ2VUV29nbWcwY2NodWlZdUZydjhEWFhkTVpTSVFSVlpKT2dhX3hheVZWRWNCajBDdzN5NzN5aEQ0RmtHU2UtUnJQNmhQSkpBSW0zTFZpZW40aFhFTGciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", 66 | "transports": ["internal"], 67 | }, 68 | "type": "public-key", 69 | "clientExtensionResults": {}, 70 | "authenticatorAttachment": "platform", 71 | }, 72 | expected_challenge=base64url_to_bytes( 73 | "CeTWogmg0cchuiYuFrv8DXXdMZSIQRVZJOga_xayVVEcBj0Cw3y73yhD4FkGSe-RrP6hPJJAIm3LVien4hXELg" 74 | ), 75 | expected_origin="http://localhost:5000", 76 | expected_rp_id="localhost", 77 | require_user_verification=True, 78 | ) 79 | 80 | print("\n[Registration Verification - None]") 81 | print(registration_verification) 82 | assert registration_verification.credential_id == base64url_to_bytes( 83 | "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s" 84 | ) 85 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.9 3 | 4 | [mypy-asn1crypto.*] 5 | ignore_missing_imports = True 6 | 7 | [mypy-cbor2.*] 8 | ignore_missing_imports = True 9 | 10 | [mypy-OpenSSL.*] 11 | ignore_missing_imports = True 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "webauthn" 7 | dynamic = ["version"] 8 | description = "Pythonic WebAuthn" 9 | readme = "README.md" 10 | license = "BSD-3-Clause" 11 | license-files = ["LICENSE"] 12 | keywords = ["webauthn", "fido2"] 13 | authors = [{ name = "Duo Labs", email = "labs@duo.com" }] 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Intended Audience :: Developers", 17 | "Programming Language :: Python :: 3", 18 | ] 19 | requires-python = ">=3.9" 20 | dependencies = [ 21 | "asn1crypto>=1.5.1", 22 | "cbor2>=5.6.5", 23 | "cryptography>=44.0.2", 24 | "pyOpenSSL>=25.0.0", 25 | ] 26 | 27 | [project.urls] 28 | Homepage = "https://github.com/duo-labs/py_webauthn" 29 | 30 | [tool.setuptools] 31 | include-package-data = true 32 | 33 | [tool.setuptools.packages.find] 34 | include = ["webauthn", "webauthn.*"] 35 | 36 | [tool.setuptools.package-data] 37 | webauthn = ["py.typed"] 38 | 39 | [tool.setuptools.dynamic] 40 | version = { attr = "webauthn.__version__" } 41 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asn1crypto==1.5.1 2 | black==24.8.0 3 | cbor2==5.6.5 4 | cffi==1.17.1 5 | click==8.1.7 6 | cryptography==44.0.2 7 | mccabe==0.7.0 8 | mypy==1.11.2 9 | mypy-extensions==1.0.0 10 | pathspec==0.12.1 11 | platformdirs==4.3.6 12 | pycodestyle==2.12.1 13 | pycparser==2.22 14 | pyflakes==3.2.0 15 | pyOpenSSL==25.0.0 16 | regex==2024.11.6 17 | six==1.16.0 18 | toml==0.10.2 19 | tomli==2.0.1 20 | typing_extensions==4.12.2 21 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duo-labs/py_webauthn/67cacb6038c7fbc18ba532e14c0f8d18015bf7b4/tests/__init__.py -------------------------------------------------------------------------------- /tests/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duo-labs/py_webauthn/67cacb6038c7fbc18ba532e14c0f8d18015bf7b4/tests/helpers/__init__.py -------------------------------------------------------------------------------- /tests/helpers/x509store.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from unittest.mock import patch 3 | 4 | from OpenSSL.crypto import X509Store 5 | from webauthn.helpers import validate_certificate_chain 6 | 7 | 8 | def patch_validate_certificate_chain_x509store_getter(func): 9 | """ 10 | This is a very purpose-built decorator to help set a fixed time for X.509 certificate chain 11 | validation in unittests. It makes the following assumptions, all of which must be true for this 12 | decorator to remain useful: 13 | 14 | - X.509 certificate chain validation occurs in **webauthn/helpers/validate_certificate_chain.py::**`validate_certificate_chain` 15 | - `validate_certificate_chain(...)` uses `OpenSSL.crypto.X509Store` to verify certificate chains 16 | - **webauthn/helpers/__init__.py** continues to re-export `validate_certificate_chain` 17 | 18 | Usage: 19 | 20 | ``` 21 | from unittest import TestCase 22 | from datetime import datetime 23 | from OpenSSL.crypto import X509Store 24 | 25 | from .helpers.x509store import patch_validate_certificate_chain_x509store_getter 26 | 27 | class TestX509Validation(TestCase): 28 | @patch_validate_certificate_chain_x509store_getter 29 | def test_validate_x509_chain(self, patched_x509store: X509Store): 30 | patched_x509store.set_time(datetime(2021, 9, 1, 0, 0, 0)) 31 | # ... 32 | ``` 33 | """ 34 | 35 | def wrapper(*args, **kwargs): 36 | """ 37 | Using `inspect.getmodule(...)` below helps deal with the fact that, in Python 3.9 and 38 | Python 3.10, `@patch("webauthn.helpers.validate_certificate_chain._generate_new_cert_store")` 39 | errors out because `webauthn.helpers.validate_certificate_chain` is understood to be the method 40 | re-exported via `__all__` in **webauthn/helpers/__init__.py**, not the module of the same name. 41 | """ 42 | with patch.object( 43 | inspect.getmodule(validate_certificate_chain), 44 | "_generate_new_cert_store", 45 | ) as mock_generate_new_cert_store: 46 | new_cert_store = X509Store() 47 | mock_generate_new_cert_store.return_value = new_cert_store 48 | return func(*args, new_cert_store, **kwargs) 49 | 50 | return wrapper 51 | -------------------------------------------------------------------------------- /tests/test_aaguid_to_string.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers import base64url_to_bytes 4 | from webauthn.helpers.aaguid_to_string import aaguid_to_string 5 | 6 | 7 | class TestAAGUIDToString(TestCase): 8 | def test_converts_bytes_to_uuid_format(self): 9 | aaguid_bytes = base64url_to_bytes("AAAAAAAAAAAAAAAAAAAAAA") 10 | 11 | output = aaguid_to_string(aaguid_bytes) 12 | 13 | assert output == "00000000-0000-0000-0000-000000000000" 14 | -------------------------------------------------------------------------------- /tests/test_base64url_to_bytes.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers.base64url_to_bytes import base64url_to_bytes 4 | 5 | 6 | class TestWebAuthnBase64URLToBytes(TestCase): 7 | def test_converts_base64url_string_to_buffer(self) -> None: 8 | output = base64url_to_bytes("AQIDBAU") 9 | 10 | assert output == bytes([1, 2, 3, 4, 5]) 11 | -------------------------------------------------------------------------------- /tests/test_bytes_subclass_support.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn import verify_authentication_response, base64url_to_bytes 4 | from webauthn.helpers.structs import ( 5 | AuthenticationCredential, 6 | AuthenticatorAssertionResponse, 7 | ) 8 | 9 | 10 | class TestWebAuthnBytesSubclassSupport(TestCase): 11 | def test_handles_bytes_subclasses(self) -> None: 12 | """ 13 | Ensure the library can support being used in projects that might work with values that are 14 | subclasses of `bytes`. Let's embrace Python's duck-typing, not shy away from it 15 | """ 16 | 17 | class CustomBytes(bytes): 18 | def __new__(cls, data: str): 19 | data_bytes = base64url_to_bytes(data) 20 | self = bytes.__new__(cls, memoryview(data_bytes).tobytes()) 21 | return self 22 | 23 | verification = verify_authentication_response( 24 | credential=AuthenticationCredential( 25 | id="fq9Nj0nS24B5y6Pkw_h3-9GEAEA3-0LBPxE2zvTdLjDqtSeCSNYFe9VMRueSpAZxT3YDc6L1lWXdQNwI-sVNYrefEcRR1Nsb_0jpHE955WEtFud2xxZg3MvoLMxHLet63i5tajd1fHtP7I-00D6cehM8ZWlLp2T3s9lfZgVIFcA", 26 | raw_id=CustomBytes( 27 | "fq9Nj0nS24B5y6Pkw_h3-9GEAEA3-0LBPxE2zvTdLjDqtSeCSNYFe9VMRueSpAZxT3YDc6L1lWXdQNwI-sVNYrefEcRR1Nsb_0jpHE955WEtFud2xxZg3MvoLMxHLet63i5tajd1fHtP7I-00D6cehM8ZWlLp2T3s9lfZgVIFcA" 28 | ), 29 | response=AuthenticatorAssertionResponse( 30 | authenticator_data=CustomBytes( 31 | "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAABw" 32 | ), 33 | client_data_json=CustomBytes( 34 | "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiZVo0ZWVBM080ank1Rkl6cURhU0o2SkROR3UwYkJjNXpJMURqUV9rTHNvMVdOcWtHNms1bUNZZjFkdFFoVlVpQldaV2xaa3pSNU1GZWVXQ3BKUlVOWHciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" 35 | ), 36 | signature=CustomBytes( 37 | "RRWV8mYDRvK7YdQgdtZD4pJ2dh1D_IWZ_D6jsZo6FHJBoenbj0CVT5nA20vUzlRhN4R6dOEUHmUwP1F8eRBhBg" 38 | ), 39 | ), 40 | ), 41 | expected_challenge=CustomBytes( 42 | "eZ4eeA3O4jy5FIzqDaSJ6JDNGu0bBc5zI1DjQ_kLso1WNqkG6k5mCYf1dtQhVUiBWZWlZkzR5MFeeWCpJRUNXw" 43 | ), 44 | expected_rp_id="localhost", 45 | expected_origin="http://localhost:5000", 46 | credential_public_key=CustomBytes( 47 | "pAEBAycgBiFYIMz6_SUFLiDid2Yhlq0YboyJ-CDrIrNpkPUGmJp4D3Dp" 48 | ), 49 | credential_current_sign_count=3, 50 | ) 51 | 52 | assert verification.new_sign_count == 7 53 | 54 | def test_handles_memoryviews(self) -> None: 55 | """ 56 | Ensure support for libraries that leverage memoryviews 57 | """ 58 | 59 | def base64url_to_memoryview(data: str) -> memoryview: 60 | data_bytes = base64url_to_bytes(data) 61 | return memoryview(data_bytes) 62 | 63 | verification = verify_authentication_response( 64 | credential=AuthenticationCredential( 65 | id="fq9Nj0nS24B5y6Pkw_h3-9GEAEA3-0LBPxE2zvTdLjDqtSeCSNYFe9VMRueSpAZxT3YDc6L1lWXdQNwI-sVNYrefEcRR1Nsb_0jpHE955WEtFud2xxZg3MvoLMxHLet63i5tajd1fHtP7I-00D6cehM8ZWlLp2T3s9lfZgVIFcA", 66 | raw_id=base64url_to_memoryview( 67 | "fq9Nj0nS24B5y6Pkw_h3-9GEAEA3-0LBPxE2zvTdLjDqtSeCSNYFe9VMRueSpAZxT3YDc6L1lWXdQNwI-sVNYrefEcRR1Nsb_0jpHE955WEtFud2xxZg3MvoLMxHLet63i5tajd1fHtP7I-00D6cehM8ZWlLp2T3s9lfZgVIFcA" 68 | ), 69 | response=AuthenticatorAssertionResponse( 70 | authenticator_data=base64url_to_memoryview( 71 | "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAABw" 72 | ), 73 | client_data_json=base64url_to_memoryview( 74 | "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiZVo0ZWVBM080ank1Rkl6cURhU0o2SkROR3UwYkJjNXpJMURqUV9rTHNvMVdOcWtHNms1bUNZZjFkdFFoVlVpQldaV2xaa3pSNU1GZWVXQ3BKUlVOWHciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" 75 | ), 76 | signature=base64url_to_memoryview( 77 | "RRWV8mYDRvK7YdQgdtZD4pJ2dh1D_IWZ_D6jsZo6FHJBoenbj0CVT5nA20vUzlRhN4R6dOEUHmUwP1F8eRBhBg" 78 | ), 79 | ), 80 | ), 81 | expected_challenge=base64url_to_memoryview( 82 | "eZ4eeA3O4jy5FIzqDaSJ6JDNGu0bBc5zI1DjQ_kLso1WNqkG6k5mCYf1dtQhVUiBWZWlZkzR5MFeeWCpJRUNXw" 83 | ), 84 | expected_rp_id="localhost", 85 | expected_origin="http://localhost:5000", 86 | credential_public_key=base64url_to_memoryview( 87 | "pAEBAycgBiFYIMz6_SUFLiDid2Yhlq0YboyJ-CDrIrNpkPUGmJp4D3Dp" 88 | ), 89 | credential_current_sign_count=3, 90 | ) 91 | 92 | assert verification.new_sign_count == 7 93 | -------------------------------------------------------------------------------- /tests/test_bytes_to_base64url.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers.bytes_to_base64url import bytes_to_base64url 4 | 5 | 6 | class TestWebAuthnBytesToBase64URL(TestCase): 7 | def test_converts_buffer_to_base64url_string(self) -> None: 8 | output = bytes_to_base64url(bytes([1, 2, 3, 4, 5])) 9 | 10 | assert output == "AQIDBAU" 11 | -------------------------------------------------------------------------------- /tests/test_decode_credential_public_key.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers import base64url_to_bytes, bytes_to_base64url 4 | from webauthn.helpers.cose import COSEKTY, COSEAlgorithmIdentifier 5 | from webauthn.helpers.decode_credential_public_key import ( 6 | DecodedEC2PublicKey, 7 | DecodedRSAPublicKey, 8 | decode_credential_public_key, 9 | ) 10 | 11 | 12 | class TestDecodeCredentialPublicKey(TestCase): 13 | def test_decodes_ec2_public_key(self) -> None: 14 | decoded = decode_credential_public_key( 15 | base64url_to_bytes( 16 | "pQECAyYgASFYIDDHBDxTqWP4yZZnAa524L6uPuwhireUwRD5sXY6U2gxIlggxuwbECbDdNfTTegnc174oYdusZiMmJgct0yI_ulrJGI" 17 | ) 18 | ) 19 | 20 | assert isinstance(decoded, DecodedEC2PublicKey) 21 | assert decoded.kty == COSEKTY.EC2 22 | assert decoded.alg == COSEAlgorithmIdentifier.ECDSA_SHA_256 23 | assert decoded.crv == 1 24 | assert ( 25 | decoded.x 26 | and bytes_to_base64url(decoded.x) == "MMcEPFOpY_jJlmcBrnbgvq4-7CGKt5TBEPmxdjpTaDE" 27 | ) 28 | assert ( 29 | decoded.y 30 | and bytes_to_base64url(decoded.y) == "xuwbECbDdNfTTegnc174oYdusZiMmJgct0yI_ulrJGI" 31 | ) 32 | 33 | def test_decode_rsa_public_key(self) -> None: 34 | decoded = decode_credential_public_key( 35 | base64url_to_bytes( 36 | "pAEDAzkBACBZAQDxfpXrj0ba_AH30JJ_-W7BHSOPugOD8aEDdNBKc1gjB9AmV3FPl2aL0fwiOMKtM_byI24qXb2FzcyjC7HUVkHRtzkAQnahXckI4wY_01koaY6iwXuIE3Ya0Zjs2iZyz6u4G_abGnWdObqa_kHxc3CHR7Xy5MDkAkKyX6TqU0tgHZcEhDd_Lb5ONJDwg4wvKlZBtZYElfMuZ6lonoRZ7qR_81rGkDZyFaxp6RlyvzEbo4ijeIaHQylqCz-oFm03ifZMOfRHYuF4uTjJDRH-g4BW1f3rdi7DTHk1hJnIw1IyL_VFIQ9NifkAguYjNCySCUNpYli2eMrPhAu5dYJFFjINIUMBAAE" 37 | ) 38 | ) 39 | 40 | assert isinstance(decoded, DecodedRSAPublicKey) 41 | assert decoded.kty == COSEKTY.RSA 42 | assert decoded.alg == COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256 43 | assert decoded.e and bytes_to_base64url(decoded.e) == "AQAB" 44 | assert ( 45 | decoded.n 46 | and bytes_to_base64url(decoded.n) 47 | == "8X6V649G2vwB99CSf_luwR0jj7oDg_GhA3TQSnNYIwfQJldxT5dmi9H8IjjCrTP28iNuKl29hc3Mowux1FZB0bc5AEJ2oV3JCOMGP9NZKGmOosF7iBN2GtGY7Nomcs-ruBv2mxp1nTm6mv5B8XNwh0e18uTA5AJCsl-k6lNLYB2XBIQ3fy2-TjSQ8IOMLypWQbWWBJXzLmepaJ6EWe6kf_NaxpA2chWsaekZcr8xG6OIo3iGh0Mpags_qBZtN4n2TDn0R2LheLk4yQ0R_oOAVtX963Yuw0x5NYSZyMNSMi_1RSEPTYn5AILmIzQskglDaWJYtnjKz4QLuXWCRRYyDQ" 48 | ) 49 | 50 | def test_decode_uncompressed_ec2_public_key(self) -> None: 51 | decoded = decode_credential_public_key( 52 | base64url_to_bytes( 53 | "BBaxKZueVyr5ICDfosygxwRflSdPUcNheZhThXCeTFTNo0EM9dj0V+xJ1JwpE2XZ/8NRIt5KVvr71Zl0rB8BWOs=" 54 | ) 55 | ) 56 | 57 | assert isinstance(decoded, DecodedEC2PublicKey) 58 | assert decoded.kty == COSEKTY.EC2 59 | assert decoded.alg == COSEAlgorithmIdentifier.ECDSA_SHA_256 60 | assert decoded.crv == 1 61 | assert ( 62 | decoded.x 63 | and bytes_to_base64url(decoded.x) == "FrEpm55XKvkgIN-izKDHBF-VJ09Rw2F5mFOFcJ5MVM0" 64 | ) 65 | assert ( 66 | decoded.y 67 | and bytes_to_base64url(decoded.y) == "o0EM9dj0V-xJ1JwpE2XZ_8NRIt5KVvr71Zl0rB8BWOs" 68 | ) 69 | -------------------------------------------------------------------------------- /tests/test_generate_authentication_options.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock, patch 3 | 4 | from webauthn import generate_authentication_options 5 | from webauthn.helpers.structs import ( 6 | PublicKeyCredentialDescriptor, 7 | UserVerificationRequirement, 8 | ) 9 | 10 | 11 | class TestWebAuthnGenerateAttestationOptions(TestCase): 12 | @patch("secrets.token_bytes") 13 | def test_generates_options_with_defaults(self, token_bytes_mock: MagicMock) -> None: 14 | token_bytes_mock.return_value = b"12345" 15 | 16 | options = generate_authentication_options(rp_id="example.com") 17 | 18 | assert options.challenge == b"12345" 19 | assert options.timeout == 60000 20 | assert options.rp_id == "example.com" 21 | assert options.allow_credentials == [] 22 | assert options.user_verification == UserVerificationRequirement.PREFERRED 23 | 24 | def test_generates_options_with_custom_values(self) -> None: 25 | options = generate_authentication_options( 26 | rp_id="example.com", 27 | allow_credentials=[ 28 | PublicKeyCredentialDescriptor(id=b"12345"), 29 | ], 30 | challenge=b"this_is_a_challenge", 31 | timeout=12000, 32 | user_verification=UserVerificationRequirement.REQUIRED, 33 | ) 34 | 35 | assert options.challenge == b"this_is_a_challenge" 36 | assert options.timeout == 12000 37 | assert options.rp_id == "example.com" 38 | assert options.allow_credentials == [ 39 | PublicKeyCredentialDescriptor(id=b"12345"), 40 | ] 41 | assert options.user_verification == UserVerificationRequirement.REQUIRED 42 | 43 | def test_raises_on_empty_rp_id(self) -> None: 44 | with self.assertRaisesRegex(ValueError, "rp_id"): 45 | generate_authentication_options( 46 | rp_id="", 47 | ) 48 | -------------------------------------------------------------------------------- /tests/test_generate_challenge.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers.generate_challenge import generate_challenge 4 | 5 | 6 | class TestWebAuthnGenerateChallenge(TestCase): 7 | def test_generates_byte_sequence(self) -> None: 8 | output = generate_challenge() 9 | 10 | self.assertEqual(type(output), bytes) 11 | self.assertEqual(len(output), 64) 12 | 13 | def test_generates_unique_value_each_time(self) -> None: 14 | output1 = generate_challenge() 15 | output2 = generate_challenge() 16 | 17 | self.assertNotEqual(output1, output2) 18 | -------------------------------------------------------------------------------- /tests/test_generate_registration_options.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock, patch 3 | 4 | from webauthn.helpers.cose import COSEAlgorithmIdentifier 5 | from webauthn.helpers.structs import ( 6 | AttestationConveyancePreference, 7 | AuthenticatorAttachment, 8 | AuthenticatorSelectionCriteria, 9 | PublicKeyCredentialDescriptor, 10 | PublicKeyCredentialParameters, 11 | PublicKeyCredentialRpEntity, 12 | PublicKeyCredentialUserEntity, 13 | ResidentKeyRequirement, 14 | ) 15 | from webauthn import generate_registration_options 16 | 17 | 18 | class TestGenerateRegistrationOptions(TestCase): 19 | @patch("secrets.token_bytes") 20 | def test_generates_options_with_defaults(self, token_bytes_mock: MagicMock) -> None: 21 | token_bytes_mock.return_value = b"12345" 22 | user_id = "ABAV6QWPBEY9WOTOA1A4".encode("utf-8") 23 | 24 | options = generate_registration_options( 25 | rp_id="example.com", 26 | rp_name="Example Co", 27 | user_id=user_id, 28 | user_name="lee", 29 | ) 30 | 31 | assert options.rp == PublicKeyCredentialRpEntity( 32 | id="example.com", 33 | name="Example Co", 34 | ) 35 | assert options.challenge == b"12345" 36 | assert options.user == PublicKeyCredentialUserEntity( 37 | id=user_id, 38 | name="lee", 39 | display_name="lee", 40 | ) 41 | assert options.pub_key_cred_params[0] == PublicKeyCredentialParameters( 42 | type="public-key", 43 | alg=COSEAlgorithmIdentifier.ECDSA_SHA_256, 44 | ) 45 | assert options.timeout == 60000 46 | assert options.exclude_credentials == [] 47 | assert options.authenticator_selection is None 48 | assert options.attestation == AttestationConveyancePreference.NONE 49 | 50 | def test_generates_options_with_custom_values(self) -> None: 51 | user_id = "ABAV6QWPBEY9WOTOA1A4".encode("utf-8") 52 | 53 | options = generate_registration_options( 54 | rp_id="example.com", 55 | rp_name="Example Co", 56 | user_id=user_id, 57 | user_name="lee", 58 | user_display_name="Lee", 59 | attestation=AttestationConveyancePreference.DIRECT, 60 | authenticator_selection=AuthenticatorSelectionCriteria( 61 | authenticator_attachment=AuthenticatorAttachment.PLATFORM, 62 | resident_key=ResidentKeyRequirement.REQUIRED, 63 | ), 64 | challenge=b"1234567890", 65 | exclude_credentials=[ 66 | PublicKeyCredentialDescriptor(id=b"1234567890"), 67 | ], 68 | supported_pub_key_algs=[COSEAlgorithmIdentifier.ECDSA_SHA_512], 69 | timeout=120000, 70 | ) 71 | 72 | assert options.rp == PublicKeyCredentialRpEntity(id="example.com", name="Example Co") 73 | assert options.challenge == b"1234567890" 74 | assert options.user == PublicKeyCredentialUserEntity( 75 | id=user_id, 76 | name="lee", 77 | display_name="Lee", 78 | ) 79 | assert options.pub_key_cred_params[0] == PublicKeyCredentialParameters( 80 | type="public-key", 81 | alg=COSEAlgorithmIdentifier.ECDSA_SHA_512, 82 | ) 83 | assert options.timeout == 120000 84 | assert options.exclude_credentials == [PublicKeyCredentialDescriptor(id=b"1234567890")] 85 | assert options.authenticator_selection == AuthenticatorSelectionCriteria( 86 | authenticator_attachment=AuthenticatorAttachment.PLATFORM, 87 | resident_key=ResidentKeyRequirement.REQUIRED, 88 | require_resident_key=True, 89 | ) 90 | assert options.attestation == AttestationConveyancePreference.DIRECT 91 | 92 | def test_raises_on_empty_rp_id(self) -> None: 93 | with self.assertRaisesRegex(ValueError, "rp_id"): 94 | generate_registration_options( 95 | rp_id="", 96 | rp_name="Example Co", 97 | user_name="blah", 98 | ) 99 | 100 | def test_raises_on_empty_rp_name(self) -> None: 101 | with self.assertRaisesRegex(ValueError, "rp_name"): 102 | generate_registration_options( 103 | rp_id="example.com", 104 | rp_name="", 105 | user_name="blah", 106 | ) 107 | 108 | @patch("secrets.token_bytes") 109 | def test_generated_random_id_on_empty_user_id(self, token_bytes_mock: MagicMock) -> None: 110 | token_bytes_mock.return_value = bytes([1, 2, 3, 4]) 111 | 112 | options = generate_registration_options( 113 | rp_id="example.com", 114 | rp_name="Example Co", 115 | user_name="blah", 116 | user_id=None, 117 | ) 118 | 119 | self.assertEqual(options.user.id, bytes([1, 2, 3, 4])) 120 | 121 | def test_raises_on_non_bytes_user_id(self) -> None: 122 | with self.assertRaisesRegex(ValueError, "user_id"): 123 | generate_registration_options( 124 | rp_id="example.com", 125 | rp_name="Example Co", 126 | user_name="hello", 127 | user_id="hello", # type: ignore 128 | ) 129 | 130 | def test_raises_on_empty_user_name(self) -> None: 131 | with self.assertRaisesRegex(ValueError, "user_name"): 132 | generate_registration_options( 133 | rp_id="example.com", 134 | rp_name="Example Co", 135 | user_name="", 136 | ) 137 | -------------------------------------------------------------------------------- /tests/test_generate_user_handle.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers import generate_user_handle 4 | 5 | 6 | class TestWebAuthnGenerateUserHandle(TestCase): 7 | def test_generates_byte_sequence(self) -> None: 8 | output = generate_user_handle() 9 | 10 | assert type(output) == bytes 11 | assert len(output) == 64 12 | 13 | def test_generates_unique_value_each_time(self) -> None: 14 | output1 = generate_user_handle() 15 | output2 = generate_user_handle() 16 | 17 | assert output1 != output2 18 | -------------------------------------------------------------------------------- /tests/test_map_tpm_manufacturer_id.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers.tpm import map_tpm_manufacturer_id 4 | 5 | 6 | class TestWebAuthnGenerateUserHandle(TestCase): 7 | def test_handles_recognized_id(self) -> None: 8 | info = map_tpm_manufacturer_id("id:4353434F") 9 | 10 | self.assertEqual(info.name, "Cisco") 11 | self.assertEqual(info.id, "CSCO") 12 | 13 | def test_raises_on_unrecognized_id(self) -> None: 14 | with self.assertRaises(KeyError): 15 | map_tpm_manufacturer_id("id:FFFFFFFF") 16 | -------------------------------------------------------------------------------- /tests/test_options_to_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest import TestCase 3 | 4 | from webauthn.helpers.cose import COSEAlgorithmIdentifier 5 | from webauthn.helpers.options_to_json import options_to_json 6 | from webauthn.helpers.structs import ( 7 | AttestationConveyancePreference, 8 | AuthenticatorAttachment, 9 | AuthenticatorSelectionCriteria, 10 | AuthenticatorTransport, 11 | PublicKeyCredentialDescriptor, 12 | PublicKeyCredentialHint, 13 | ResidentKeyRequirement, 14 | UserVerificationRequirement, 15 | ) 16 | from webauthn import generate_registration_options, generate_authentication_options 17 | 18 | 19 | class TestWebAuthnOptionsToJSON(TestCase): 20 | maxDiff = None 21 | 22 | def test_converts_registration_options_to_JSON(self) -> None: 23 | options = generate_registration_options( 24 | rp_id="example.com", 25 | rp_name="Example Co", 26 | user_id=bytes([1, 2, 3, 4]), 27 | user_name="lee", 28 | user_display_name="Lee", 29 | attestation=AttestationConveyancePreference.DIRECT, 30 | authenticator_selection=AuthenticatorSelectionCriteria( 31 | authenticator_attachment=AuthenticatorAttachment.PLATFORM, 32 | resident_key=ResidentKeyRequirement.REQUIRED, 33 | ), 34 | challenge=b"1234567890", 35 | exclude_credentials=[ 36 | PublicKeyCredentialDescriptor(id=b"1234567890"), 37 | ], 38 | supported_pub_key_algs=[COSEAlgorithmIdentifier.ECDSA_SHA_512], 39 | timeout=120000, 40 | hints=[ 41 | PublicKeyCredentialHint.SECURITY_KEY, 42 | PublicKeyCredentialHint.CLIENT_DEVICE, 43 | PublicKeyCredentialHint.HYBRID, 44 | ], 45 | ) 46 | 47 | output = options_to_json(options) 48 | 49 | self.assertEqual( 50 | json.loads(output), 51 | { 52 | "rp": {"name": "Example Co", "id": "example.com"}, 53 | "user": { 54 | "id": "AQIDBA", 55 | "name": "lee", 56 | "displayName": "Lee", 57 | }, 58 | "challenge": "MTIzNDU2Nzg5MA", 59 | "pubKeyCredParams": [{"type": "public-key", "alg": -36}], 60 | "timeout": 120000, 61 | "excludeCredentials": [{"type": "public-key", "id": "MTIzNDU2Nzg5MA"}], 62 | "authenticatorSelection": { 63 | "authenticatorAttachment": "platform", 64 | "residentKey": "required", 65 | "requireResidentKey": True, 66 | "userVerification": "preferred", 67 | }, 68 | "attestation": "direct", 69 | "hints": ["security-key", "client-device", "hybrid"], 70 | }, 71 | ) 72 | 73 | def test_includes_optional_value_when_set(self) -> None: 74 | options = generate_registration_options( 75 | rp_id="example.com", 76 | rp_name="Example Co", 77 | user_name="lee", 78 | exclude_credentials=[ 79 | PublicKeyCredentialDescriptor( 80 | id=b"1234567890", 81 | transports=[AuthenticatorTransport.USB], 82 | ) 83 | ], 84 | ) 85 | 86 | output = options_to_json(options) 87 | 88 | self.assertEqual( 89 | json.loads(output)["excludeCredentials"], 90 | [ 91 | { 92 | "id": "MTIzNDU2Nzg5MA", 93 | "transports": ["usb"], 94 | "type": "public-key", 95 | } 96 | ], 97 | ) 98 | 99 | def test_converts_authentication_options_to_JSON(self) -> None: 100 | options = generate_authentication_options( 101 | rp_id="example.com", 102 | challenge=b"1234567890", 103 | allow_credentials=[ 104 | PublicKeyCredentialDescriptor(id=b"1234567890"), 105 | ], 106 | timeout=120000, 107 | user_verification=UserVerificationRequirement.DISCOURAGED, 108 | ) 109 | 110 | output = options_to_json(options) 111 | 112 | self.assertEqual( 113 | json.loads(output), 114 | { 115 | "rpId": "example.com", 116 | "challenge": "MTIzNDU2Nzg5MA", 117 | "allowCredentials": [{"type": "public-key", "id": "MTIzNDU2Nzg5MA"}], 118 | "timeout": 120000, 119 | "userVerification": "discouraged", 120 | }, 121 | ) 122 | 123 | def test_raises_on_bad_input(self) -> None: 124 | class FooClass: 125 | pass 126 | 127 | with self.assertRaisesRegex(TypeError, "not instance"): 128 | options_to_json(FooClass()) # type: ignore 129 | -------------------------------------------------------------------------------- /tests/test_options_to_json_dict.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers.cose import COSEAlgorithmIdentifier 4 | from webauthn.helpers.options_to_json_dict import options_to_json_dict 5 | from webauthn.helpers.structs import ( 6 | AttestationConveyancePreference, 7 | AuthenticatorAttachment, 8 | AuthenticatorSelectionCriteria, 9 | PublicKeyCredentialDescriptor, 10 | PublicKeyCredentialHint, 11 | ResidentKeyRequirement, 12 | UserVerificationRequirement, 13 | ) 14 | from webauthn import generate_registration_options, generate_authentication_options 15 | 16 | 17 | class TestWebAuthnOptionsToJSON(TestCase): 18 | maxDiff = None 19 | 20 | def test_converts_registration_options_to_JSON(self) -> None: 21 | options = generate_registration_options( 22 | rp_id="example.com", 23 | rp_name="Example Co", 24 | user_id=bytes([1, 2, 3, 4]), 25 | user_name="lee", 26 | user_display_name="Lee", 27 | attestation=AttestationConveyancePreference.DIRECT, 28 | authenticator_selection=AuthenticatorSelectionCriteria( 29 | authenticator_attachment=AuthenticatorAttachment.PLATFORM, 30 | resident_key=ResidentKeyRequirement.REQUIRED, 31 | ), 32 | challenge=b"1234567890", 33 | exclude_credentials=[ 34 | PublicKeyCredentialDescriptor(id=b"1234567890"), 35 | ], 36 | supported_pub_key_algs=[COSEAlgorithmIdentifier.ECDSA_SHA_512], 37 | timeout=120000, 38 | hints=[ 39 | PublicKeyCredentialHint.SECURITY_KEY, 40 | PublicKeyCredentialHint.CLIENT_DEVICE, 41 | PublicKeyCredentialHint.HYBRID, 42 | ], 43 | ) 44 | 45 | output = options_to_json_dict(options) 46 | 47 | self.assertEqual( 48 | output, 49 | { 50 | "rp": {"name": "Example Co", "id": "example.com"}, 51 | "user": { 52 | "id": "AQIDBA", 53 | "name": "lee", 54 | "displayName": "Lee", 55 | }, 56 | "challenge": "MTIzNDU2Nzg5MA", 57 | "pubKeyCredParams": [{"type": "public-key", "alg": -36}], 58 | "timeout": 120000, 59 | "excludeCredentials": [{"type": "public-key", "id": "MTIzNDU2Nzg5MA"}], 60 | "authenticatorSelection": { 61 | "authenticatorAttachment": "platform", 62 | "residentKey": "required", 63 | "requireResidentKey": True, 64 | "userVerification": "preferred", 65 | }, 66 | "attestation": "direct", 67 | "hints": ["security-key", "client-device", "hybrid"], 68 | }, 69 | ) 70 | 71 | def test_converts_authentication_options_to_JSON(self) -> None: 72 | options = generate_authentication_options( 73 | rp_id="example.com", 74 | challenge=b"1234567890", 75 | allow_credentials=[ 76 | PublicKeyCredentialDescriptor(id=b"1234567890"), 77 | ], 78 | timeout=120000, 79 | user_verification=UserVerificationRequirement.DISCOURAGED, 80 | ) 81 | 82 | output = options_to_json_dict(options) 83 | 84 | self.assertEqual( 85 | output, 86 | { 87 | "rpId": "example.com", 88 | "challenge": "MTIzNDU2Nzg5MA", 89 | "allowCredentials": [{"type": "public-key", "id": "MTIzNDU2Nzg5MA"}], 90 | "timeout": 120000, 91 | "userVerification": "discouraged", 92 | }, 93 | ) 94 | -------------------------------------------------------------------------------- /tests/test_parse_attestation_object.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers import base64url_to_bytes 4 | from webauthn.helpers.cose import COSEAlgorithmIdentifier 5 | from webauthn.helpers.parse_attestation_object import parse_attestation_object 6 | from webauthn.helpers.structs import AttestationFormat 7 | 8 | 9 | class TestParseAttestationObject(TestCase): 10 | """ 11 | To generate values to pass into `parse_attestation_object()`: 12 | 13 | 1. Perform an attestation in the browser until you receive a value back from 14 | `navigator.credentials.create()` 15 | 2. Base64URL-encode the value of `response.attestationObject` to make it easy to 16 | include in tests below 17 | """ 18 | 19 | def test_can_parse_good_none_attestation_object(self) -> None: 20 | attestation_object = base64url_to_bytes( 21 | "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAKwAAAAAAAAAAAAAAAAAAAAAAQBGidLzwQ3gFpfrQexcVIM1EKISJJUorv8HP1BQ-Km2G7Lw5VyGWRVG7pibkOG3OJ2LYG9tZY57jUrSjOqfEJgilAQIDJiABIVggBlNr3gyj0tq3QJrYd7t74BncruqqAVPCDlMfaU-7_kIiWCCXa5Oxqay8XoVwsTYzLSDp-ULUIAalnTyiQJrrrRWB_A" 22 | ) 23 | 24 | output = parse_attestation_object(attestation_object) 25 | 26 | assert output.fmt == AttestationFormat.NONE 27 | # attestation statement 28 | att_stmt = output.att_stmt 29 | assert att_stmt.sig is None 30 | assert att_stmt.x5c is None 31 | assert att_stmt.response is None 32 | assert att_stmt.alg is None 33 | assert att_stmt.ver is None 34 | assert att_stmt.cert_info is None 35 | assert att_stmt.pub_area is None 36 | # authenticator data 37 | auth_data = output.auth_data 38 | assert auth_data.rp_id_hash == base64url_to_bytes( 39 | "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2M" 40 | ) 41 | assert auth_data.flags.up 42 | assert auth_data.flags.uv 43 | assert auth_data.flags.at 44 | assert not auth_data.flags.ed 45 | assert auth_data.sign_count == 43 46 | # attested credential data 47 | assert auth_data.attested_credential_data 48 | attested_cred_data = auth_data.attested_credential_data 49 | assert attested_cred_data.aaguid == base64url_to_bytes("AAAAAAAAAAAAAAAAAAAAAA") 50 | assert attested_cred_data.credential_id == base64url_to_bytes( 51 | "EaJ0vPBDeAWl-tB7FxUgzUQohIklSiu_wc_UFD4qbYbsvDlXIZZFUbumJuQ4bc4nYtgb21ljnuNStKM6p8QmCA" 52 | ) 53 | assert attested_cred_data.credential_public_key == base64url_to_bytes( 54 | "pQECAyYgASFYIAZTa94Mo9Lat0Ca2He7e-AZ3K7qqgFTwg5TH2lPu_5CIlggl2uTsamsvF6FcLE2My0g6flC1CAGpZ08okCa660Vgfw" 55 | ) 56 | # extensions 57 | assert not auth_data.extensions 58 | 59 | def test_can_parse_good_packed_attestation_object(self) -> None: 60 | attestation_object = base64url_to_bytes( 61 | "o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEcwRQIgRpuZ6hdaLAgWgCFTIo4BGSTBAxwwqk4u3s1-JAzv_H4CIQCZnfoic34aOwlac1A09eflEtb0V1kO7yGhHOw5P5wVWmN4NWOBWQLBMIICvTCCAaWgAwIBAgIEKudiYzANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbjELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEnMCUGA1UEAwweWXViaWNvIFUyRiBFRSBTZXJpYWwgNzE5ODA3MDc1MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKgOGXmBD2Z4R_xCqJVRXhL8Jr45rHjsyFykhb1USGozZENOZ3cdovf5Ke8fj2rxi5tJGn_VnW4_6iQzKdIaeP6NsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQbUS6m_bsLkm5MAyP6SDLczAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQByV9A83MPhFWmEkNb4DvlbUwcjc9nmRzJjKxHc3HeK7GvVkm0H4XucVDB4jeMvTke0WHb_jFUiApvpOHh5VyMx5ydwFoKKcRs5x0_WwSWL0eTZ5WbVcHkDR9pSNcA_D_5AsUKOBcbpF5nkdVRxaQHuuIuwV4k1iK2IqtMNcU8vL6w21U261xCcWwJ6sMq4zzVO8QCKCQhsoIaWrwz828GDmPzfAjFsJiLJXuYivdHACkeJ5KHMt0mjVLpfJ2BCML7_rgbmvwL7wBW80VHfNdcKmKjkLcpEiPzwcQQhiN_qHV90t-p4iyr5xRSpurlP5zic2hlRkLKxMH2_kRjhqSn4aGF1dGhEYXRhWMRJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY0UAAAAqbUS6m_bsLkm5MAyP6SDLcwBAFsWBrFcw8yRjxV8z18Egh91o1AScNRYkIuUoY6wIlIhslDpP7eydKi1q5s9g1ugDP9mqBlPDDFPRbH6YLwHbtqUBAgMmIAEhWCAq3y0RWh8nLzanBZQwTA7yAbUy9KEDAM0b3N9Elrb0VCJYIJrX7ygtpyInb5mXBE7g9YEow6xWrJ400HhL2r4q5tzV", 62 | ) 63 | 64 | output = parse_attestation_object(attestation_object) 65 | 66 | assert output.fmt == AttestationFormat.PACKED 67 | # attestation statement 68 | att_stmt = output.att_stmt 69 | assert att_stmt.sig and att_stmt.sig == base64url_to_bytes( 70 | "MEUCIEabmeoXWiwIFoAhUyKOARkkwQMcMKpOLt7NfiQM7_x-AiEAmZ36InN-GjsJWnNQNPXn5RLW9FdZDu8hoRzsOT-cFVo" 71 | ) 72 | assert att_stmt.x5c and len(att_stmt.x5c) == 1 73 | assert att_stmt.response is None 74 | assert att_stmt.alg == COSEAlgorithmIdentifier.ECDSA_SHA_256 75 | assert att_stmt.ver is None 76 | assert att_stmt.cert_info is None 77 | assert att_stmt.pub_area is None 78 | # authenticator data 79 | auth_data = output.auth_data 80 | assert auth_data.rp_id_hash == base64url_to_bytes( 81 | "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2M" 82 | ) 83 | assert auth_data.flags.up 84 | assert auth_data.flags.uv 85 | assert auth_data.flags.at 86 | assert not auth_data.flags.ed 87 | assert auth_data.sign_count == 42 88 | # attested credential data 89 | assert auth_data.attested_credential_data 90 | attested_cred_data = auth_data.attested_credential_data 91 | assert attested_cred_data.aaguid == base64url_to_bytes("bUS6m_bsLkm5MAyP6SDLcw") 92 | assert attested_cred_data.credential_id == base64url_to_bytes( 93 | "FsWBrFcw8yRjxV8z18Egh91o1AScNRYkIuUoY6wIlIhslDpP7eydKi1q5s9g1ugDP9mqBlPDDFPRbH6YLwHbtg" 94 | ) 95 | assert attested_cred_data.credential_public_key == base64url_to_bytes( 96 | "pQECAyYgASFYICrfLRFaHycvNqcFlDBMDvIBtTL0oQMAzRvc30SWtvRUIlggmtfvKC2nIidvmZcETuD1gSjDrFasnjTQeEvavirm3NU" 97 | ) 98 | # extensions 99 | assert auth_data.extensions is None 100 | -------------------------------------------------------------------------------- /tests/test_parse_authentication_options.py: -------------------------------------------------------------------------------- 1 | from email.mime import base 2 | from unittest import TestCase 3 | 4 | from webauthn.helpers import base64url_to_bytes, options_to_json 5 | from webauthn.helpers.exceptions import InvalidJSONStructure 6 | from webauthn.helpers.structs import ( 7 | AuthenticatorTransport, 8 | PublicKeyCredentialDescriptor, 9 | UserVerificationRequirement, 10 | ) 11 | from webauthn.helpers.parse_authentication_options_json import parse_authentication_options_json 12 | from webauthn.authentication.generate_authentication_options import generate_authentication_options 13 | 14 | 15 | class TestParseAuthenticationOptionsJSON(TestCase): 16 | maxDiff = None 17 | 18 | def test_returns_parsed_options_simple(self) -> None: 19 | opts = parse_authentication_options_json( 20 | { 21 | "challenge": "skxyhJljbw-ZQn-g1i87FBWeJ8_8B78oihdtSmVYaI2mArvHxI7WyTEW3gIeIRamDPlh8PJOK-ThcQc3xPNYTQ", 22 | "timeout": 60000, 23 | "rpId": "example.com", 24 | "allowCredentials": [], 25 | "userVerification": "preferred", 26 | } 27 | ) 28 | 29 | self.assertEqual( 30 | opts.challenge, 31 | base64url_to_bytes( 32 | "skxyhJljbw-ZQn-g1i87FBWeJ8_8B78oihdtSmVYaI2mArvHxI7WyTEW3gIeIRamDPlh8PJOK-ThcQc3xPNYTQ" 33 | ), 34 | ) 35 | self.assertEqual(opts.timeout, 60000) 36 | self.assertEqual(opts.rp_id, "example.com") 37 | self.assertEqual(opts.allow_credentials, []) 38 | self.assertEqual(opts.user_verification, UserVerificationRequirement.PREFERRED) 39 | 40 | def test_returns_parsed_options_full(self) -> None: 41 | opts = parse_authentication_options_json( 42 | { 43 | "challenge": "MTIzNDU2Nzg5MA", 44 | "timeout": 12000, 45 | "rpId": "example.com", 46 | "allowCredentials": [ 47 | { 48 | "id": "MTIzNDU2Nzg5MA", 49 | "type": "public-key", 50 | "transports": ["internal", "hybrid"], 51 | } 52 | ], 53 | "userVerification": "required", 54 | } 55 | ) 56 | 57 | self.assertEqual(opts.challenge, base64url_to_bytes("MTIzNDU2Nzg5MA")) 58 | self.assertEqual(opts.timeout, 12000) 59 | self.assertEqual(opts.rp_id, "example.com") 60 | self.assertEqual( 61 | opts.allow_credentials, 62 | [ 63 | PublicKeyCredentialDescriptor( 64 | id=base64url_to_bytes("MTIzNDU2Nzg5MA"), 65 | transports=[AuthenticatorTransport.INTERNAL, AuthenticatorTransport.HYBRID], 66 | ) 67 | ], 68 | ) 69 | self.assertEqual(opts.user_verification, UserVerificationRequirement.REQUIRED) 70 | 71 | def test_supports_json_string(self) -> None: 72 | opts = parse_authentication_options_json( 73 | '{"challenge": "skxyhJljbw-ZQn-g1i87FBWeJ8_8B78oihdtSmVYaI2mArvHxI7WyTEW3gIeIRamDPlh8PJOK-ThcQc3xPNYTQ", "timeout": 60000, "rpId": "example.com", "allowCredentials": [], "userVerification": "preferred"}' 74 | ) 75 | 76 | self.assertEqual( 77 | opts.challenge, 78 | base64url_to_bytes( 79 | "skxyhJljbw-ZQn-g1i87FBWeJ8_8B78oihdtSmVYaI2mArvHxI7WyTEW3gIeIRamDPlh8PJOK-ThcQc3xPNYTQ" 80 | ), 81 | ) 82 | self.assertEqual(opts.timeout, 60000) 83 | self.assertEqual(opts.rp_id, "example.com") 84 | self.assertEqual(opts.allow_credentials, []) 85 | self.assertEqual(opts.user_verification, UserVerificationRequirement.PREFERRED) 86 | 87 | def test_supports_options_to_json_output(self) -> None: 88 | """ 89 | Test that output from `generate_authentication_options()` that's fed directly into 90 | `options_to_json()` gets parsed back into the original options without any changes along 91 | the way. 92 | """ 93 | opts = generate_authentication_options( 94 | rp_id="example.com", 95 | challenge=b"1234567890", 96 | timeout=12000, 97 | allow_credentials=[ 98 | PublicKeyCredentialDescriptor( 99 | id=b"1234567890", 100 | transports=[AuthenticatorTransport.INTERNAL, AuthenticatorTransport.HYBRID], 101 | ) 102 | ], 103 | user_verification=UserVerificationRequirement.REQUIRED, 104 | ) 105 | 106 | opts_json = options_to_json(opts) 107 | 108 | parsed_opts_json = parse_authentication_options_json(opts_json) 109 | 110 | self.assertEqual(parsed_opts_json.rp_id, opts.rp_id) 111 | self.assertEqual(parsed_opts_json.challenge, opts.challenge) 112 | self.assertEqual(parsed_opts_json.allow_credentials, opts.allow_credentials) 113 | self.assertEqual(parsed_opts_json.timeout, opts.timeout) 114 | self.assertEqual(parsed_opts_json.user_verification, opts.user_verification) 115 | 116 | def test_raises_on_non_dict_json(self) -> None: 117 | with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"): 118 | parse_authentication_options_json("[0]") 119 | 120 | def test_raises_on_missing_challenge(self) -> None: 121 | with self.assertRaisesRegex(InvalidJSONStructure, "missing required challenge"): 122 | parse_authentication_options_json({}) 123 | 124 | def test_supports_optional_timeout(self) -> None: 125 | opts = parse_authentication_options_json( 126 | { 127 | "challenge": "aaa", 128 | "userVerification": "required", 129 | } 130 | ) 131 | 132 | self.assertIsNone(opts.timeout) 133 | 134 | def test_supports_optional_rp_id(self) -> None: 135 | opts = parse_authentication_options_json( 136 | { 137 | "challenge": "aaa", 138 | "userVerification": "required", 139 | } 140 | ) 141 | 142 | self.assertIsNone(opts.rp_id) 143 | 144 | def test_raises_on_missing_user_verification(self) -> None: 145 | with self.assertRaisesRegex(InvalidJSONStructure, "missing required userVerification"): 146 | parse_authentication_options_json( 147 | { 148 | "challenge": "aaaa", 149 | } 150 | ) 151 | 152 | def test_raises_on_invalid_user_verification(self) -> None: 153 | with self.assertRaisesRegex(InvalidJSONStructure, "userVerification was invalid"): 154 | parse_authentication_options_json( 155 | { 156 | "challenge": "aaaa", 157 | "userVerification": "when_inconvenient", 158 | } 159 | ) 160 | 161 | def test_supports_optional_allow_credentials(self) -> None: 162 | opts = parse_authentication_options_json( 163 | { 164 | "challenge": "aaa", 165 | "userVerification": "required", 166 | } 167 | ) 168 | 169 | self.assertIsNone(opts.allow_credentials) 170 | 171 | def test_raises_on_allow_credentials_entry_missing_id(self) -> None: 172 | with self.assertRaisesRegex(InvalidJSONStructure, "missing required id"): 173 | parse_authentication_options_json( 174 | { 175 | "challenge": "aaa", 176 | "userVerification": "required", 177 | "allowCredentials": [{}], 178 | } 179 | ) 180 | 181 | def test_raises_on_allow_credentials_entry_invalid_transports(self) -> None: 182 | with self.assertRaisesRegex(InvalidJSONStructure, "transports was not list"): 183 | parse_authentication_options_json( 184 | { 185 | "challenge": "aaa", 186 | "userVerification": "required", 187 | "allowCredentials": [{"id": "aaaa", "transports": ""}], 188 | } 189 | ) 190 | 191 | def test_raises_on_allow_credentials_entry_invalid_transports_entry(self) -> None: 192 | with self.assertRaisesRegex(InvalidJSONStructure, "entry transports had invalid value"): 193 | parse_authentication_options_json( 194 | { 195 | "challenge": "aaa", 196 | "userVerification": "required", 197 | "allowCredentials": [{"id": "aaaa", "transports": ["pcie"]}], 198 | } 199 | ) 200 | -------------------------------------------------------------------------------- /tests/test_parse_backup_flags.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers import parse_backup_flags 4 | from webauthn.helpers.structs import AuthenticatorDataFlags 5 | from webauthn.helpers.exceptions import InvalidBackupFlags 6 | 7 | 8 | class TestParseBackupFlags(TestCase): 9 | flags: AuthenticatorDataFlags 10 | 11 | def setUp(self) -> None: 12 | self.flags = AuthenticatorDataFlags( 13 | up=True, 14 | uv=False, 15 | be=False, 16 | bs=False, 17 | at=False, 18 | ed=False, 19 | ) 20 | 21 | def test_returns_single_device_not_backed_up(self) -> None: 22 | self.flags.be = False 23 | self.flags.bs = False 24 | 25 | parsed = parse_backup_flags(self.flags) 26 | 27 | self.assertEqual(parsed.credential_device_type, "single_device") 28 | self.assertEqual(parsed.credential_backed_up, False) 29 | 30 | def test_returns_multi_device_not_backed_up(self) -> None: 31 | self.flags.be = True 32 | self.flags.bs = False 33 | 34 | parsed = parse_backup_flags(self.flags) 35 | 36 | self.assertEqual(parsed.credential_device_type, "multi_device") 37 | self.assertEqual(parsed.credential_backed_up, False) 38 | 39 | def test_returns_multi_device_backed_up(self) -> None: 40 | self.flags.be = True 41 | self.flags.bs = True 42 | 43 | parsed = parse_backup_flags(self.flags) 44 | 45 | self.assertEqual(parsed.credential_device_type, "multi_device") 46 | self.assertEqual(parsed.credential_backed_up, True) 47 | 48 | def test_raises_on_invalid_backup_state_flags(self) -> None: 49 | self.flags.be = False 50 | self.flags.bs = True 51 | 52 | with self.assertRaisesRegex( 53 | InvalidBackupFlags, 54 | "impossible", 55 | ): 56 | parse_backup_flags(self.flags) 57 | -------------------------------------------------------------------------------- /tests/test_parse_client_data_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest import TestCase 3 | 4 | from webauthn.helpers import base64url_to_bytes, bytes_to_base64url 5 | from webauthn.helpers.exceptions import InvalidJSONStructure 6 | from webauthn.helpers.parse_client_data_json import parse_client_data_json 7 | from webauthn.helpers.structs import TokenBindingStatus 8 | 9 | 10 | class TestParseClientDataJSON(TestCase): 11 | def test_can_parse_attestation_client_data(self): 12 | client_data_bytes = base64url_to_bytes( 13 | "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoidkZHdmt6UFZzQ0JKOXVLMWxHT0ZucGl4NDF5clB1eFBITFlMdGJRckVYNjNqSlplYXNaVlBfY3lZZkExYktmelJJbk1vcG05VUJtNkNvS2FfakZ0MWciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", 14 | ) 15 | expected_challenge = base64url_to_bytes( 16 | "vFGvkzPVsCBJ9uK1lGOFnpix41yrPuxPHLYLtbQrEX63jJZeasZVP_cyYfA1bKfzRInMopm9UBm6CoKa_jFt1g" 17 | ) 18 | 19 | output = parse_client_data_json(client_data_bytes) 20 | 21 | assert output.type == "webauthn.create" 22 | assert output.challenge == expected_challenge 23 | assert output.origin == "http://localhost:5000" 24 | assert output.cross_origin is False 25 | assert output.token_binding is None 26 | 27 | def test_raises_exception_on_bad_json(self): 28 | client_data_bytes = b"not_real-JS0N" 29 | 30 | with self.assertRaisesRegex( 31 | InvalidJSONStructure, 32 | "Unable to decode", 33 | ): 34 | parse_client_data_json(client_data_bytes) 35 | 36 | def test_requires_type(self): 37 | client_data_str = json.dumps( 38 | { 39 | "challenge": bytes_to_base64url(b"challenge"), 40 | "origin": "http://localhost:5000", 41 | } 42 | ) 43 | client_data_bytes = client_data_str.encode("utf-8") 44 | 45 | with self.assertRaisesRegex( 46 | InvalidJSONStructure, 47 | 'missing required property "type"', 48 | ): 49 | parse_client_data_json(client_data_bytes) 50 | 51 | def test_requires_challenge(self): 52 | client_data_str = json.dumps( 53 | {"type": "webauthn.create", "origin": "http://localhost:5000"} 54 | ) 55 | client_data_bytes = client_data_str.encode("utf-8") 56 | 57 | with self.assertRaisesRegex( 58 | InvalidJSONStructure, 59 | 'missing required property "challenge"', 60 | ): 61 | parse_client_data_json(client_data_bytes) 62 | 63 | def test_requires_origin(self): 64 | client_data_str = json.dumps( 65 | { 66 | "type": "webauthn.create", 67 | "challenge": bytes_to_base64url(b"challenge"), 68 | } 69 | ) 70 | client_data_bytes = client_data_str.encode("utf-8") 71 | 72 | with self.assertRaisesRegex( 73 | InvalidJSONStructure, 74 | 'missing required property "origin"', 75 | ): 76 | parse_client_data_json(client_data_bytes) 77 | 78 | def test_omit_cross_origin_if_not_present(self): 79 | client_data_str = json.dumps( 80 | { 81 | "type": "webauthn.create", 82 | "challenge": bytes_to_base64url(b"challenge"), 83 | "origin": "http://localhost:5000", 84 | } 85 | ) 86 | client_data_bytes = client_data_str.encode("utf-8") 87 | 88 | output = parse_client_data_json(client_data_bytes) 89 | 90 | assert output.cross_origin is None 91 | 92 | def test_omit_token_binding_if_not_present(self): 93 | client_data_str = json.dumps( 94 | { 95 | "type": "webauthn.create", 96 | "challenge": bytes_to_base64url(b"challenge"), 97 | "origin": "http://localhost:5000", 98 | } 99 | ) 100 | client_data_bytes = client_data_str.encode("utf-8") 101 | 102 | output = parse_client_data_json(client_data_bytes) 103 | 104 | assert output.token_binding is None 105 | 106 | def test_include_token_binding_when_present(self): 107 | client_data_str = json.dumps( 108 | { 109 | "type": "webauthn.create", 110 | "challenge": bytes_to_base64url(b"challenge"), 111 | "origin": "http://localhost:5000", 112 | "tokenBinding": {"status": "present", "id": "someidhere"}, 113 | } 114 | ) 115 | client_data_bytes = client_data_str.encode("utf-8") 116 | 117 | output = parse_client_data_json(client_data_bytes) 118 | 119 | assert output.token_binding 120 | assert output.token_binding.status == TokenBindingStatus.PRESENT 121 | assert output.token_binding.id == "someidhere" 122 | 123 | def test_require_status_in_token_binding_when_present(self): 124 | client_data_str = json.dumps( 125 | { 126 | "type": "webauthn.create", 127 | "challenge": bytes_to_base64url(b"challenge"), 128 | "origin": "http://localhost:5000", 129 | "tokenBinding": {"id": "someidhere"}, 130 | } 131 | ) 132 | client_data_bytes = client_data_str.encode("utf-8") 133 | 134 | with self.assertRaises(InvalidJSONStructure) as context: 135 | parse_client_data_json(client_data_bytes) 136 | 137 | assert 'missing required property "status"' in str(context.exception) 138 | 139 | def test_omit_id_when_missing_from_token_binding(self): 140 | client_data_str = json.dumps( 141 | { 142 | "type": "webauthn.create", 143 | "challenge": bytes_to_base64url(b"challenge"), 144 | "origin": "http://localhost:5000", 145 | "tokenBinding": { 146 | "status": "present", 147 | }, 148 | } 149 | ) 150 | client_data_bytes = client_data_str.encode("utf-8") 151 | 152 | output = parse_client_data_json(client_data_bytes) 153 | 154 | assert output.token_binding 155 | assert output.token_binding.id is None 156 | -------------------------------------------------------------------------------- /tests/test_snake_case_to_camel_case.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers.snake_case_to_camel_case import snake_case_to_camel_case 4 | 5 | 6 | class TestWebAuthnSnakeCaseToCamelCase(TestCase): 7 | def test_converts_snake_case_to_camel_case(self) -> None: 8 | output = snake_case_to_camel_case("snake_case") 9 | 10 | assert output == "snakeCase" 11 | 12 | def test_converts_client_data_json(self) -> None: 13 | output = snake_case_to_camel_case("client_data_json") 14 | 15 | assert output == "clientDataJSON" 16 | -------------------------------------------------------------------------------- /tests/test_structs.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers import parse_registration_credential_json 4 | from webauthn.helpers.structs import ( 5 | AuthenticatorTransport, 6 | AuthenticatorAttachment, 7 | ) 8 | 9 | 10 | class TestStructsRegistrationCredential(TestCase): 11 | def test_parse_registration_credential_json(self): 12 | """ 13 | Check that we can properly parse some values that aren't really here-or-there for response 14 | verification, but can still be useful to RP's to fine-tune the WebAuthn experience. 15 | """ 16 | parsed = parse_registration_credential_json( 17 | """{ 18 | "id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", 19 | "rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", 20 | "response": { 21 | "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVkBZ0mWDeWIDoxodDQXD2R2YFuP5K65ooYyx5lc87qDHZdjRQAAAAAAAAAAAAAAAAAAAAAAAAAAACBmggo_UlC8p2tiPVtNQ8nZ5NSxst4WS_5fnElA2viTq6QBAwM5AQAgWQEA31dtHqc70D_h7XHQ6V_nBs3Tscu91kBL7FOw56_VFiaKYRH6Z4KLr4J0S12hFJ_3fBxpKfxyMfK66ZMeAVbOl_wemY4S5Xs4yHSWy21Xm_dgWhLJjZ9R1tjfV49kDPHB_ssdvP7wo3_NmoUPYMgK-edgZ_ehttp_I6hUUCnVaTvn_m76b2j9yEPReSwl-wlGsabYG6INUhTuhSOqG-UpVVQdNJVV7GmIPHCA2cQpJBDZBohT4MBGme_feUgm4sgqVCWzKk6CzIKIz5AIVnspLbu05SulAVnSTB3NxTwCLNJR_9v9oSkvphiNbmQBVQH1tV_psyi9HM1Jtj9VJVKMeyFDAQAB", 22 | "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiQ2VUV29nbWcwY2NodWlZdUZydjhEWFhkTVpTSVFSVlpKT2dhX3hheVZWRWNCajBDdzN5NzN5aEQ0RmtHU2UtUnJQNmhQSkpBSW0zTFZpZW40aFhFTGciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", 23 | "transports": ["internal", "hybrid"] 24 | }, 25 | "type": "public-key", 26 | "clientExtensionResults": {}, 27 | "authenticatorAttachment": "platform" 28 | }""" 29 | ) 30 | 31 | self.assertEqual( 32 | parsed.response.transports, 33 | [ 34 | AuthenticatorTransport.INTERNAL, 35 | AuthenticatorTransport.HYBRID, 36 | ], 37 | ) 38 | self.assertEqual(parsed.authenticator_attachment, AuthenticatorAttachment.PLATFORM) 39 | -------------------------------------------------------------------------------- /tests/test_tpm_parse_cert_info.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers.tpm.parse_cert_info import parse_cert_info 4 | from webauthn.helpers.tpm.structs import TPM_ALG, TPM_ST 5 | 6 | 7 | class TestWebAuthnTPMParseCertInfo(TestCase): 8 | def test_properly_parses_cert_info_bytes(self) -> None: 9 | cert_info = b'\xffTCG\x80\x17\x00"\x00\x0bW"f{J5_9"\x15\tL\x01\xd5e\xbcr\xc6\xc9\x03\xbc#\xb5m\xee\xb5yI+j\xe6\xce\x00\x14`\x0bD(A\x99\xf3\xd3\x12I[\x04\x1f\xf4\xe7\xfb)\xc8\x02\x8f\x00\x00\x00\x00\x1a0)Z\x16\xb0}\xb1R\'s\xf8\x01\x97g1K\xfaf`T\x00"\x00\x0b\xe7\x1c"\x90\x07\xdeA\xe1w\xe0\xb3F\xe1\x07\x02\x8c\x16b\xe1\r\x9e\xb8\xae\xe7\xa95\xac\xf6\x1a\xedx\x89\x00"\x00\x0b\x7f\xe8\x84\xdaC\xa7\xc5?\xcept,\xa9\nA\x99\x93\xbc\x1f\x15\xcbs\x7f\xe0\x1a\x96u\xca\xe4\x8f\x86\x81' 10 | 11 | output = parse_cert_info(cert_info) 12 | 13 | assert output.magic == b"\xffTCG" 14 | assert output.type == TPM_ST.ATTEST_CERTIFY 15 | assert ( 16 | output.qualified_signer 17 | == b'\x00\x0bW"f{J5_9"\x15\tL\x01\xd5e\xbcr\xc6\xc9\x03\xbc#\xb5m\xee\xb5yI+j\xe6\xce' 18 | ) 19 | assert output.extra_data == b"`\x0bD(A\x99\xf3\xd3\x12I[\x04\x1f\xf4\xe7\xfb)\xc8\x02\x8f" 20 | assert output.firmware_version == b"\x97g1K\xfaf`T" 21 | # Attested 22 | assert output.attested.name_alg == TPM_ALG.SHA256 23 | assert output.attested.name_alg_bytes == b"\x00\x0b" 24 | assert ( 25 | output.attested.name 26 | == b'\x00\x0b\xe7\x1c"\x90\x07\xdeA\xe1w\xe0\xb3F\xe1\x07\x02\x8c\x16b\xe1\r\x9e\xb8\xae\xe7\xa95\xac\xf6\x1a\xedx\x89' 27 | ) 28 | assert ( 29 | output.attested.qualified_name 30 | == b"\x00\x0b\x7f\xe8\x84\xdaC\xa7\xc5?\xcept,\xa9\nA\x99\x93\xbc\x1f\x15\xcbs\x7f\xe0\x1a\x96u\xca\xe4\x8f\x86\x81" 31 | ) 32 | # Clock Info 33 | assert output.clock_info.clock == b"\x00\x00\x00\x00\x1a0)Z" 34 | assert output.clock_info.reset_count == 380665265 35 | assert output.clock_info.restart_count == 1378317304 36 | assert output.clock_info.safe is True 37 | -------------------------------------------------------------------------------- /tests/test_tpm_parse_pub_area.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers.tpm.parse_pub_area import parse_pub_area 4 | from webauthn.helpers.tpm.structs import TPM_ALG, TPMPubAreaParametersRSA 5 | 6 | 7 | class TestWebAuthnTPMParsePubArea(TestCase): 8 | def test_properly_parses_pub_area_bytes(self) -> None: 9 | pub_area = b'\x00\x01\x00\x0b\x00\x06\x04r\x00 \x9d\xff\xcb\xf3l8:\xe6\x99\xfb\x98h\xdcm\xcb\x89\xd7\x158\x84\xbe(\x03\x92,\x12AX\xbf\xad"\xae\x00\x10\x00\x10\x08\x00\x00\x00\x00\x00\x01\x00\xb4\xd2\x0c\x0c\xf9-\x02\xad\x8b\xd937k\x1acE\x90\x853\x008\x08S<\xa58\x15`-{\xc0b\xf5X\xdcO\x94\x86\xf6\x18\x14\xff\xde\x01n\x15\xcf\x88\xec\xe8~\x81\xc3\xd3\xbfg\x82\x89\xe6\xbb{\xffW\xff#I!\x03\xb1\x8a\xf6\x96\x9a\x80\xa5Xj\xf8+\x9a\x01r&\xbc\\\xae\x1dU,\x86\xc3q\'Uks\xe5K/\x14Sw\x01\xfaE\x9d\x81\xfcJWb\x80c\x1b\xc4\xf3\xc3\x1fd\x9f"\xfd\xce[8z\xe0\xea\xeb\x18\x81\xd3\x8fc\xab\xfa\x84\x8d\x840\\\x8fS\x1b>#R\xb1\x91\xd0\xa6\xf6&R\xee\xc56\x18T%"\xa5\xa23i\x96\xb0\xa6{\x82\xc8@\x14\xb0T#R\xb1\x91\xd0\xa6\xf6&R\xee\xc56\x18T%"\xa5\xa23i\x96\xb0\xa6{\x82\xc8@\x14\xb0T 2021-09-03 @ 23:02:07Z 19 | bytes.fromhex( 20 | "30820243308201c9a0030201020206017ba3992221300a06082a8648ce3d0403023048311c301a06035504030c134170706c6520576562417574686e204341203131133011060355040a0c0a4170706c6520496e632e3113301106035504080c0a43616c69666f726e6961301e170d3231303833313233303230375a170d3231303930333233303230375a3081913149304706035504030c4062313066373138626335646437353838383661316438636662356238623633313732396634643765346261303639616230613939326331633038343738616639311a3018060355040b0c114141412043657274696669636174696f6e31133011060355040a0c0a4170706c6520496e632e3113301106035504080c0a43616c69666f726e69613059301306072a8648ce3d020106082a8648ce3d03010703420004d124b0e9ff8192723c9ee2fa4f8170d373e03286cf880aeec7008a14cdea64724963e05bb8c44a9f980ded12aa8a33795cf81d31e74116ced6f1f4c5eb0c358fa3553053300c0603551d130101ff04023000300e0603551d0f0101ff0404030204f0303306092a864886f76364080204263024a1220420e457e5bc292f1635210248ed2e776ba129c7cc469524a75356836caef2f058a0300a06082a8648ce3d0403020368003065023065c6e7075ddacb50879a8412904759013d0da78726408759a01f1994c1795a69c2c1d11306c2d1bc97be6141627b8677023100ab0b9e7d97ca2b603b1edb6e264c49bf1971380c2afa5d37f8c4ff5a5de6d457a19cb80c02b2edf94b0853e0482f8686" 21 | ), 22 | # 2020-03-18 @ 18:38:01Z <-> 2030-03-13 @ 00:00:00Z 23 | bytes.fromhex( 24 | "30820234308201baa003020102021056255395c7a7fb40ebe228d8260853b6300a06082a8648ce3d040303304b311f301d06035504030c164170706c6520576562417574686e20526f6f7420434131133011060355040a0c0a4170706c6520496e632e3113301106035504080c0a43616c69666f726e6961301e170d3230303331383138333830315a170d3330303331333030303030305a3048311c301a06035504030c134170706c6520576562417574686e204341203131133011060355040a0c0a4170706c6520496e632e3113301106035504080c0a43616c69666f726e69613076301006072a8648ce3d020106052b8104002203620004832e872f261491810225b9f5fcd6bb6378b5f55f3fcb045bc735993475fd549044df9bfe19211765c69a1dda050b38d45083401a434fb24d112d56c3e1cfbfcb9891fec0696081bef96cbc77c88dddaf46a5aee1dd515b5afaab93be9c0b2691a366306430120603551d130101ff040830060101ff020100301f0603551d2304183016801426d764d9c578c25a67d1a7de6b12d01b63f1c6d7301d0603551d0e04160414ebae82c4ffa1ac5b51d4cf24610500be63bd7788300e0603551d0f0101ff040403020106300a06082a8648ce3d0403030368003065023100dd8b1a3481a5fad9dbb4e7657b841e144c27b75b876a4186c2b1475750337227efe554457ef648950c632e5c483e70c102302c8a6044dc201fcfe59bc34d2930c1487851d960ed6a75f1eb4acabe38cd25b897d0c805bef0c7f78b07a571c6e80e07" 25 | ), 26 | ] 27 | 28 | 29 | class TestValidateCertificateChain(TestCase): 30 | def setUp(self): 31 | # Setting the time to something that satisfies all these: 32 | # (Leaf) 20210831230207Z <-> 20210903230207Z <- Earliest expiration 33 | # (Int.) 20200318183801Z <-> 20300313000000Z 34 | # (Root) 20200318182132Z <-> 20450315000000Z 35 | self.x509store_time = datetime(2021, 9, 1, 0, 0, 0) 36 | 37 | @patch_validate_certificate_chain_x509store_getter 38 | def test_validates_certificate_chain(self, patched_x509store: X509Store) -> None: 39 | patched_x509store.set_time(self.x509store_time) 40 | 41 | try: 42 | validate_certificate_chain( 43 | x5c=apple_x5c_certs, 44 | pem_root_certs_bytes=[apple_webauthn_root_ca], 45 | ) 46 | except Exception as err: 47 | print(err) 48 | self.fail("validate_certificate_chain failed when it should have succeeded") 49 | 50 | @patch_validate_certificate_chain_x509store_getter 51 | def test_throws_on_bad_root_cert(self, patched_x509store: X509Store) -> None: 52 | patched_x509store.set_time(self.x509store_time) 53 | 54 | with self.assertRaises(InvalidCertificateChain): 55 | validate_certificate_chain( 56 | x5c=apple_x5c_certs, 57 | # An obviously invalid root cert for these x5c certs 58 | pem_root_certs_bytes=[globalsign_root_ca], 59 | ) 60 | 61 | def test_passes_on_no_root_certs(self): 62 | try: 63 | validate_certificate_chain( 64 | x5c=apple_x5c_certs, 65 | ) 66 | except Exception as err: 67 | print(err) 68 | self.fail("validate_certificate_chain failed when it should have succeeded") 69 | 70 | def test_passes_on_empty_root_certs_array(self): 71 | try: 72 | validate_certificate_chain( 73 | x5c=apple_x5c_certs, 74 | pem_root_certs_bytes=[], 75 | ) 76 | except Exception as err: 77 | print(err) 78 | self.fail("validate_certificate_chain failed when it should have succeeded") 79 | 80 | def test_includes_original_exception_when_raising(self): 81 | """ 82 | A low-effort attempt at ensuring that the attempt to validate a certificate chain will 83 | include the original exception raised by the X509 validation library, instead of just its 84 | message. 85 | """ 86 | custom_exception = X509StoreContextError(message="Oops", certificate=X509(), errors=[]) 87 | 88 | with patch( 89 | "OpenSSL.crypto.X509StoreContext.verify_certificate", side_effect=custom_exception 90 | ): 91 | with self.assertRaises(InvalidCertificateChain) as ctx: 92 | validate_certificate_chain( 93 | x5c=apple_x5c_certs, 94 | pem_root_certs_bytes=[apple_webauthn_root_ca], 95 | ) 96 | 97 | cause = ctx.exception.__cause__ 98 | self.assertEqual(cause, custom_exception) 99 | -------------------------------------------------------------------------------- /tests/test_verify_registration_response_android_key.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from datetime import datetime 3 | from OpenSSL.crypto import X509Store 4 | 5 | from webauthn.helpers import base64url_to_bytes 6 | from webauthn.helpers.structs import AttestationFormat 7 | from webauthn import verify_registration_response 8 | 9 | from .helpers.x509store import patch_validate_certificate_chain_x509store_getter 10 | 11 | 12 | class TestVerifyRegistrationResponseAndroidKey(TestCase): 13 | @patch_validate_certificate_chain_x509store_getter 14 | def test_verify_attestation_android_key_hardware_authority( 15 | self, 16 | patched_x509store: X509Store, 17 | ) -> None: 18 | """ 19 | This android-key attestation was generated on a Pixel 8a in January 2025 via an origin 20 | trial. Google will be sunsetting android-safetynet attestation for android-key attestations 21 | for device-bound passkeys (i.e. `"residentKey": "discouraged"`) in April 2025 22 | 23 | See here for more info: 24 | https://android-developers.googleblog.com/2024/09/attestation-format-change-for-android-fido2-api.html 25 | """ 26 | credential = """{ 27 | "id": "AYNe4CBKc8H30FuAb8uaht6JbEQfbSBnS0SX7B6MFg8ofI92oR5lheRDJCgwY-JqB_QSJtezdhMbf8Wzt_La5N0", 28 | "rawId": "AYNe4CBKc8H30FuAb8uaht6JbEQfbSBnS0SX7B6MFg8ofI92oR5lheRDJCgwY-JqB_QSJtezdhMbf8Wzt_La5N0", 29 | "response": { 30 | "attestationObject": "o2NmbXRrYW5kcm9pZC1rZXlnYXR0U3RtdKNjYWxnJmNzaWdYSDBGAiEAs9Aufj5f5HyLKEFsgfmqyaXfAih-hGuTJqgmxZGijzYCIQDAMddAq1gwH3MtesYR6WE6IAockRz8ilR7CFw_kgdmv2N4NWOFWQLQMIICzDCCAnKgAwIBAgIBATAKBggqhkjOPQQDAjA5MSkwJwYDVQQDEyBkNjAyYTAzYTY3MmQ4NjViYTVhNDg1ZTMzYTIwN2M3MzEMMAoGA1UEChMDVEVFMB4XDTcwMDEwMTAwMDAwMFoXDTQ4MDEwMTAwMDAwMFowHzEdMBsGA1UEAxMUQW5kcm9pZCBLZXlzdG9yZSBLZXkwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATXVi3-n-rBsrP3A4Pj9P8e6PNh3eNdC38PaFiCZyMWdUVA6PbE6985PSUDDcnk3Knnpyc66J_HFOu_geuqiWtAo4IBgzCCAX8wDgYDVR0PAQH_BAQDAgeAMIIBawYKKwYBBAHWeQIBEQSCAVswggFXAgIBLAoBAQICASwKAQEEIFZS4txFVJqW-Wr6IlUC-H-twIpgvAITksC-jFBi_V9eBAAwd7-FPQgCBgGUcHc4or-FRWcEZTBjMT0wGwQWY29tLmdvb2dsZS5hbmRyb2lkLmdzZgIBIzAeBBZjb20uZ29vZ2xlLmFuZHJvaWQuZ21zAgQO6jzjMSIEIPD9bFtBDyXLJcO1M0bIly-uMPjudBHfkQSArWstYNuDMIGpoQUxAwIBAqIDAgEDowQCAgEApQUxAwIBBKoDAgEBv4N4AwIBA7-DeQMCAQq_hT4DAgEAv4VATDBKBCCd4l-wK7VTDUQUnRSEN8guJn5VcyJTCqbwOwrC6Skx2gEB_woBAAQg6y0px0ZXc5v2bsVb45w-6IiMbXzp3gyHIWKS1mbz6gu_hUEFAgMCSfC_hUIFAgMDFwW_hU4GAgQBNP35v4VPBgIEATT9-TAKBggqhkjOPQQDAgNIADBFAiEAzNz6wyTo4t5ixo9G4zXPwh4zSB9F854sU_KDGTf0dxYCICaQVSWzWgTZLQYv13MXJJee8S8_luQB3W5lPPzP0exsWQHjMIIB3zCCAYWgAwIBAgIRANYCoDpnLYZbpaSF4zogfHMwCgYIKoZIzj0EAwIwKTETMBEGA1UEChMKR29vZ2xlIExMQzESMBAGA1UEAxMJRHJvaWQgQ0EzMB4XDTI1MDEwNzE3MDg0M1oXDTI1MDIwMjEwMzUyN1owOTEpMCcGA1UEAxMgZDYwMmEwM2E2NzJkODY1YmE1YTQ4NWUzM2EyMDdjNzMxDDAKBgNVBAoTA1RFRTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFPbPYqm91rYvZVCBdFaHRMg0tw7U07JA1EcD9ZP4d0lK2NFM4A0wGKS4jbTR_bu7NTt_YyF388S0PWAJTluqnOjfjB8MB0GA1UdDgQWBBSXyrsZ_A1NnJGRq0sm2G9nm-NC5zAfBgNVHSMEGDAWgBTFUX4F2MtjWykYrAIa8sh9bBL-kjAPBgNVHRMBAf8EBTADAQH_MA4GA1UdDwEB_wQEAwICBDAZBgorBgEEAdZ5AgEeBAuiAQgDZkdvb2dsZTAKBggqhkjOPQQDAgNIADBFAiEAysd6JDoI8X4NEdrRwUwtIAy-hLxSEKUVS2XVWS2CP04CIFNQQzM4TkA_xaZj8KyiS61nb-aOBP35tlA34JCOlv9nWQHcMIIB2DCCAV2gAwIBAgIUAIUK9vrO5iIEbQx0izdwqlWwtk0wCgYIKoZIzj0EAwMwKTETMBEGA1UEChMKR29vZ2xlIExMQzESMBAGA1UEAxMJRHJvaWQgQ0EyMB4XDTI0MTIwOTA2Mjg1M1oXDTI1MDIxNzA2Mjg1MlowKTETMBEGA1UEChMKR29vZ2xlIExMQzESMBAGA1UEAxMJRHJvaWQgQ0EzMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPjbr-yt9xhgcbKLXoN3RK-1FcCjwIpeMPJZjayW0dqNtFflHp2smO0DxN_6x7M7NAGbcC9lM1_E-N6z51ODv-6NjMGEwDgYDVR0PAQH_BAQDAgIEMA8GA1UdEwEB_wQFMAMBAf8wHQYDVR0OBBYEFMVRfgXYy2NbKRisAhryyH1sEv6SMB8GA1UdIwQYMBaAFKYLhqTwyH8ztWE5Ys0956c6QoNIMAoGCCqGSM49BAMDA2kAMGYCMQCuzU0wV_NkOQzgqzyqP66SJN6lilrU-NDVU6qNCnbFsUoZQOm4wBwUw7LqfoUhx7YCMQDFEvqHfc2hwN2J4I9Z4rTHiLlsy6gA33WvECzIZmVMpKcyEiHlm4c9XR0nVkAjQ_5ZA4QwggOAMIIBaKADAgECAgoDiCZnYGWJloYOMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNVBAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMjIwMTI2MjI0OTQ1WhcNMzcwMTIyMjI0OTQ1WjApMRMwEQYDVQQKEwpHb29nbGUgTExDMRIwEAYDVQQDEwlEcm9pZCBDQTIwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT72ZtYJ0I2etFhouvtVs0sBzvYsx8thNCZV1wsDPvsMDSTPij-M1wBFD00OUn2bfU5b7K2_t2NkXc2-_V9g--mdb6SoRGmJ_AG9ScY60LKSA7iPT7gZ_5-q0tnEPPZJCqjZjBkMB0GA1UdDgQWBBSmC4ak8Mh_M7VhOWLNPeenOkKDSDAfBgNVHSMEGDAWgBQ2YeEAfIgFCVGLRGxH_xpMyepPEjASBgNVHRMBAf8ECDAGAQH_AgECMA4GA1UdDwEB_wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEArpB2eLbKHNcS6Q3Td3N7ZCgVLN0qA7CboM-Ftu4YYAcHxh-e_sk7T7XOg5S4d9a_DD7mIXgENSBPB_fVqCnBaSDKNJ3nUuC1_9gcT95p4kKJo0tqcsWw8WgKVJhNuZCN7d_ziHLiRRcrKtaj944THzsy7vB-pSai7gTah_RJrDQI91bDUJgld8_p_QAbVnYA8o-msO0sRKxgF1V5QuBwBTfpdkqshqL3nwBm0sofqI_rM-JOQava3-IurHvfkzioiOJ0uFJnBGVjpZFwGwsmyKwzl-3qRKlkHggAOKt3lQQ4GiJnOCm10JrxPa2Za0K6_kyk6YyvvRcFNai5ej3nMKJPg-eeG2nST6N6ePFuaeoNQnD4XkagGFEQYzcqvsdFsmsbUFMghFl7zEVYdscuSgCG939wxW1JgKyG5ce7CI40328w9IuOf8mUS_W3i4jSfxqCJbegyo_SKDpDILnhJUBy0T3fN8mv9AyO0uoJBlvnogIVv2SdpYUt92vyOiGMy3Jx_ZRWjIRa7iIV3VnjLI__pgCrXQLMinZWEWsxVxg25nrk8u32nZd67DJN3k2FufRbsmHZly9CLo0P79lkIEC3rifLqqJeDyHQNaBMUC6BSDZ5RJCtMjSZw2xL5z0X9_zBsKVPkMW61hMhKzVmYNLe1DJQANRP-enru5i1oXlZBSAwggUcMIIDBKADAgECAgkA1Q_yW6Py1rMwDQYJKoZIhvcNAQELBQAwGzEZMBcGA1UEBRMQZjkyMDA5ZTg1M2I2YjA0NTAeFw0xOTExMjIyMDM3NThaFw0zNDExMTgyMDM3NThaMBsxGTAXBgNVBAUTEGY5MjAwOWU4NTNiNmIwNDUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCvtseCK7GnAewrtC6LzFQWY6vvmC8yx391MQMMl1JLG1_oCfvHKqlFH3Q8vZpvEzV0SqVed_a2rDU17hfCXmOVF92ckuY3SlPL_iWPj_u2_RKTeKIqTKmcRS1HpZ8yAfRBl8oczX52L7L1MVG2_rL__Stv5P5bxr2ew0v-CCOdqvzrjrWo7Ss6zZxeOneQ4bUUQnkxWYWYEa2esqlrvdelfJOpHEH8zSfWf9b2caoLgVJhrThPo3lEhkYE3bPYxPkgoZsWVsLxStbQPFbsBgiZBBwe0aX-bTRAtVa60dChUlicU-VdNwdi8BIu75GGGxsObEyAknSZwOm-wLg-O8H5PHLASWBLvS8TReYsP44m2-wGyUdm88EoI51PQxL62BI4h-Br7PVnWDv4NVqB_uq6-ZqDyN8-KjIq_Gcr8SCxNRWLaCHOrzCbbu53-YgzsBjaoQ5FHwajdNUHgfNZCClmu3eLkwiUJpjnTgvNJGKKAcLMA-UfCz5bSsHk356vn_akkqd8FIOIKIUBW0Is5nuAuIybSOE7YHq1Rccj_4xE-PLTaLn2Ug0xFF6_noYq1x32o7_SRQlZ1lN0DZehLzaLE-9m1dClSm4vXZpv70RoMrxnhEclhh8JPdDm80BdqJZD7w9NabZCAFH9uTBJZz42lQWA0830-9CLxYSDlSYAYwIDAQABo2MwYTAdBgNVHQ4EFgQUNmHhAHyIBQlRi0RsR_8aTMnqTxIwHwYDVR0jBBgwFoAUNmHhAHyIBQlRi0RsR_8aTMnqTxIwDwYDVR0TAQH_BAUwAwEB_zAOBgNVHQ8BAf8EBAMCAgQwDQYJKoZIhvcNAQELBQADggIBAE4xoFzyi6Zdva-hztcJae5cqEEErd7YowbPf23uUDdddF7ZkssCQsznLcnu1RGR_lrVK61907JcCZ4TpJGjzdSHpazOh2YyTErkYzgkaue3ikGKy7mKBcTJ1pbuqrYJ0LoM4aMb6YSQ3z9MDqndyegv-w_LPp692MuVJ4nysUEfrFbIhkJutylgQnNdpQ4RrHFfGBjPn9xOJUo3YzUbaiRAFQhhJjpuMQvhpQ3lx-juiA_dS-WISjcSjRiDC7NHa_QpHoLVxmpklJOeCEgL-8APfYp01D5zc36-XY5OxRUwLUaJaSeA3HU47X6Rdb5hOedNQ604izBQ_9Wp3lJiAAiYwB9jxT3-IiCRCPpPZboWxJzL3gg318WETVS3OYugEi5QWxVckxPP4m5y2H4iqhYW5r2_VH3f-T3ynjWmO0Vf4fwOyVWB8_T3u-O7goOWo3rjFXWCvDdkuXgKI578D3Wh4ubZQc6rrCfd6wHivYQhApvqNNUa7mxgJx1alevQBRWpwAE92Av4fuomC4HDT2iObrE0ivDY6hysMqy52T-iSv8DCoTI8rD1acyVCAsgrDWs4MbY29T2hHcZUZ0yRQFm60vxW4WQRFAa3q9DY4LDSxXjtUyS5htpwr_HJkWJFys8k9vjXOBtCP1cATIsoId7HRJ0OvH61ZQOobwC3YkcaGF1dGhEYXRhWMVJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY0UAAAAAuT_ZYfLmRi-xIoIAIkfeeABBAYNe4CBKc8H30FuAb8uaht6JbEQfbSBnS0SX7B6MFg8ofI92oR5lheRDJCgwY-JqB_QSJtezdhMbf8Wzt_La5N2lAQIDJiABIVgg11Yt_p_qwbKz9wOD4_T_HujzYd3jXQt_D2hYgmcjFnUiWCBFQOj2xOvfOT0lAw3J5Nyp56cnOuifxxTrv4HrqolrQA", 31 | "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoidDRMV0kwaVlKU1RXUGw5V1hVZE5oZEhBbnJQRExGOWVXQVA5bEhnbUhQOCIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODAwMCIsImNyb3NzT3JpZ2luIjpmYWxzZX0" 32 | }, 33 | "type": "public-key", 34 | "clientExtensionResults": { 35 | "credProps": { "rk": false } 36 | }, 37 | "authenticatorAttachment": "platform" 38 | }""" 39 | 40 | challenge = base64url_to_bytes("t4LWI0iYJSTWPl9WXUdNhdHAnrPDLF9eWAP9lHgmHP8") 41 | rp_id = "localhost" 42 | expected_origin = "http://localhost:8000" 43 | 44 | # Setting the time to something that satisfies all these: 45 | # (Leaf) 19700101000000Z <-> 20480101000000Z 46 | # (Int.) 20250107170843Z <-> 20250202103527Z <- Earliest expiration 47 | # (Int.) 20241209062853Z <-> 20250217062852Z 48 | # (Int.) 20220126224945Z <-> 20370122224945Z 49 | # (Root) 20191122203758Z <-> 20341118203758Z 50 | patched_x509store.set_time(datetime(2025, 1, 8, 0, 0, 0)) 51 | 52 | verification = verify_registration_response( 53 | credential=credential, 54 | expected_challenge=challenge, 55 | expected_origin=expected_origin, 56 | expected_rp_id=rp_id, 57 | ) 58 | 59 | assert verification.fmt == AttestationFormat.ANDROID_KEY 60 | assert verification.credential_id == base64url_to_bytes( 61 | "AYNe4CBKc8H30FuAb8uaht6JbEQfbSBnS0SX7B6MFg8ofI92oR5lheRDJCgwY-JqB_QSJtezdhMbf8Wzt_La5N0" 62 | ) 63 | -------------------------------------------------------------------------------- /tests/test_verify_registration_response_apple.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from datetime import datetime 3 | 4 | from OpenSSL.crypto import X509Store 5 | 6 | from webauthn.helpers import base64url_to_bytes 7 | from webauthn.helpers.structs import AttestationFormat 8 | from webauthn import verify_registration_response 9 | 10 | from .helpers.x509store import patch_validate_certificate_chain_x509store_getter 11 | 12 | 13 | class TestVerifyRegistrationResponseApple(TestCase): 14 | @patch_validate_certificate_chain_x509store_getter 15 | def test_verify_attestation_apple_passkey( 16 | self, 17 | patched_x509store: X509Store, 18 | ) -> None: 19 | # Setting the time to something that satisfies all these: 20 | # (Leaf) 20210831230207Z <-> 20210903230207Z <- Earliest expiration 21 | # (Int.) 20200318183801Z <-> 20300313000000Z 22 | # (Root) 20200318182132Z <-> 20450315000000Z 23 | patched_x509store.set_time(datetime(2021, 9, 1, 0, 0, 0)) 24 | 25 | credential = """{ 26 | "id": "0yhsKG_gCzynIgNbvXWkqJKL8Uc", 27 | "rawId": "0yhsKG_gCzynIgNbvXWkqJKL8Uc", 28 | "response": { 29 | "attestationObject": "o2NmbXRlYXBwbGVnYXR0U3RtdKFjeDVjglkCRzCCAkMwggHJoAMCAQICBgF7o5kiITAKBggqhkjOPQQDAjBIMRwwGgYDVQQDDBNBcHBsZSBXZWJBdXRobiBDQSAxMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIxMDgzMTIzMDIwN1oXDTIxMDkwMzIzMDIwN1owgZExSTBHBgNVBAMMQGIxMGY3MThiYzVkZDc1ODg4NmExZDhjZmI1YjhiNjMxNzI5ZjRkN2U0YmEwNjlhYjBhOTkyYzFjMDg0NzhhZjkxGjAYBgNVBAsMEUFBQSBDZXJ0aWZpY2F0aW9uMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0SSw6f-BknI8nuL6T4Fw03PgMobPiAruxwCKFM3qZHJJY-BbuMRKn5gN7RKqijN5XPgdMedBFs7W8fTF6ww1j6NVMFMwDAYDVR0TAQH_BAIwADAOBgNVHQ8BAf8EBAMCBPAwMwYJKoZIhvdjZAgCBCYwJKEiBCDkV-W8KS8WNSECSO0ud2uhKcfMRpUkp1NWg2yu8vBYoDAKBggqhkjOPQQDAgNoADBlAjBlxucHXdrLUIeahBKQR1kBPQ2nhyZAh1mgHxmUwXlaacLB0RMGwtG8l75hQWJ7hncCMQCrC559l8orYDse224mTEm_GXE4DCr6XTf4xP9aXebUV6GcuAwCsu35SwhT4EgvhoZZAjgwggI0MIIBuqADAgECAhBWJVOVx6f7QOviKNgmCFO2MAoGCCqGSM49BAMDMEsxHzAdBgNVBAMMFkFwcGxlIFdlYkF1dGhuIFJvb3QgQ0ExEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwHhcNMjAwMzE4MTgzODAxWhcNMzAwMzEzMDAwMDAwWjBIMRwwGgYDVQQDDBNBcHBsZSBXZWJBdXRobiBDQSAxMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEgy6HLyYUkYECJbn1_Na7Y3i19V8_ywRbxzWZNHX9VJBE35v-GSEXZcaaHdoFCzjUUINAGkNPsk0RLVbD4c-_y5iR_sBpYIG--Wy8d8iN3a9Gpa7h3VFbWvqrk76cCyaRo2YwZDASBgNVHRMBAf8ECDAGAQH_AgEAMB8GA1UdIwQYMBaAFCbXZNnFeMJaZ9Gn3msS0Btj8cbXMB0GA1UdDgQWBBTrroLE_6GsW1HUzyRhBQC-Y713iDAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIxAN2LGjSBpfrZ27TnZXuEHhRMJ7dbh2pBhsKxR1dQM3In7-VURX72SJUMYy5cSD5wwQIwLIpgRNwgH8_lm8NNKTDBSHhR2WDtanXx60rKvjjNJbiX0MgFvvDH94sHpXHG6A4HaGF1dGhEYXRhWJiPh6BZvowZk4E0cyGRAQ-e4LvoufWAcLD1j4UMTOIowUUAAAAA8kqOcNDT-CwpNzJSPMTeWgAU0yhsKG_gCzynIgNbvXWkqJKL8UelAQIDJiABIVgg0SSw6f-BknI8nuL6T4Fw03PgMobPiAruxwCKFM3qZHIiWCBJY-BbuMRKn5gN7RKqijN5XPgdMedBFs7W8fTF6ww1jw", 30 | "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiMW5ocXlNa2ZHQVFMLXRUY3NmcHVveXE4aHFlb0hyMGQ5dERtanYxQnVKOTdZVEEzRkxXUzVFZFk0cVVnLU16cnVjMnNpQmR5VmxuRklQQjFnMEhoMkEiLCJvcmlnaW4iOiJodHRwczovL2RldjIuZG9udG5lZWRhLnB3OjUwMDAifQ" 31 | }, 32 | "type": "public-key", 33 | "clientExtensionResults": {} 34 | }""" 35 | challenge = base64url_to_bytes( 36 | "1nhqyMkfGAQL-tTcsfpuoyq8hqeoHr0d9tDmjv1BuJ97YTA3FLWS5EdY4qUg-Mzruc2siBdyVlnFIPB1g0Hh2A" 37 | ) 38 | rp_id = "dev2.dontneeda.pw" 39 | expected_origin = "https://dev2.dontneeda.pw:5000" 40 | 41 | verification = verify_registration_response( 42 | credential=credential, 43 | expected_challenge=challenge, 44 | expected_origin=expected_origin, 45 | expected_rp_id=rp_id, 46 | ) 47 | 48 | assert verification.fmt == AttestationFormat.APPLE 49 | assert verification.credential_id == base64url_to_bytes("0yhsKG_gCzynIgNbvXWkqJKL8Uc") 50 | -------------------------------------------------------------------------------- /tests/test_verify_registration_response_packed.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from webauthn.helpers import base64url_to_bytes 4 | from webauthn.helpers.structs import AttestationFormat 5 | from webauthn import verify_registration_response 6 | 7 | 8 | class TestVerifyRegistrationResponsePacked(TestCase): 9 | def test_verify_attestation_from_yubikey_firefox(self) -> None: 10 | credential = """{ 11 | "id": "syGQPDZRUYdb4m3rdWeyPaIMYlbmydGp1TP_33vE_lqJ3PHNyTd0iKsnKr5WjnCcBzcesZrDEfB_RBLFzU3k4w", 12 | "rawId": "syGQPDZRUYdb4m3rdWeyPaIMYlbmydGp1TP_33vE_lqJ3PHNyTd0iKsnKr5WjnCcBzcesZrDEfB_RBLFzU3k4w", 13 | "response": { 14 | "attestationObject": "o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEcwRQIhAOfrFlQpbavT6dJeTDJSCDzYSYPjBDHli2-syT2c1IiKAiAx5gQ2z5cHjdQX-jEHTb7JcjfQoVSW8fXszF5ihSgeOGN4NWOBWQLBMIICvTCCAaWgAwIBAgIEKudiYzANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbjELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEnMCUGA1UEAwweWXViaWNvIFUyRiBFRSBTZXJpYWwgNzE5ODA3MDc1MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKgOGXmBD2Z4R_xCqJVRXhL8Jr45rHjsyFykhb1USGozZENOZ3cdovf5Ke8fj2rxi5tJGn_VnW4_6iQzKdIaeP6NsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQbUS6m_bsLkm5MAyP6SDLczAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQByV9A83MPhFWmEkNb4DvlbUwcjc9nmRzJjKxHc3HeK7GvVkm0H4XucVDB4jeMvTke0WHb_jFUiApvpOHh5VyMx5ydwFoKKcRs5x0_WwSWL0eTZ5WbVcHkDR9pSNcA_D_5AsUKOBcbpF5nkdVRxaQHuuIuwV4k1iK2IqtMNcU8vL6w21U261xCcWwJ6sMq4zzVO8QCKCQhsoIaWrwz828GDmPzfAjFsJiLJXuYivdHACkeJ5KHMt0mjVLpfJ2BCML7_rgbmvwL7wBW80VHfNdcKmKjkLcpEiPzwcQQhiN_qHV90t-p4iyr5xRSpurlP5zic2hlRkLKxMH2_kRjhqSn4aGF1dGhEYXRhWMRJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY0UAAAA0bUS6m_bsLkm5MAyP6SDLcwBAsyGQPDZRUYdb4m3rdWeyPaIMYlbmydGp1TP_33vE_lqJ3PHNyTd0iKsnKr5WjnCcBzcesZrDEfB_RBLFzU3k46UBAgMmIAEhWCBAX_i3O3DvBnkGq_uLNk_PeAX5WwO_MIxBp0mhX6Lw7yJYIOW-1-Fch829McWvRUYAHTWZTx5IycKSGECL1UzUaK_8", 15 | "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiOExCQ2lPWTNxMWNCWkhGQVd0UzRBWlpDaHpHcGh5NjdsSzdJNzB6S2k0eUM3cGdyUTJQY2g3bkFqTGsxd3E5Z3Jlc2hJQXNXMkFqaWJoWGpqSTBUbVEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" 16 | }, 17 | "type": "public-key", 18 | "clientExtensionResults": {}, 19 | "transports": [ 20 | "nfc", 21 | "usb" 22 | ] 23 | }""" 24 | challenge = base64url_to_bytes( 25 | "8LBCiOY3q1cBZHFAWtS4AZZChzGphy67lK7I70zKi4yC7pgrQ2Pch7nAjLk1wq9greshIAsW2AjibhXjjI0TmQ" 26 | ) 27 | rp_id = "localhost" 28 | expected_origin = "http://localhost:5000" 29 | 30 | verification = verify_registration_response( 31 | credential=credential, 32 | expected_challenge=challenge, 33 | expected_origin=expected_origin, 34 | expected_rp_id=rp_id, 35 | ) 36 | 37 | assert verification.fmt == AttestationFormat.PACKED 38 | assert verification.credential_id == base64url_to_bytes( 39 | "syGQPDZRUYdb4m3rdWeyPaIMYlbmydGp1TP_33vE_lqJ3PHNyTd0iKsnKr5WjnCcBzcesZrDEfB_RBLFzU3k4w" 40 | ) 41 | 42 | def test_verify_attestation_with_okp_public_key(self) -> None: 43 | credential = """{ 44 | "id": "WlHiMqH6UhUs-d43z-aGlE3nsXuEOQpa9P9pwpqb4tmvtBMBfGvAV2wUrqBCDENjkkxd6kIRzZQKcluyOFlyW_vXVZSAEgod1xj-1QmFpuwyBVnlkQGefRbmUjbEt5iE4q3tdjy65EWIekO0SNjCQx3LxIJMzi25fgUkI9Y-gg0", 45 | "rawId": "WlHiMqH6UhUs-d43z-aGlE3nsXuEOQpa9P9pwpqb4tmvtBMBfGvAV2wUrqBCDENjkkxd6kIRzZQKcluyOFlyW_vXVZSAEgod1xj-1QmFpuwyBVnlkQGefRbmUjbEt5iE4q3tdjy65EWIekO0SNjCQx3LxIJMzi25fgUkI9Y-gg0", 46 | "response": { 47 | "attestationObject": "o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEcwRQIgB3c0BvOsyJut14wj4XVWxXliicWZMZLDNF621Zz7h_8CIQCIOqRyWOazVhqlBrD-HL-83KWAvGZRgvW-4A9SE8BLFWN4NWOBWQLBMIICvTCCAaWgAwIBAgIEK_F8eDANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbjELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEnMCUGA1UEAwweWXViaWNvIFUyRiBFRSBTZXJpYWwgNzM3MjQ2MzI4MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEdMLHhCPIcS6bSPJZWGb8cECuTN8H13fVha8Ek5nt-pI8vrSflxb59Vp4bDQlH8jzXj3oW1ZwUDjHC6EnGWB5i6NsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjcwEwYLKwYBBAGC5RwCAQEEBAMCAiQwIQYLKwYBBAGC5RwBAQQEEgQQxe9V_62aS5-1gK3rr-Am0DAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCLbpN2nXhNbunZANJxAn_Cd-S4JuZsObnUiLnLLS0FPWa01TY8F7oJ8bE-aFa4kTe6NQQfi8-yiZrQ8N-JL4f7gNdQPSrH-r3iFd4SvroDe1jaJO4J9LeiFjmRdcVa-5cqNF4G1fPCofvw9W4lKnObuPakr0x_icdVq1MXhYdUtQk6Zr5mBnc4FhN9qi7DXqLHD5G7ZFUmGwfIcD2-0m1f1mwQS8yRD5-_aDCf3vutwddoi3crtivzyromwbKklR4qHunJ75LGZLZA8pJ_mXnUQ6TTsgRqPvPXgQPbSyGMf2z_DIPbQqCD_Bmc4dj9o6LozheBdDtcZCAjSPTAd_uiaGF1dGhEYXRhWOFJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY0EAAAACxe9V_62aS5-1gK3rr-Am0ACAWlHiMqH6UhUs-d43z-aGlE3nsXuEOQpa9P9pwpqb4tmvtBMBfGvAV2wUrqBCDENjkkxd6kIRzZQKcluyOFlyW_vXVZSAEgod1xj-1QmFpuwyBVnlkQGefRbmUjbEt5iE4q3tdjy65EWIekO0SNjCQx3LxIJMzi25fgUkI9Y-gg2kAQEDJyAGIVggnB_oUZDQU0esRlNPmjEO96aMDTgs34D8Dv31tAwhUZo", 48 | "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiN0pVQmpXWkZkRm96dWx4YjcxRHZIa2gzUDZXS1VHNEVsVW83d0VrS2ljMkpFVEpNQUlLQ2Y3ckJFOVlrc0k1b05qRHpRNkhxaDZFNzNPeTZTUGNNbnciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" 49 | }, 50 | "type": "public-key", 51 | "clientExtensionResults": {}, 52 | "transports": [ 53 | "usb" 54 | ] 55 | }""" 56 | challenge = base64url_to_bytes( 57 | "7JUBjWZFdFozulxb71DvHkh3P6WKUG4ElUo7wEkKic2JETJMAIKCf7rBE9YksI5oNjDzQ6Hqh6E73Oy6SPcMnw" 58 | ) 59 | rp_id = "localhost" 60 | expected_origin = "http://localhost:5000" 61 | 62 | verification = verify_registration_response( 63 | credential=credential, 64 | expected_challenge=challenge, 65 | expected_origin=expected_origin, 66 | expected_rp_id=rp_id, 67 | ) 68 | 69 | assert verification.fmt == AttestationFormat.PACKED 70 | assert verification.credential_id == base64url_to_bytes( 71 | "WlHiMqH6UhUs-d43z-aGlE3nsXuEOQpa9P9pwpqb4tmvtBMBfGvAV2wUrqBCDENjkkxd6kIRzZQKcluyOFlyW_vXVZSAEgod1xj-1QmFpuwyBVnlkQGefRbmUjbEt5iE4q3tdjy65EWIekO0SNjCQx3LxIJMzi25fgUkI9Y-gg0" 72 | ) 73 | -------------------------------------------------------------------------------- /tests/test_verify_safetynet_timestamp.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock, patch 3 | 4 | from webauthn.helpers import verify_safetynet_timestamp 5 | 6 | 7 | class TestVerifySafetyNetTimestamp(TestCase): 8 | mock_time: MagicMock 9 | # time.time() returns time in microseconds 10 | mock_now = 1636589648 11 | 12 | def setUp(self) -> None: 13 | super().setUp() 14 | time_patch = patch("time.time") 15 | self.mock_time = time_patch.start() 16 | self.mock_time.return_value = self.mock_now 17 | 18 | def tearDown(self) -> None: 19 | super().tearDown() 20 | patch.stopall() 21 | 22 | def test_does_not_raise_on_timestamp_slightly_in_future(self): 23 | # Put timestamp just a bit in the future 24 | timestamp_ms = (self.mock_now * 1000) + 600 25 | verify_safetynet_timestamp(timestamp_ms) 26 | 27 | assert True 28 | 29 | def test_does_not_raise_on_timestamp_slightly_in_past(self): 30 | # Put timestamp just a bit in the past 31 | timestamp_ms = (self.mock_now * 1000) - 600 32 | verify_safetynet_timestamp(timestamp_ms) 33 | 34 | assert True 35 | 36 | def test_raises_on_timestamp_too_far_in_future(self): 37 | # Put timestamp 20 seconds in the future 38 | timestamp_ms = (self.mock_now * 1000) + 20000 39 | self.assertRaisesRegex( 40 | ValueError, 41 | "was later than", 42 | lambda: verify_safetynet_timestamp(timestamp_ms), 43 | ) 44 | 45 | def test_raises_on_timestamp_too_far_in_past(self): 46 | # Put timestamp 20 seconds in the past 47 | timestamp_ms = (self.mock_now * 1000) - 20000 48 | self.assertRaisesRegex( 49 | ValueError, 50 | "expired", 51 | lambda: verify_safetynet_timestamp(timestamp_ms), 52 | ) 53 | 54 | def test_does_not_raise_on_last_possible_millisecond(self): 55 | # Timestamp is verified at the exact last millisecond 56 | timestamp_ms = (self.mock_now * 1000) + 10000 57 | verify_safetynet_timestamp(timestamp_ms) 58 | 59 | assert True 60 | -------------------------------------------------------------------------------- /webauthn/__init__.py: -------------------------------------------------------------------------------- 1 | from .registration.generate_registration_options import generate_registration_options 2 | from .registration.verify_registration_response import verify_registration_response 3 | from .authentication.generate_authentication_options import ( 4 | generate_authentication_options, 5 | ) 6 | from .authentication.verify_authentication_response import ( 7 | verify_authentication_response, 8 | ) 9 | from .helpers import base64url_to_bytes, options_to_json 10 | 11 | __version__ = "2.6.0" 12 | 13 | __all__ = [ 14 | "generate_registration_options", 15 | "verify_registration_response", 16 | "generate_authentication_options", 17 | "verify_authentication_response", 18 | "base64url_to_bytes", 19 | "options_to_json", 20 | ] 21 | -------------------------------------------------------------------------------- /webauthn/authentication/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duo-labs/py_webauthn/67cacb6038c7fbc18ba532e14c0f8d18015bf7b4/webauthn/authentication/__init__.py -------------------------------------------------------------------------------- /webauthn/authentication/generate_authentication_options.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from webauthn.helpers import generate_challenge 4 | from webauthn.helpers.structs import ( 5 | PublicKeyCredentialDescriptor, 6 | PublicKeyCredentialRequestOptions, 7 | UserVerificationRequirement, 8 | ) 9 | 10 | 11 | def generate_authentication_options( 12 | *, 13 | rp_id: str, 14 | challenge: Optional[bytes] = None, 15 | timeout: int = 60000, 16 | allow_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None, 17 | user_verification: UserVerificationRequirement = UserVerificationRequirement.PREFERRED, 18 | ) -> PublicKeyCredentialRequestOptions: 19 | """Generate options for retrieving a credential via navigator.credentials.get() 20 | 21 | Args: 22 | `rp_id`: The Relying Party's unique identifier as specified in attestations. 23 | (optional) `challenge`: A byte sequence for the authenticator to return back in its response. Defaults to 64 random bytes. 24 | (optional) `timeout`: How long in milliseconds the browser should give the user to choose an authenticator. This value is a *hint* and may be ignored by the browser. 25 | (optional) `allow_credentials`: A list of credentials registered to the user. 26 | (optional) `user_verification`: The RP's preference for the authenticator's enforcement of the "user verified" flag. 27 | 28 | Returns: 29 | Authentication options ready for the browser. Consider using `helpers.options_to_json()` in this library to quickly convert the options to JSON. 30 | """ 31 | 32 | if not rp_id: 33 | raise ValueError("rp_id cannot be an empty string") 34 | 35 | ######## 36 | # Set defaults for required values 37 | ######## 38 | 39 | if not challenge: 40 | challenge = generate_challenge() 41 | 42 | if not allow_credentials: 43 | allow_credentials = [] 44 | 45 | return PublicKeyCredentialRequestOptions( 46 | rp_id=rp_id, 47 | challenge=challenge, 48 | timeout=timeout, 49 | allow_credentials=allow_credentials, 50 | user_verification=user_verification, 51 | ) 52 | -------------------------------------------------------------------------------- /webauthn/authentication/verify_authentication_response.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import hashlib 3 | from typing import List, Union 4 | 5 | from cryptography.exceptions import InvalidSignature 6 | 7 | from webauthn.helpers import ( 8 | bytes_to_base64url, 9 | byteslike_to_bytes, 10 | decode_credential_public_key, 11 | decoded_public_key_to_cryptography, 12 | parse_authenticator_data, 13 | parse_backup_flags, 14 | parse_client_data_json, 15 | parse_authentication_credential_json, 16 | verify_signature, 17 | ) 18 | from webauthn.helpers.exceptions import InvalidAuthenticationResponse 19 | from webauthn.helpers.structs import ( 20 | AuthenticationCredential, 21 | ClientDataType, 22 | CredentialDeviceType, 23 | PublicKeyCredentialType, 24 | TokenBindingStatus, 25 | ) 26 | 27 | 28 | @dataclass 29 | class VerifiedAuthentication: 30 | """ 31 | Information about a verified authentication of which an RP can make use 32 | """ 33 | 34 | credential_id: bytes 35 | new_sign_count: int 36 | credential_device_type: CredentialDeviceType 37 | credential_backed_up: bool 38 | user_verified: bool 39 | 40 | 41 | expected_token_binding_statuses = [ 42 | TokenBindingStatus.SUPPORTED, 43 | TokenBindingStatus.PRESENT, 44 | ] 45 | 46 | 47 | def verify_authentication_response( 48 | *, 49 | credential: Union[str, dict, AuthenticationCredential], 50 | expected_challenge: bytes, 51 | expected_rp_id: str, 52 | expected_origin: Union[str, List[str]], 53 | credential_public_key: bytes, 54 | credential_current_sign_count: int, 55 | require_user_verification: bool = False, 56 | ) -> VerifiedAuthentication: 57 | """Verify a response from navigator.credentials.get() 58 | 59 | Args: 60 | - `credential`: The value returned from `navigator.credentials.get()`. Can be either a 61 | stringified JSON object, a plain dict, or an instance of RegistrationCredential 62 | - `expected_challenge`: The challenge passed to the authenticator within the preceding 63 | authentication options. 64 | - `expected_rp_id`: The Relying Party's unique identifier as specified in the preceding 65 | authentication options. 66 | - `expected_origin`: The domain, with HTTP protocol (e.g. "https://domain.here"), on which 67 | the authentication ceremony should have occurred. 68 | - `credential_public_key`: The public key for the credential's ID as provided in a 69 | preceding authenticator registration ceremony. 70 | - `credential_current_sign_count`: The current known number of times the authenticator was 71 | used. 72 | - (optional) `require_user_verification`: Whether or not to require that the authenticator 73 | verified the user. 74 | 75 | Returns: 76 | Information about the authenticator 77 | 78 | Raises: 79 | `helpers.exceptions.InvalidAuthenticationResponse` if the response cannot be verified 80 | """ 81 | if isinstance(credential, str) or isinstance(credential, dict): 82 | credential = parse_authentication_credential_json(credential) 83 | 84 | # FIDO-specific check 85 | if bytes_to_base64url(credential.raw_id) != credential.id: 86 | raise InvalidAuthenticationResponse("id and raw_id were not equivalent") 87 | 88 | # FIDO-specific check 89 | if credential.type != PublicKeyCredentialType.PUBLIC_KEY: 90 | raise InvalidAuthenticationResponse( 91 | f'Unexpected credential type "{credential.type}", expected "public-key"' 92 | ) 93 | 94 | response = credential.response 95 | 96 | client_data_bytes = byteslike_to_bytes(response.client_data_json) 97 | authenticator_data_bytes = byteslike_to_bytes(response.authenticator_data) 98 | signature_bytes = byteslike_to_bytes(response.signature) 99 | 100 | client_data = parse_client_data_json(client_data_bytes) 101 | 102 | if client_data.type != ClientDataType.WEBAUTHN_GET: 103 | raise InvalidAuthenticationResponse( 104 | f'Unexpected client data type "{client_data.type}", expected "{ClientDataType.WEBAUTHN_GET}"' 105 | ) 106 | 107 | if expected_challenge != client_data.challenge: 108 | raise InvalidAuthenticationResponse("Client data challenge was not expected challenge") 109 | 110 | if isinstance(expected_origin, str): 111 | if expected_origin != client_data.origin: 112 | raise InvalidAuthenticationResponse( 113 | f'Unexpected client data origin "{client_data.origin}", expected "{expected_origin}"' 114 | ) 115 | else: 116 | try: 117 | expected_origin.index(client_data.origin) 118 | except ValueError: 119 | raise InvalidAuthenticationResponse( 120 | f'Unexpected client data origin "{client_data.origin}", expected one of {expected_origin}' 121 | ) 122 | 123 | if client_data.token_binding: 124 | status = client_data.token_binding.status 125 | if status not in expected_token_binding_statuses: 126 | raise InvalidAuthenticationResponse( 127 | f'Unexpected token_binding status of "{status}", expected one of "{",".join(expected_token_binding_statuses)}"' 128 | ) 129 | 130 | auth_data = parse_authenticator_data(authenticator_data_bytes) 131 | 132 | # Generate a hash of the expected RP ID for comparison 133 | expected_rp_id_hash = hashlib.sha256() 134 | expected_rp_id_hash.update(expected_rp_id.encode("utf-8")) 135 | expected_rp_id_hash_bytes = expected_rp_id_hash.digest() 136 | 137 | if auth_data.rp_id_hash != expected_rp_id_hash_bytes: 138 | raise InvalidAuthenticationResponse("Unexpected RP ID hash") 139 | 140 | if not auth_data.flags.up: 141 | raise InvalidAuthenticationResponse("User was not present during authentication") 142 | 143 | if require_user_verification and not auth_data.flags.uv: 144 | raise InvalidAuthenticationResponse( 145 | "User verification is required but user was not verified during authentication" 146 | ) 147 | 148 | if ( 149 | auth_data.sign_count > 0 or credential_current_sign_count > 0 150 | ) and auth_data.sign_count <= credential_current_sign_count: 151 | # Require the sign count to have been incremented over what was reported by the 152 | # authenticator the last time this credential was used, otherwise this might be 153 | # a replay attack 154 | raise InvalidAuthenticationResponse( 155 | f"Response sign count of {auth_data.sign_count} was not greater than current count of {credential_current_sign_count}" 156 | ) 157 | 158 | client_data_hash = hashlib.sha256() 159 | client_data_hash.update(client_data_bytes) 160 | client_data_hash_bytes = client_data_hash.digest() 161 | 162 | signature_base = authenticator_data_bytes + client_data_hash_bytes 163 | 164 | try: 165 | decoded_public_key = decode_credential_public_key(credential_public_key) 166 | crypto_public_key = decoded_public_key_to_cryptography(decoded_public_key) 167 | 168 | verify_signature( 169 | public_key=crypto_public_key, 170 | signature_alg=decoded_public_key.alg, 171 | signature=signature_bytes, 172 | data=signature_base, 173 | ) 174 | except InvalidSignature: 175 | raise InvalidAuthenticationResponse("Could not verify authentication signature") 176 | 177 | parsed_backup_flags = parse_backup_flags(auth_data.flags) 178 | 179 | return VerifiedAuthentication( 180 | credential_id=credential.raw_id, 181 | new_sign_count=auth_data.sign_count, 182 | credential_device_type=parsed_backup_flags.credential_device_type, 183 | credential_backed_up=parsed_backup_flags.credential_backed_up, 184 | user_verified=auth_data.flags.uv, 185 | ) 186 | -------------------------------------------------------------------------------- /webauthn/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | from .aaguid_to_string import aaguid_to_string 2 | from .base64url_to_bytes import base64url_to_bytes 3 | from .bytes_to_base64url import bytes_to_base64url 4 | from .byteslike_to_bytes import byteslike_to_bytes 5 | from .decode_credential_public_key import decode_credential_public_key 6 | from .decoded_public_key_to_cryptography import decoded_public_key_to_cryptography 7 | from .encode_cbor import encode_cbor 8 | from .generate_challenge import generate_challenge 9 | from .generate_user_handle import generate_user_handle 10 | from .hash_by_alg import hash_by_alg 11 | from .options_to_json import options_to_json 12 | from .options_to_json_dict import options_to_json_dict 13 | from .parse_attestation_object import parse_attestation_object 14 | from .parse_authentication_credential_json import parse_authentication_credential_json 15 | from .parse_authentication_options_json import parse_authentication_options_json 16 | from .parse_authenticator_data import parse_authenticator_data 17 | from .parse_backup_flags import parse_backup_flags 18 | from .parse_cbor import parse_cbor 19 | from .parse_client_data_json import parse_client_data_json 20 | from .parse_registration_credential_json import parse_registration_credential_json 21 | from .parse_registration_options_json import parse_registration_options_json 22 | from .validate_certificate_chain import validate_certificate_chain 23 | from .verify_safetynet_timestamp import verify_safetynet_timestamp 24 | from .verify_signature import verify_signature 25 | 26 | __all__ = [ 27 | "aaguid_to_string", 28 | "base64url_to_bytes", 29 | "bytes_to_base64url", 30 | "byteslike_to_bytes", 31 | "decode_credential_public_key", 32 | "decoded_public_key_to_cryptography", 33 | "encode_cbor", 34 | "generate_challenge", 35 | "generate_user_handle", 36 | "hash_by_alg", 37 | "options_to_json", 38 | "options_to_json_dict", 39 | "parse_attestation_object", 40 | "parse_authenticator_data", 41 | "parse_authentication_credential_json", 42 | "parse_authentication_options_json", 43 | "parse_backup_flags", 44 | "parse_cbor", 45 | "parse_client_data_json", 46 | "parse_registration_credential_json", 47 | "parse_registration_options_json", 48 | "validate_certificate_chain", 49 | "verify_safetynet_timestamp", 50 | "verify_signature", 51 | ] 52 | -------------------------------------------------------------------------------- /webauthn/helpers/aaguid_to_string.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | 3 | 4 | def aaguid_to_string(val: bytes) -> str: 5 | """ 6 | Take aaguid bytes and convert them to a GUID string 7 | """ 8 | if len(val) != 16: 9 | raise ValueError(f"AAGUID was {len(val)} bytes, expected 16 bytes") 10 | 11 | # Convert to a hexadecimal string representation 12 | to_hex = codecs.encode(val, encoding="hex").decode("utf-8") 13 | 14 | # Split up the hex string into segments 15 | # 8 chars 16 | seg_1 = to_hex[0:8] 17 | # 4 chars 18 | seg_2 = to_hex[8:12] 19 | # 4 chars 20 | seg_3 = to_hex[12:16] 21 | # 4 chars 22 | seg_4 = to_hex[16:20] 23 | # 12 chars 24 | seg_5 = to_hex[20:32] 25 | 26 | # "00000000-0000-0000-0000-000000000000" 27 | return f"{seg_1}-{seg_2}-{seg_3}-{seg_4}-{seg_5}" 28 | -------------------------------------------------------------------------------- /webauthn/helpers/algorithms.py: -------------------------------------------------------------------------------- 1 | from cryptography.hazmat.primitives.asymmetric.ec import ( 2 | ECDSA, 3 | SECP256R1, 4 | SECP384R1, 5 | SECP521R1, 6 | EllipticCurve, 7 | EllipticCurveSignatureAlgorithm, 8 | ) 9 | from cryptography.hazmat.primitives.hashes import ( 10 | SHA1, 11 | SHA256, 12 | SHA384, 13 | SHA512, 14 | HashAlgorithm, 15 | ) 16 | 17 | from .cose import COSECRV, COSEAlgorithmIdentifier 18 | from .exceptions import UnsupportedAlgorithm, UnsupportedEC2Curve 19 | 20 | 21 | def is_rsa_pkcs(alg_id: COSEAlgorithmIdentifier) -> bool: 22 | """Determine if the specified COSE algorithm ID denotes an RSA PKCSv1 public key""" 23 | return alg_id in ( 24 | COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_1, 25 | COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, 26 | COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384, 27 | COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512, 28 | ) 29 | 30 | 31 | def is_rsa_pss(alg_id: COSEAlgorithmIdentifier) -> bool: 32 | """Determine if the specified COSE algorithm ID denotes an RSA PSS public key""" 33 | return alg_id in ( 34 | COSEAlgorithmIdentifier.RSASSA_PSS_SHA_256, 35 | COSEAlgorithmIdentifier.RSASSA_PSS_SHA_384, 36 | COSEAlgorithmIdentifier.RSASSA_PSS_SHA_512, 37 | ) 38 | 39 | 40 | def get_ec2_sig_alg(alg_id: COSEAlgorithmIdentifier) -> EllipticCurveSignatureAlgorithm: 41 | """Turn an "ECDSA" COSE algorithm identifier into a corresponding signature 42 | algorithm 43 | """ 44 | if alg_id == COSEAlgorithmIdentifier.ECDSA_SHA_256: 45 | return ECDSA(SHA256()) 46 | if alg_id == COSEAlgorithmIdentifier.ECDSA_SHA_512: 47 | return ECDSA(SHA512()) 48 | 49 | raise UnsupportedAlgorithm(f"Unrecognized EC2 signature alg {alg_id}") 50 | 51 | 52 | def get_ec2_curve(crv_id: COSECRV) -> EllipticCurve: 53 | """Turn an EC2 COSE crv identifier into a corresponding curve""" 54 | if crv_id == COSECRV.P256: 55 | return SECP256R1() 56 | elif crv_id == COSECRV.P384: 57 | return SECP384R1() 58 | elif crv_id == COSECRV.P521: 59 | return SECP521R1() 60 | 61 | raise UnsupportedEC2Curve(f"Unrecognized EC2 curve {crv_id}") 62 | 63 | 64 | def get_rsa_pkcs1_sig_alg(alg_id: COSEAlgorithmIdentifier) -> HashAlgorithm: 65 | """Turn an "RSASSA_PKCS1" COSE algorithm identifier into a corresponding signature 66 | algorithm 67 | """ 68 | if alg_id == COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_1: 69 | return SHA1() 70 | if alg_id == COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256: 71 | return SHA256() 72 | if alg_id == COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384: 73 | return SHA384() 74 | if alg_id == COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512: 75 | return SHA512() 76 | 77 | raise UnsupportedAlgorithm(f"Unrecognized RSA PKCS1 signature alg {alg_id}") 78 | 79 | 80 | def get_rsa_pss_sig_alg(alg_id: COSEAlgorithmIdentifier) -> HashAlgorithm: 81 | """Turn an "RSASSA_PSS" COSE algorithm identifier into a corresponding signature 82 | algorithm 83 | """ 84 | if alg_id == COSEAlgorithmIdentifier.RSASSA_PSS_SHA_256: 85 | return SHA256() 86 | if alg_id == COSEAlgorithmIdentifier.RSASSA_PSS_SHA_384: 87 | return SHA384() 88 | if alg_id == COSEAlgorithmIdentifier.RSASSA_PSS_SHA_512: 89 | return SHA512() 90 | 91 | raise UnsupportedAlgorithm(f"Unrecognized RSA PSS signature alg {alg_id}") 92 | -------------------------------------------------------------------------------- /webauthn/helpers/asn1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duo-labs/py_webauthn/67cacb6038c7fbc18ba532e14c0f8d18015bf7b4/webauthn/helpers/asn1/__init__.py -------------------------------------------------------------------------------- /webauthn/helpers/asn1/android_key.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from asn1crypto.core import ( 4 | Boolean, 5 | Enumerated, 6 | Integer, 7 | Null, 8 | OctetString, 9 | Sequence, 10 | SetOf, 11 | ) 12 | 13 | 14 | class Integers(SetOf): 15 | _child_spec = Integer 16 | 17 | 18 | class SecurityLevel(Enumerated): 19 | _map = { 20 | 0: "Software", 21 | 1: "TrustedEnvironment", 22 | 2: "StrongBox", 23 | } 24 | 25 | 26 | class VerifiedBootState(Enumerated): 27 | _map = { 28 | 0: "Verified", 29 | 1: "SelfSigned", 30 | 2: "Unverified", 31 | 3: "Failed", 32 | } 33 | 34 | 35 | class RootOfTrust(Sequence): 36 | _fields = [ 37 | ("verifiedBootKey", OctetString), 38 | ("deviceLocked", Boolean), 39 | ("verifiedBootState", VerifiedBootState), 40 | ("verifiedBootHash", OctetString), 41 | ] 42 | 43 | 44 | class AuthorizationList(Sequence): 45 | _fields = [ 46 | ("purpose", Integers, {"explicit": 1, "optional": True}), 47 | ("algorithm", Integer, {"explicit": 2, "optional": True}), 48 | ("keySize", Integer, {"explicit": 3, "optional": True}), 49 | ("digest", Integers, {"explicit": 5, "optional": True}), 50 | ("padding", Integers, {"explicit": 6, "optional": True}), 51 | ("ecCurve", Integer, {"explicit": 10, "optional": True}), 52 | ("rsaPublicExponent", Integer, {"explicit": 200, "optional": True}), 53 | ("rollbackResistance", Null, {"explicit": 303, "optional": True}), 54 | ("activeDateTime", Integer, {"explicit": 400, "optional": True}), 55 | ("originationExpireDateTime", Integer, {"explicit": 401, "optional": True}), 56 | ("usageExpireDateTime", Integer, {"explicit": 402, "optional": True}), 57 | ("noAuthRequired", Null, {"explicit": 503, "optional": True}), 58 | ("userAuthType", Integer, {"explicit": 504, "optional": True}), 59 | ("authTimeout", Integer, {"explicit": 505, "optional": True}), 60 | ("allowWhileOnBody", Null, {"explicit": 506, "optional": True}), 61 | ("trustedUserPresenceRequired", Null, {"explicit": 507, "optional": True}), 62 | ("trustedConfirmationRequired", Null, {"explicit": 508, "optional": True}), 63 | ("unlockedDeviceRequired", Null, {"explicit": 509, "optional": True}), 64 | ("allApplications", Null, {"explicit": 600, "optional": True}), 65 | ("applicationId", OctetString, {"explicit": 601, "optional": True}), 66 | ("creationDateTime", Integer, {"explicit": 701, "optional": True}), 67 | ("origin", Integer, {"explicit": 702, "optional": True}), 68 | ("rollbackResistant", Null, {"explicit": 703, "optional": True}), 69 | ("rootOfTrust", RootOfTrust, {"explicit": 704, "optional": True}), 70 | ("osVersion", Integer, {"explicit": 705, "optional": True}), 71 | ("osPatchLevel", Integer, {"explicit": 706, "optional": True}), 72 | ("attestationApplicationId", OctetString, {"explicit": 709, "optional": True}), 73 | ("attestationIdBrand", OctetString, {"explicit": 710, "optional": True}), 74 | ("attestationIdDevice", OctetString, {"explicit": 711, "optional": True}), 75 | ("attestationIdProduct", OctetString, {"explicit": 712, "optional": True}), 76 | ("attestationIdSerial", OctetString, {"explicit": 713, "optional": True}), 77 | ("attestationIdImei", OctetString, {"explicit": 714, "optional": True}), 78 | ("attestationIdMeid", OctetString, {"explicit": 715, "optional": True}), 79 | ("attestationIdManufacturer", OctetString, {"explicit": 716, "optional": True}), 80 | ("attestationIdModel", OctetString, {"explicit": 717, "optional": True}), 81 | ("vendorPatchLevel", Integer, {"explicit": 718, "optional": True}), 82 | ("bootPatchLevel", Integer, {"explicit": 719, "optional": True}), 83 | ] 84 | 85 | 86 | class KeyDescription(Sequence): 87 | """Attestation extension content as ASN.1 schema (DER-encoded) 88 | 89 | Corresponds to X.509 certificate extension with the following OID: 90 | 91 | `1.3.6.1.4.1.11129.2.1.17` 92 | 93 | See https://source.android.com/security/keystore/attestation#schema 94 | """ 95 | 96 | _fields = [ 97 | ("attestationVersion", Integer), 98 | ("attestationSecurityLevel", SecurityLevel), 99 | ("keymasterVersion", Integer), 100 | ("keymasterSecurityLevel", SecurityLevel), 101 | ("attestationChallenge", OctetString), 102 | ("uniqueId", OctetString), 103 | ("softwareEnforced", AuthorizationList), 104 | ("teeEnforced", AuthorizationList), 105 | ] 106 | 107 | 108 | class KeyOrigin(int, Enum): 109 | """`Tag::ORIGIN` 110 | 111 | See https://source.android.com/security/keystore/tags#origin 112 | """ 113 | 114 | GENERATED = 0 115 | DERIVED = 1 116 | IMPORTED = 2 117 | UNKNOWN = 3 118 | 119 | 120 | class KeyPurpose(int, Enum): 121 | """`Tag::PURPOSE` 122 | 123 | See https://source.android.com/security/keystore/tags#purpose 124 | """ 125 | 126 | ENCRYPT = 0 127 | DECRYPT = 1 128 | SIGN = 2 129 | VERIFY = 3 130 | DERIVE_KEY = 4 131 | WRAP_KEY = 5 132 | -------------------------------------------------------------------------------- /webauthn/helpers/base64url_to_bytes.py: -------------------------------------------------------------------------------- 1 | from base64 import urlsafe_b64decode 2 | 3 | 4 | def base64url_to_bytes(val: str) -> bytes: 5 | """ 6 | Convert a Base64URL-encoded string to bytes. 7 | """ 8 | # Padding is optional in Base64URL. Unfortunately, Python's decoder requires the 9 | # padding. Given the fact that urlsafe_b64decode will ignore too _much_ padding, 10 | # we can tack on a constant amount of padding to ensure encoded values can always be 11 | # decoded. 12 | return urlsafe_b64decode(f"{val}===") 13 | -------------------------------------------------------------------------------- /webauthn/helpers/bytes_to_base64url.py: -------------------------------------------------------------------------------- 1 | from base64 import urlsafe_b64encode 2 | 3 | 4 | def bytes_to_base64url(val: bytes) -> str: 5 | """ 6 | Base64URL-encode the provided bytes 7 | """ 8 | return urlsafe_b64encode(val).decode("utf-8").rstrip("=") 9 | -------------------------------------------------------------------------------- /webauthn/helpers/byteslike_to_bytes.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | 4 | def byteslike_to_bytes(val: Union[bytes, memoryview]) -> bytes: 5 | """ 6 | Massage bytes subclasses into bytes for ease of concatenation, comparison, etc... 7 | """ 8 | if isinstance(val, memoryview): 9 | val = val.tobytes() 10 | 11 | return bytes(val) 12 | -------------------------------------------------------------------------------- /webauthn/helpers/cose.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class COSEAlgorithmIdentifier(int, Enum): 5 | """Various registered values indicating cryptographic algorithms that may be used in credential responses 6 | 7 | Members: 8 | `ECDSA_SHA_256` 9 | `EDDSA` 10 | `ECDSA_SHA_512` 11 | `RSASSA_PSS_SHA_256` 12 | `RSASSA_PSS_SHA_384` 13 | `RSASSA_PSS_SHA_512` 14 | `RSASSA_PKCS1_v1_5_SHA_256` 15 | `RSASSA_PKCS1_v1_5_SHA_384` 16 | `RSASSA_PKCS1_v1_5_SHA_512` 17 | `RSASSA_PKCS1_v1_5_SHA_1` 18 | 19 | https://www.w3.org/TR/webauthn-2/#sctn-alg-identifier 20 | https://www.iana.org/assignments/cose/cose.xhtml#algorithms 21 | """ 22 | 23 | ECDSA_SHA_256 = -7 24 | EDDSA = -8 25 | ECDSA_SHA_512 = -36 26 | RSASSA_PSS_SHA_256 = -37 27 | RSASSA_PSS_SHA_384 = -38 28 | RSASSA_PSS_SHA_512 = -39 29 | RSASSA_PKCS1_v1_5_SHA_256 = -257 30 | RSASSA_PKCS1_v1_5_SHA_384 = -258 31 | RSASSA_PKCS1_v1_5_SHA_512 = -259 32 | RSASSA_PKCS1_v1_5_SHA_1 = -65535 # Deprecated; here for legacy support 33 | 34 | 35 | class COSEKTY(int, Enum): 36 | """ 37 | Possible values for COSEKey.KTY representing a public key's key type 38 | 39 | https://tools.ietf.org/html/rfc8152#section-13 40 | https://www.iana.org/assignments/cose/cose.xhtml#table-key-type 41 | """ 42 | 43 | OKP = 1 44 | EC2 = 2 45 | RSA = 3 46 | 47 | 48 | class COSECRV(int, Enum): 49 | """Possible values for COSEKey.CRV representing an EC2 public key's curve 50 | 51 | https://tools.ietf.org/html/rfc8152#section-13.1 52 | https://www.iana.org/assignments/cose/cose.xhtml#table-elliptic-curves 53 | """ 54 | 55 | P256 = 1 # EC2, NIST P-256 also known as secp256r1 56 | P384 = 2 # EC2, NIST P-384 also known as secp384r1 57 | P521 = 3 # EC2, NIST P-521 also known as secp521r1 58 | ED25519 = 6 # OKP, Ed25519 for use w/ EdDSA only 59 | 60 | 61 | class COSEKey(int, Enum): 62 | """ 63 | COSE keys for public keys 64 | 65 | https://tools.ietf.org/html/rfc8152 66 | https://www.iana.org/assignments/cose/cose.xhtml#table-key-common-parameters 67 | https://www.iana.org/assignments/cose/cose.xhtml#table-key-type-parameters 68 | """ 69 | 70 | KTY = 1 71 | ALG = 3 72 | # EC2, OKP 73 | CRV = -1 74 | X = -2 75 | # EC2 76 | Y = -3 77 | # RSA 78 | N = -1 79 | E = -2 80 | -------------------------------------------------------------------------------- /webauthn/helpers/decode_credential_public_key.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | from dataclasses import dataclass 3 | 4 | import cbor2 5 | 6 | from .cose import COSECRV, COSEKTY, COSEAlgorithmIdentifier, COSEKey 7 | from .exceptions import InvalidPublicKeyStructure, UnsupportedPublicKeyType 8 | from .parse_cbor import parse_cbor 9 | 10 | 11 | @dataclass 12 | class DecodedOKPPublicKey: 13 | kty: COSEKTY 14 | alg: COSEAlgorithmIdentifier 15 | crv: COSECRV 16 | x: bytes 17 | 18 | 19 | @dataclass 20 | class DecodedEC2PublicKey: 21 | kty: COSEKTY 22 | alg: COSEAlgorithmIdentifier 23 | crv: COSECRV 24 | x: bytes 25 | y: bytes 26 | 27 | 28 | @dataclass 29 | class DecodedRSAPublicKey: 30 | kty: COSEKTY 31 | alg: COSEAlgorithmIdentifier 32 | n: bytes 33 | e: bytes 34 | 35 | 36 | def decode_credential_public_key( 37 | key: bytes, 38 | ) -> Union[DecodedOKPPublicKey, DecodedEC2PublicKey, DecodedRSAPublicKey]: 39 | """ 40 | Decode a CBOR-encoded public key and turn it into a data structure. 41 | 42 | Supports OKP, EC2, and RSA public keys 43 | """ 44 | # Occasionally we might be given a public key in an "uncompressed" format, 45 | # typically from older U2F security keys. As per the FIDO spec this is indicated by 46 | # a leading 0x04 "uncompressed point compression method" format byte. In that case 47 | # we need to fill in some blanks to turn it into a full EC2 key for signature 48 | # verification 49 | # 50 | # See https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-registry-v2.0-id-20180227.html#public-key-representation-formats 51 | if key[0] == 0x04: 52 | return DecodedEC2PublicKey( 53 | kty=COSEKTY.EC2, 54 | alg=COSEAlgorithmIdentifier.ECDSA_SHA_256, 55 | crv=COSECRV.P256, 56 | x=key[1:33], 57 | y=key[33:65], 58 | ) 59 | 60 | decoded_key: dict = parse_cbor(key) 61 | 62 | kty = decoded_key[COSEKey.KTY] 63 | alg = decoded_key[COSEKey.ALG] 64 | 65 | if not kty: 66 | raise InvalidPublicKeyStructure("Credential public key missing kty") 67 | if not alg: 68 | raise InvalidPublicKeyStructure("Credential public key missing alg") 69 | 70 | if kty == COSEKTY.OKP: 71 | crv = decoded_key[COSEKey.CRV] 72 | x = decoded_key[COSEKey.X] 73 | 74 | if not crv: 75 | raise InvalidPublicKeyStructure("OKP credential public key missing crv") 76 | if not x: 77 | raise InvalidPublicKeyStructure("OKP credential public key missing x") 78 | 79 | return DecodedOKPPublicKey( 80 | kty=kty, 81 | alg=alg, 82 | crv=crv, 83 | x=x, 84 | ) 85 | elif kty == COSEKTY.EC2: 86 | crv = decoded_key[COSEKey.CRV] 87 | x = decoded_key[COSEKey.X] 88 | y = decoded_key[COSEKey.Y] 89 | 90 | if not crv: 91 | raise InvalidPublicKeyStructure("EC2 credential public key missing crv") 92 | if not x: 93 | raise InvalidPublicKeyStructure("EC2 credential public key missing x") 94 | if not y: 95 | raise InvalidPublicKeyStructure("EC2 credential public key missing y") 96 | 97 | return DecodedEC2PublicKey( 98 | kty=kty, 99 | alg=alg, 100 | crv=crv, 101 | x=x, 102 | y=y, 103 | ) 104 | elif kty == COSEKTY.RSA: 105 | n = decoded_key[COSEKey.N] 106 | e = decoded_key[COSEKey.E] 107 | 108 | if not n: 109 | raise InvalidPublicKeyStructure("RSA credential public key missing n") 110 | if not e: 111 | raise InvalidPublicKeyStructure("RSA credential public key missing e") 112 | 113 | return DecodedRSAPublicKey( 114 | kty=kty, 115 | alg=alg, 116 | n=n, 117 | e=e, 118 | ) 119 | 120 | raise UnsupportedPublicKeyType(f'Unsupported credential public key type "{kty}"') 121 | -------------------------------------------------------------------------------- /webauthn/helpers/decoded_public_key_to_cryptography.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | from typing import Union 3 | 4 | from cryptography.hazmat.primitives.asymmetric.ec import ( 5 | EllipticCurvePublicKey, 6 | EllipticCurvePublicNumbers, 7 | ) 8 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey 9 | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey, RSAPublicNumbers 10 | 11 | from .algorithms import get_ec2_curve 12 | from .cose import COSECRV, COSEAlgorithmIdentifier 13 | from .decode_credential_public_key import ( 14 | DecodedEC2PublicKey, 15 | DecodedOKPPublicKey, 16 | DecodedRSAPublicKey, 17 | ) 18 | from .exceptions import UnsupportedPublicKey 19 | 20 | 21 | def decoded_public_key_to_cryptography( 22 | public_key: Union[DecodedOKPPublicKey, DecodedEC2PublicKey, DecodedRSAPublicKey] 23 | ) -> Union[Ed25519PublicKey, EllipticCurvePublicKey, RSAPublicKey]: 24 | """Convert raw decoded public key parameters (crv, x, y, n, e, etc...) into 25 | public keys using primitives from the cryptography.io library 26 | """ 27 | if isinstance(public_key, DecodedEC2PublicKey): 28 | """ 29 | alg is -7 (ES256), where kty is 2 (with uncompressed points) and 30 | crv is 1 (P-256). 31 | https://www.w3.org/TR/webauthn-2/#sctn-public-key-easy 32 | """ 33 | x = int(codecs.encode(public_key.x, "hex"), 16) 34 | y = int(codecs.encode(public_key.y, "hex"), 16) 35 | curve = get_ec2_curve(public_key.crv) 36 | 37 | ecc_pub_key = EllipticCurvePublicNumbers(x, y, curve).public_key() 38 | 39 | return ecc_pub_key 40 | elif isinstance(public_key, DecodedRSAPublicKey): 41 | """ 42 | alg is -257 (RS256) 43 | https://www.w3.org/TR/webauthn-2/#sctn-public-key-easy 44 | """ 45 | e = int(codecs.encode(public_key.e, "hex"), 16) 46 | n = int(codecs.encode(public_key.n, "hex"), 16) 47 | 48 | rsa_pub_key = RSAPublicNumbers(e, n).public_key() 49 | 50 | return rsa_pub_key 51 | elif isinstance(public_key, DecodedOKPPublicKey): 52 | """ 53 | -8 (EdDSA), where crv is 6 (Ed25519). 54 | https://www.w3.org/TR/webauthn-2/#sctn-public-key-easy 55 | """ 56 | if public_key.alg != COSEAlgorithmIdentifier.EDDSA or public_key.crv != COSECRV.ED25519: 57 | raise UnsupportedPublicKey( 58 | f"OKP public key with alg {public_key.alg} and crv {public_key.crv} is not supported" 59 | ) 60 | 61 | okp_pub_key = Ed25519PublicKey.from_public_bytes(public_key.x) 62 | 63 | return okp_pub_key 64 | else: 65 | raise UnsupportedPublicKey(f"Unrecognized decoded public key: {public_key}") 66 | -------------------------------------------------------------------------------- /webauthn/helpers/encode_cbor.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import cbor2 4 | 5 | from .exceptions import InvalidCBORData 6 | 7 | 8 | def encode_cbor(val: Any) -> bytes: 9 | """ 10 | Attempt to encode data into CBOR. 11 | 12 | Raises: 13 | `helpers.exceptions.InvalidCBORData` if data cannot be decoded 14 | """ 15 | try: 16 | to_return = cbor2.dumps(val) 17 | except Exception as exc: 18 | raise InvalidCBORData("Data could not be encoded to CBOR") from exc 19 | 20 | return to_return 21 | -------------------------------------------------------------------------------- /webauthn/helpers/exceptions.py: -------------------------------------------------------------------------------- 1 | class WebAuthnException(Exception): 2 | pass 3 | 4 | 5 | class InvalidRegistrationOptions(WebAuthnException): 6 | pass 7 | 8 | 9 | class InvalidRegistrationResponse(WebAuthnException): 10 | pass 11 | 12 | 13 | class InvalidAuthenticationOptions(WebAuthnException): 14 | pass 15 | 16 | 17 | class InvalidAuthenticationResponse(WebAuthnException): 18 | pass 19 | 20 | 21 | class InvalidPublicKeyStructure(WebAuthnException): 22 | pass 23 | 24 | 25 | class UnsupportedPublicKeyType(WebAuthnException): 26 | pass 27 | 28 | 29 | class InvalidJSONStructure(WebAuthnException): 30 | pass 31 | 32 | 33 | class InvalidAuthenticatorDataStructure(WebAuthnException): 34 | pass 35 | 36 | 37 | class SignatureVerificationException(WebAuthnException): 38 | pass 39 | 40 | 41 | class UnsupportedAlgorithm(WebAuthnException): 42 | pass 43 | 44 | 45 | class UnsupportedPublicKey(WebAuthnException): 46 | pass 47 | 48 | 49 | class UnsupportedEC2Curve(WebAuthnException): 50 | pass 51 | 52 | 53 | class InvalidTPMPubAreaStructure(WebAuthnException): 54 | pass 55 | 56 | 57 | class InvalidTPMCertInfoStructure(WebAuthnException): 58 | pass 59 | 60 | 61 | class InvalidCertificateChain(WebAuthnException): 62 | pass 63 | 64 | 65 | class InvalidBackupFlags(WebAuthnException): 66 | pass 67 | 68 | 69 | class InvalidCBORData(WebAuthnException): 70 | pass 71 | -------------------------------------------------------------------------------- /webauthn/helpers/generate_challenge.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | 4 | def generate_challenge() -> bytes: 5 | """ 6 | Create a random value for the authenticator to sign, going above and beyond the recommended 7 | number of random bytes as per https://www.w3.org/TR/webauthn-2/#sctn-cryptographic-challenges: 8 | 9 | "In order to prevent replay attacks, the challenges MUST contain enough entropy to make 10 | guessing them infeasible. Challenges SHOULD therefore be at least 16 bytes long." 11 | """ 12 | return secrets.token_bytes(64) 13 | -------------------------------------------------------------------------------- /webauthn/helpers/generate_user_handle.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | 4 | def generate_user_handle() -> bytes: 5 | """ 6 | Convenience method RP's can use to generate a privacy-preserving random sequence of 7 | bytes as per best practices defined in the WebAuthn spec. This value is intended to 8 | be used as the value of `user_id` when calling `generate_registration_options()`, 9 | and can then be used during authentication verification to match the credential to 10 | a user. 11 | 12 | See https://www.w3.org/TR/webauthn-2/#sctn-user-handle-privacy: 13 | 14 | "It is RECOMMENDED to let the user handle be 64 random bytes, and store this value 15 | in the user's account." 16 | """ 17 | return secrets.token_bytes(64) 18 | -------------------------------------------------------------------------------- /webauthn/helpers/hash_by_alg.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from typing import Optional 3 | 4 | from .cose import COSEAlgorithmIdentifier 5 | 6 | SHA_256 = [ 7 | COSEAlgorithmIdentifier.ECDSA_SHA_256, 8 | COSEAlgorithmIdentifier.RSASSA_PSS_SHA_256, 9 | COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, 10 | ] 11 | SHA_384 = [ 12 | COSEAlgorithmIdentifier.RSASSA_PSS_SHA_384, 13 | COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384, 14 | ] 15 | SHA_512 = [ 16 | COSEAlgorithmIdentifier.ECDSA_SHA_512, 17 | COSEAlgorithmIdentifier.RSASSA_PSS_SHA_512, 18 | COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512, 19 | ] 20 | SHA_1 = [ 21 | COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_1, 22 | ] 23 | 24 | 25 | def hash_by_alg(to_hash: bytes, alg: Optional[COSEAlgorithmIdentifier] = None) -> bytes: 26 | """ 27 | Generate a hash of `to_hash` by the specified COSE algorithm ID. Defaults to hashing 28 | with SHA256 29 | """ 30 | # Default to SHA256 for hashing 31 | hash = hashlib.sha256() 32 | 33 | if alg in SHA_384: 34 | hash = hashlib.sha384() 35 | elif alg in SHA_512: 36 | hash = hashlib.sha512() 37 | elif alg in SHA_1: 38 | hash = hashlib.sha1() 39 | 40 | hash.update(to_hash) 41 | return hash.digest() 42 | -------------------------------------------------------------------------------- /webauthn/helpers/options_to_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Union 3 | 4 | from .structs import ( 5 | PublicKeyCredentialCreationOptions, 6 | PublicKeyCredentialRequestOptions, 7 | ) 8 | from .options_to_json_dict import options_to_json_dict 9 | 10 | 11 | def options_to_json( 12 | options: Union[ 13 | PublicKeyCredentialCreationOptions, 14 | PublicKeyCredentialRequestOptions, 15 | ], 16 | ) -> str: 17 | """ 18 | Convert registration or authentication options into a simple JSON dictionary, and then stringify 19 | the result to send to the front end as `Content-Type: application/json`. Alternatively use 20 | `webauthn.helpers.options_to_json_dict` to get a raw `dict` instead to combine the options with 21 | other data beforehand/encode with a different scheme/etc... 22 | """ 23 | return json.dumps(options_to_json_dict(options=options)) 24 | -------------------------------------------------------------------------------- /webauthn/helpers/options_to_json_dict.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Dict, Any 2 | 3 | from .structs import ( 4 | PublicKeyCredentialCreationOptions, 5 | PublicKeyCredentialRequestOptions, 6 | ) 7 | from .bytes_to_base64url import bytes_to_base64url 8 | 9 | 10 | def options_to_json_dict( 11 | options: Union[ 12 | PublicKeyCredentialCreationOptions, 13 | PublicKeyCredentialRequestOptions, 14 | ], 15 | ) -> Dict[str, Any]: 16 | """ 17 | Convert registration or authentication options into a simple JSON dictionary. Alternatively, use 18 | `webauthn.helpers.options_to_json` to perform this conversion and then stringify the resulting 19 | `dict` to make it easier to send to the front end. 20 | """ 21 | if isinstance(options, PublicKeyCredentialCreationOptions): 22 | _rp = {"name": options.rp.name} 23 | if options.rp.id: 24 | _rp["id"] = options.rp.id 25 | 26 | _user: Dict[str, Any] = { 27 | "id": bytes_to_base64url(options.user.id), 28 | "name": options.user.name, 29 | "displayName": options.user.display_name, 30 | } 31 | 32 | reg_to_return: Dict[str, Any] = { 33 | "rp": _rp, 34 | "user": _user, 35 | "challenge": bytes_to_base64url(options.challenge), 36 | "pubKeyCredParams": [ 37 | {"type": param.type, "alg": param.alg} for param in options.pub_key_cred_params 38 | ], 39 | } 40 | 41 | # Begin handling optional values 42 | 43 | if options.timeout is not None: 44 | reg_to_return["timeout"] = options.timeout 45 | 46 | if options.exclude_credentials is not None: 47 | _excluded = options.exclude_credentials 48 | json_excluded = [] 49 | 50 | for cred in _excluded: 51 | json_excluded_cred: Dict[str, Any] = { 52 | "id": bytes_to_base64url(cred.id), 53 | "type": cred.type.value, 54 | } 55 | 56 | if cred.transports: 57 | json_excluded_cred["transports"] = [ 58 | transport.value for transport in cred.transports 59 | ] 60 | 61 | json_excluded.append(json_excluded_cred) 62 | 63 | reg_to_return["excludeCredentials"] = json_excluded 64 | 65 | if options.authenticator_selection is not None: 66 | _selection = options.authenticator_selection 67 | json_selection: Dict[str, Any] = {} 68 | 69 | if _selection.authenticator_attachment is not None: 70 | json_selection["authenticatorAttachment"] = ( 71 | _selection.authenticator_attachment.value 72 | ) 73 | 74 | if _selection.resident_key is not None: 75 | json_selection["residentKey"] = _selection.resident_key.value 76 | 77 | if _selection.require_resident_key is not None: 78 | json_selection["requireResidentKey"] = _selection.require_resident_key 79 | 80 | if _selection.user_verification is not None: 81 | json_selection["userVerification"] = _selection.user_verification.value 82 | 83 | reg_to_return["authenticatorSelection"] = json_selection 84 | 85 | if options.attestation is not None: 86 | reg_to_return["attestation"] = options.attestation.value 87 | 88 | if options.hints is not None: 89 | reg_to_return["hints"] = [hint.value for hint in options.hints] 90 | 91 | return reg_to_return 92 | 93 | if isinstance(options, PublicKeyCredentialRequestOptions): 94 | auth_to_return: Dict[str, Any] = {"challenge": bytes_to_base64url(options.challenge)} 95 | 96 | if options.timeout is not None: 97 | auth_to_return["timeout"] = options.timeout 98 | 99 | if options.rp_id is not None: 100 | auth_to_return["rpId"] = options.rp_id 101 | 102 | if options.allow_credentials is not None: 103 | _allowed = options.allow_credentials 104 | json_allowed = [] 105 | 106 | for cred in _allowed: 107 | json_allowed_cred: Dict[str, Any] = { 108 | "id": bytes_to_base64url(cred.id), 109 | "type": cred.type.value, 110 | } 111 | 112 | if cred.transports: 113 | json_allowed_cred["transports"] = [ 114 | transport.value for transport in cred.transports 115 | ] 116 | 117 | json_allowed.append(json_allowed_cred) 118 | 119 | auth_to_return["allowCredentials"] = json_allowed 120 | 121 | if options.user_verification: 122 | auth_to_return["userVerification"] = options.user_verification.value 123 | 124 | return auth_to_return 125 | 126 | raise TypeError( 127 | "Options was not instance of PublicKeyCredentialCreationOptions or PublicKeyCredentialRequestOptions" 128 | ) 129 | -------------------------------------------------------------------------------- /webauthn/helpers/parse_attestation_object.py: -------------------------------------------------------------------------------- 1 | from .parse_attestation_statement import parse_attestation_statement 2 | from .parse_authenticator_data import parse_authenticator_data 3 | from .structs import AttestationObject 4 | from .parse_cbor import parse_cbor 5 | 6 | 7 | def parse_attestation_object(val: bytes) -> AttestationObject: 8 | """ 9 | Decode and peel apart the CBOR-encoded blob `response.attestationObject` into 10 | structured data. 11 | """ 12 | attestation_dict = parse_cbor(val) 13 | 14 | decoded_attestation_object = AttestationObject( 15 | fmt=attestation_dict["fmt"], 16 | auth_data=parse_authenticator_data(attestation_dict["authData"]), 17 | ) 18 | 19 | if "attStmt" in attestation_dict: 20 | decoded_attestation_object.att_stmt = parse_attestation_statement( 21 | attestation_dict["attStmt"] 22 | ) 23 | 24 | return decoded_attestation_object 25 | -------------------------------------------------------------------------------- /webauthn/helpers/parse_attestation_statement.py: -------------------------------------------------------------------------------- 1 | from .structs import AttestationStatement 2 | 3 | 4 | def parse_attestation_statement(val: dict) -> AttestationStatement: 5 | """ 6 | Turn `response.attestationObject.attStmt` into structured data 7 | """ 8 | attestation_statement = AttestationStatement() 9 | 10 | # Populate optional fields that may exist in the attestation statement 11 | if "sig" in val: 12 | attestation_statement.sig = val["sig"] 13 | if "x5c" in val: 14 | attestation_statement.x5c = val["x5c"] 15 | if "response" in val: 16 | attestation_statement.response = val["response"] 17 | if "alg" in val: 18 | attestation_statement.alg = val["alg"] 19 | if "ver" in val: 20 | attestation_statement.ver = val["ver"] 21 | if "certInfo" in val: 22 | attestation_statement.cert_info = val["certInfo"] 23 | if "pubArea" in val: 24 | attestation_statement.pub_area = val["pubArea"] 25 | 26 | return attestation_statement 27 | -------------------------------------------------------------------------------- /webauthn/helpers/parse_authentication_credential_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from json.decoder import JSONDecodeError 3 | from typing import Union 4 | 5 | from .exceptions import InvalidAuthenticationResponse, InvalidJSONStructure 6 | from .base64url_to_bytes import base64url_to_bytes 7 | from .structs import ( 8 | AuthenticationCredential, 9 | AuthenticatorAssertionResponse, 10 | AuthenticatorAttachment, 11 | PublicKeyCredentialType, 12 | ) 13 | 14 | 15 | def parse_authentication_credential_json(json_val: Union[str, dict]) -> AuthenticationCredential: 16 | """ 17 | Parse a JSON form of an authentication credential, as either a stringified JSON object or a 18 | plain dict, into an instance of AuthenticationCredential 19 | """ 20 | if isinstance(json_val, str): 21 | try: 22 | json_val = json.loads(json_val) 23 | except JSONDecodeError: 24 | raise InvalidJSONStructure("Unable to decode credential as JSON") 25 | 26 | if not isinstance(json_val, dict): 27 | raise InvalidJSONStructure("Credential was not a JSON object") 28 | 29 | cred_id = json_val.get("id") 30 | if not isinstance(cred_id, str): 31 | raise InvalidJSONStructure("Credential missing required id") 32 | 33 | cred_raw_id = json_val.get("rawId") 34 | if not isinstance(cred_raw_id, str): 35 | raise InvalidJSONStructure("Credential missing required rawId") 36 | 37 | cred_response = json_val.get("response") 38 | if not isinstance(cred_response, dict): 39 | raise InvalidJSONStructure("Credential missing required response") 40 | 41 | response_client_data_json = cred_response.get("clientDataJSON") 42 | if not isinstance(response_client_data_json, str): 43 | raise InvalidJSONStructure("Credential response missing required clientDataJSON") 44 | 45 | response_authenticator_data = cred_response.get("authenticatorData") 46 | if not isinstance(response_authenticator_data, str): 47 | raise InvalidJSONStructure("Credential response missing required authenticatorData") 48 | 49 | response_signature = cred_response.get("signature") 50 | if not isinstance(response_signature, str): 51 | raise InvalidJSONStructure("Credential response missing required signature") 52 | 53 | cred_type = json_val.get("type") 54 | try: 55 | # Simply try to get the single matching Enum. We'll set the literal value below assuming 56 | # the code can get past here (this is basically a mypy optimization) 57 | PublicKeyCredentialType(cred_type) 58 | except ValueError as cred_type_exc: 59 | raise InvalidJSONStructure("Credential had unexpected type") from cred_type_exc 60 | 61 | response_user_handle = cred_response.get("userHandle") 62 | if isinstance(response_user_handle, str): 63 | # The `userHandle` string will most likely be base64url-encoded for ease of JSON 64 | # transmission as per the L3 Draft spec: 65 | # https://w3c.github.io/webauthn/#dictdef-authenticatorassertionresponsejson 66 | response_user_handle = base64url_to_bytes(response_user_handle) 67 | elif response_user_handle is not None: 68 | # If it's not a string, and it's not None, then it's definitely not valid 69 | raise InvalidJSONStructure("Credential response had unexpected userHandle") 70 | 71 | cred_authenticator_attachment = json_val.get("authenticatorAttachment") 72 | if isinstance(cred_authenticator_attachment, str): 73 | try: 74 | cred_authenticator_attachment = AuthenticatorAttachment(cred_authenticator_attachment) 75 | except ValueError as cred_attachment_exc: 76 | raise InvalidJSONStructure( 77 | "Credential had unexpected authenticatorAttachment" 78 | ) from cred_attachment_exc 79 | else: 80 | cred_authenticator_attachment = None 81 | 82 | try: 83 | authentication_credential = AuthenticationCredential( 84 | id=cred_id, 85 | raw_id=base64url_to_bytes(cred_raw_id), 86 | response=AuthenticatorAssertionResponse( 87 | client_data_json=base64url_to_bytes(response_client_data_json), 88 | authenticator_data=base64url_to_bytes(response_authenticator_data), 89 | signature=base64url_to_bytes(response_signature), 90 | user_handle=response_user_handle, 91 | ), 92 | authenticator_attachment=cred_authenticator_attachment, 93 | type=PublicKeyCredentialType.PUBLIC_KEY, 94 | ) 95 | except Exception as exc: 96 | raise InvalidAuthenticationResponse( 97 | "Could not parse authentication credential from JSON data" 98 | ) from exc 99 | 100 | return authentication_credential 101 | -------------------------------------------------------------------------------- /webauthn/helpers/parse_authentication_options_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from json import JSONDecodeError 3 | from typing import List, Optional, Union 4 | 5 | from .base64url_to_bytes import base64url_to_bytes 6 | from .exceptions import InvalidJSONStructure, InvalidAuthenticationOptions 7 | from .structs import ( 8 | AuthenticatorTransport, 9 | PublicKeyCredentialDescriptor, 10 | PublicKeyCredentialRequestOptions, 11 | UserVerificationRequirement, 12 | ) 13 | 14 | 15 | def parse_authentication_options_json( 16 | json_val: Union[str, dict] 17 | ) -> PublicKeyCredentialRequestOptions: 18 | """ 19 | Parse a JSON form of authentication options, as either stringified JSON or a plain dict, into an 20 | instance of `PublicKeyCredentialRequestOptions`. Typically useful in mapping output from 21 | `generate_authentication_options()`, that's been persisted as JSON via Redis/etc... back into 22 | structured data. 23 | """ 24 | if isinstance(json_val, str): 25 | try: 26 | json_val = json.loads(json_val) 27 | except JSONDecodeError: 28 | raise InvalidJSONStructure("Unable to decode options as JSON") 29 | 30 | if not isinstance(json_val, dict): 31 | raise InvalidJSONStructure("Options were not a JSON object") 32 | 33 | """ 34 | Check challenge 35 | """ 36 | options_challenge = json_val.get("challenge") 37 | if not isinstance(options_challenge, str): 38 | raise InvalidJSONStructure("Options missing required challenge") 39 | 40 | """ 41 | Check timeout 42 | """ 43 | options_timeout = json_val.get("timeout") 44 | mapped_timeout = None 45 | if isinstance(options_timeout, int): 46 | mapped_timeout = options_timeout 47 | 48 | """ 49 | Check rpId 50 | """ 51 | options_rp_id = json_val.get("rpId") 52 | mapped_rp_id = None 53 | if isinstance(options_rp_id, str): 54 | mapped_rp_id = options_rp_id 55 | 56 | """ 57 | Check userVerification 58 | """ 59 | options_user_verification = json_val.get("userVerification") 60 | if not isinstance(options_user_verification, str): 61 | raise InvalidJSONStructure("Options missing required userVerification") 62 | 63 | try: 64 | mapped_user_verification = UserVerificationRequirement(options_user_verification) 65 | except ValueError as exc: 66 | raise InvalidJSONStructure("Options userVerification was invalid value") from exc 67 | 68 | """ 69 | Check allowCredentials 70 | """ 71 | options_allow_credentials = json_val.get("allowCredentials") 72 | mapped_allow_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None 73 | if isinstance(options_allow_credentials, list): 74 | mapped_allow_credentials = [] 75 | for cred in options_allow_credentials: 76 | _cred_id = cred.get("id") 77 | if not isinstance(_cred_id, str): 78 | raise InvalidJSONStructure("Options excludeCredentials entry missing required id") 79 | 80 | _mapped = PublicKeyCredentialDescriptor(id=base64url_to_bytes(_cred_id)) 81 | 82 | _transports = cred.get("transports") 83 | if _transports is not None: 84 | if not isinstance(_transports, list): 85 | raise InvalidJSONStructure( 86 | "Options excludeCredentials entry transports was not list" 87 | ) 88 | try: 89 | _mapped.transports = [ 90 | AuthenticatorTransport(_transport) for _transport in _transports 91 | ] 92 | except ValueError as exc: 93 | raise InvalidJSONStructure( 94 | "Options excludeCredentials entry transports had invalid value" 95 | ) from exc 96 | 97 | mapped_allow_credentials.append(_mapped) 98 | 99 | try: 100 | authentication_options = PublicKeyCredentialRequestOptions( 101 | challenge=base64url_to_bytes(options_challenge), 102 | timeout=mapped_timeout, 103 | rp_id=mapped_rp_id, 104 | user_verification=mapped_user_verification, 105 | allow_credentials=mapped_allow_credentials, 106 | ) 107 | except Exception as exc: 108 | raise InvalidAuthenticationOptions( 109 | "Could not parse authentication options from JSON data" 110 | ) from exc 111 | 112 | return authentication_options 113 | -------------------------------------------------------------------------------- /webauthn/helpers/parse_authenticator_data.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from .byteslike_to_bytes import byteslike_to_bytes 4 | from .exceptions import InvalidAuthenticatorDataStructure 5 | from .structs import AttestedCredentialData, AuthenticatorData, AuthenticatorDataFlags 6 | from .parse_cbor import parse_cbor 7 | from .encode_cbor import encode_cbor 8 | 9 | 10 | def parse_authenticator_data(val: bytes) -> AuthenticatorData: 11 | """ 12 | Turn `response.attestationObject.authData` into structured data 13 | """ 14 | val = byteslike_to_bytes(val) 15 | 16 | # Don't bother parsing if there aren't enough bytes for at least: 17 | # - rpIdHash (32 bytes) 18 | # - flags (1 byte) 19 | # - signCount (4 bytes) 20 | if len(val) < 37: 21 | raise InvalidAuthenticatorDataStructure( 22 | f"Authenticator data was {len(val)} bytes, expected at least 37 bytes" 23 | ) 24 | 25 | pointer = 0 26 | 27 | rp_id_hash = val[pointer:32] 28 | pointer += 32 29 | 30 | # Cast byte to ordinal so we can use bitwise operators on it 31 | flags_bytes = ord(val[pointer : pointer + 1]) 32 | pointer += 1 33 | 34 | sign_count = val[pointer : pointer + 4] 35 | pointer += 4 36 | 37 | # Parse flags 38 | flags = AuthenticatorDataFlags( 39 | up=flags_bytes & (1 << 0) != 0, 40 | uv=flags_bytes & (1 << 2) != 0, 41 | be=flags_bytes & (1 << 3) != 0, 42 | bs=flags_bytes & (1 << 4) != 0, 43 | at=flags_bytes & (1 << 6) != 0, 44 | ed=flags_bytes & (1 << 7) != 0, 45 | ) 46 | 47 | # The value to return 48 | authenticator_data = AuthenticatorData( 49 | rp_id_hash=rp_id_hash, 50 | flags=flags, 51 | sign_count=int.from_bytes(sign_count, "big"), 52 | ) 53 | 54 | # Parse AttestedCredentialData if present 55 | if flags.at is True: 56 | aaguid = val[pointer : pointer + 16] 57 | pointer += 16 58 | 59 | credential_id_len = int.from_bytes(val[pointer : pointer + 2], "big") 60 | pointer += 2 61 | 62 | credential_id = val[pointer : pointer + credential_id_len] 63 | pointer += credential_id_len 64 | 65 | """ 66 | Some authenticators incorrectly compose authData when using EdDSA for their public keys. 67 | A CBOR "Map of 3 items" (0xA3) should be "Map of 4 items" (0xA4), and if we manually adjust 68 | the single byte there's a good chance the authData can be correctly parsed. Let's try to 69 | detect when this happens and gracefully handle it. 70 | """ 71 | # Decodes to `{1: "OKP", 3: -8, -1: "Ed25519"}` (it's missing key -2 a.k.a. COSEKey.X) 72 | bad_eddsa_cbor = bytearray.fromhex("a301634f4b500327206745643235353139") 73 | # If we find the bytes here then let's fix the bad data 74 | if val[pointer : pointer + len(bad_eddsa_cbor)] == bad_eddsa_cbor: 75 | # Make a mutable copy of the bytes... 76 | _val = bytearray(val) 77 | # ...Fix the bad byte... 78 | _val[pointer] = 0xA4 79 | # ...Then replace `val` with the fixed bytes 80 | val = bytes(_val) 81 | 82 | # Load the next CBOR-encoded value 83 | credential_public_key = parse_cbor(val[pointer:]) 84 | credential_public_key_bytes = encode_cbor(credential_public_key) 85 | pointer += len(credential_public_key_bytes) 86 | 87 | attested_cred_data = AttestedCredentialData( 88 | aaguid=aaguid, 89 | credential_id=credential_id, 90 | credential_public_key=credential_public_key_bytes, 91 | ) 92 | authenticator_data.attested_credential_data = attested_cred_data 93 | 94 | if flags.ed is True: 95 | extension_object = parse_cbor(val[pointer:]) 96 | extension_bytes = encode_cbor(extension_object) 97 | pointer += len(extension_bytes) 98 | authenticator_data.extensions = extension_bytes 99 | 100 | # We should have parsed all authenticator data by this point 101 | if len(val) > pointer: 102 | raise InvalidAuthenticatorDataStructure( 103 | "Leftover bytes detected while parsing authenticator data" 104 | ) 105 | 106 | return authenticator_data 107 | -------------------------------------------------------------------------------- /webauthn/helpers/parse_backup_flags.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from dataclasses import dataclass 3 | 4 | from .structs import AuthenticatorDataFlags, CredentialDeviceType 5 | from .exceptions import InvalidBackupFlags 6 | 7 | 8 | @dataclass 9 | class ParsedBackupFlags: 10 | credential_device_type: CredentialDeviceType 11 | credential_backed_up: bool 12 | 13 | 14 | def parse_backup_flags(flags: AuthenticatorDataFlags) -> ParsedBackupFlags: 15 | """Convert backup eligibility and backup state flags into more useful representations 16 | 17 | Raises: 18 | `helpers.exceptions.InvalidBackupFlags` if an invalid backup state is detected 19 | """ 20 | credential_device_type = CredentialDeviceType.SINGLE_DEVICE 21 | 22 | # A credential that can be backed up can typically be used on multiple devices 23 | if flags.be: 24 | credential_device_type = CredentialDeviceType.MULTI_DEVICE 25 | 26 | if credential_device_type == CredentialDeviceType.SINGLE_DEVICE and flags.bs: 27 | raise InvalidBackupFlags( 28 | "Single-device credential indicated that it was backed up, which should be impossible." 29 | ) 30 | 31 | return ParsedBackupFlags( 32 | credential_device_type=credential_device_type, 33 | credential_backed_up=flags.bs, 34 | ) 35 | -------------------------------------------------------------------------------- /webauthn/helpers/parse_cbor.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import cbor2 4 | 5 | from .exceptions import InvalidCBORData 6 | 7 | 8 | def parse_cbor(data: bytes) -> Any: 9 | """ 10 | Attempt to decode CBOR-encoded data. 11 | 12 | Raises: 13 | `helpers.exceptions.InvalidCBORData` if data cannot be decoded 14 | """ 15 | try: 16 | to_return = cbor2.loads(data) 17 | except Exception as exc: 18 | raise InvalidCBORData("Could not decode CBOR data") from exc 19 | 20 | return to_return 21 | -------------------------------------------------------------------------------- /webauthn/helpers/parse_client_data_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from json.decoder import JSONDecodeError 3 | from typing import Union 4 | 5 | from .base64url_to_bytes import base64url_to_bytes 6 | from .byteslike_to_bytes import byteslike_to_bytes 7 | from .exceptions import InvalidJSONStructure 8 | from .structs import CollectedClientData, TokenBinding 9 | 10 | 11 | def parse_client_data_json(val: bytes) -> CollectedClientData: 12 | """ 13 | Break apart `response.clientDataJSON` buffer into structured data 14 | """ 15 | val = byteslike_to_bytes(val) 16 | 17 | try: 18 | json_dict = json.loads(val) 19 | except JSONDecodeError: 20 | raise InvalidJSONStructure("Unable to decode client_data_json bytes as JSON") 21 | 22 | # Ensure required values are present in client data 23 | if "type" not in json_dict: 24 | raise InvalidJSONStructure('client_data_json missing required property "type"') 25 | if "challenge" not in json_dict: 26 | raise InvalidJSONStructure('client_data_json missing required property "challenge"') 27 | if "origin" not in json_dict: 28 | raise InvalidJSONStructure('client_data_json missing required property "origin"') 29 | 30 | client_data = CollectedClientData( 31 | type=json_dict["type"], 32 | challenge=base64url_to_bytes(json_dict["challenge"]), 33 | origin=json_dict["origin"], 34 | ) 35 | 36 | # Populate optional values if set 37 | if "crossOrigin" in json_dict: 38 | cross_origin = bool(json_dict["crossOrigin"]) 39 | client_data.cross_origin = cross_origin 40 | 41 | if "tokenBinding" in json_dict: 42 | token_binding_dict = json_dict["tokenBinding"] 43 | 44 | # Some U2F devices set a string to `token_binding`, in which case ignore it 45 | if type(token_binding_dict) is dict: 46 | if "status" not in token_binding_dict: 47 | raise InvalidJSONStructure('token_binding missing required property "status"') 48 | 49 | status = token_binding_dict["status"] 50 | try: 51 | # This will raise ValidationError on an unexpected status 52 | token_binding = TokenBinding(status=status) 53 | 54 | # Handle optional values 55 | if "id" in token_binding_dict: 56 | id = token_binding_dict["id"] 57 | token_binding.id = f"{id}" 58 | 59 | client_data.token_binding = token_binding 60 | except Exception: 61 | # If we encounter a status we don't expect then ignore token_binding 62 | # completely 63 | pass 64 | 65 | return client_data 66 | -------------------------------------------------------------------------------- /webauthn/helpers/parse_registration_credential_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from json.decoder import JSONDecodeError 3 | from typing import Union, Optional, List 4 | 5 | from .base64url_to_bytes import base64url_to_bytes 6 | from .exceptions import InvalidRegistrationResponse, InvalidJSONStructure 7 | from .structs import ( 8 | AuthenticatorAttachment, 9 | AuthenticatorAttestationResponse, 10 | AuthenticatorTransport, 11 | PublicKeyCredentialType, 12 | RegistrationCredential, 13 | ) 14 | 15 | 16 | def parse_registration_credential_json(json_val: Union[str, dict]) -> RegistrationCredential: 17 | """ 18 | Parse a JSON form of a registration credential, as either a stringified JSON object or a 19 | plain dict, into an instance of RegistrationCredential 20 | """ 21 | if isinstance(json_val, str): 22 | try: 23 | json_val = json.loads(json_val) 24 | except JSONDecodeError: 25 | raise InvalidJSONStructure("Unable to decode credential as JSON") 26 | 27 | if not isinstance(json_val, dict): 28 | raise InvalidJSONStructure("Credential was not a JSON object") 29 | 30 | cred_id = json_val.get("id") 31 | if not isinstance(cred_id, str): 32 | raise InvalidJSONStructure("Credential missing required id") 33 | 34 | cred_raw_id = json_val.get("rawId") 35 | if not isinstance(cred_raw_id, str): 36 | raise InvalidJSONStructure("Credential missing required rawId") 37 | 38 | cred_response = json_val.get("response") 39 | if not isinstance(cred_response, dict): 40 | raise InvalidJSONStructure("Credential missing required response") 41 | 42 | response_client_data_json = cred_response.get("clientDataJSON") 43 | if not isinstance(response_client_data_json, str): 44 | raise InvalidJSONStructure("Credential response missing required clientDataJSON") 45 | 46 | response_attestation_object = cred_response.get("attestationObject") 47 | if not isinstance(response_attestation_object, str): 48 | raise InvalidJSONStructure("Credential response missing required attestationObject") 49 | 50 | cred_type = json_val.get("type") 51 | try: 52 | # Simply try to get the single matching Enum. We'll set the literal value below assuming 53 | # the code can get past here (this is basically a mypy optimization) 54 | PublicKeyCredentialType(cred_type) 55 | except ValueError as cred_type_exc: 56 | raise InvalidJSONStructure("Credential had unexpected type") from cred_type_exc 57 | 58 | transports: Optional[List[AuthenticatorTransport]] = None 59 | response_transports = cred_response.get("transports") 60 | if isinstance(response_transports, list): 61 | transports = [] 62 | for val in response_transports: 63 | try: 64 | transport_enum = AuthenticatorTransport(val) 65 | transports.append(transport_enum) 66 | except ValueError: 67 | pass 68 | 69 | cred_authenticator_attachment = json_val.get("authenticatorAttachment") 70 | if isinstance(cred_authenticator_attachment, str): 71 | try: 72 | cred_authenticator_attachment = AuthenticatorAttachment(cred_authenticator_attachment) 73 | except ValueError as cred_attachment_exc: 74 | raise InvalidJSONStructure( 75 | "Credential had unexpected authenticatorAttachment" 76 | ) from cred_attachment_exc 77 | else: 78 | cred_authenticator_attachment = None 79 | 80 | try: 81 | registration_credential = RegistrationCredential( 82 | id=cred_id, 83 | raw_id=base64url_to_bytes(cred_raw_id), 84 | response=AuthenticatorAttestationResponse( 85 | client_data_json=base64url_to_bytes(response_client_data_json), 86 | attestation_object=base64url_to_bytes(response_attestation_object), 87 | transports=transports, 88 | ), 89 | authenticator_attachment=cred_authenticator_attachment, 90 | type=PublicKeyCredentialType.PUBLIC_KEY, 91 | ) 92 | except Exception as exc: 93 | raise InvalidRegistrationResponse( 94 | "Could not parse registration credential from JSON data" 95 | ) from exc 96 | 97 | return registration_credential 98 | -------------------------------------------------------------------------------- /webauthn/helpers/pem_cert_bytes_to_open_ssl_x509.py: -------------------------------------------------------------------------------- 1 | from cryptography.x509 import load_pem_x509_certificate 2 | from OpenSSL.crypto import X509 3 | 4 | 5 | def pem_cert_bytes_to_open_ssl_x509(cert: bytes) -> X509: 6 | """Convert PEM-formatted certificate bytes into an X509 instance usable for cert 7 | chain validation 8 | """ 9 | cert_crypto = load_pem_x509_certificate(cert) 10 | cert_openssl = X509().from_cryptography(cert_crypto) 11 | return cert_openssl 12 | -------------------------------------------------------------------------------- /webauthn/helpers/snake_case_to_camel_case.py: -------------------------------------------------------------------------------- 1 | def snake_case_to_camel_case(snake_case: str) -> str: 2 | """ 3 | Helper method for converting a snake_case'd value to camelCase 4 | 5 | input: pub_key_cred_params 6 | output: pubKeyCredParams 7 | """ 8 | parts = snake_case.split("_") 9 | converted = parts[0].lower() + "".join(part.title() for part in parts[1:]) 10 | 11 | # Massage "clientDataJson" to "clientDataJSON" 12 | converted = converted.replace("Json", "JSON") 13 | 14 | return converted 15 | -------------------------------------------------------------------------------- /webauthn/helpers/tpm/__init__.py: -------------------------------------------------------------------------------- 1 | from .map_tpm_manufacturer import map_tpm_manufacturer_id 2 | from .parse_cert_info import parse_cert_info 3 | from .parse_pub_area import parse_pub_area 4 | 5 | __all__ = ["map_tpm_manufacturer_id", "parse_cert_info", "parse_pub_area"] 6 | -------------------------------------------------------------------------------- /webauthn/helpers/tpm/map_tpm_manufacturer.py: -------------------------------------------------------------------------------- 1 | from .structs import TPM_MANUFACTURERS, TPMManufacturerInfo 2 | 3 | 4 | def map_tpm_manufacturer_id(id: str) -> TPMManufacturerInfo: 5 | """ 6 | Map a TPM manufacturer's hex ID to a manufacturer's assigned name and ASCII identifier 7 | 8 | Args: 9 | - `id`: A TPM manufacturer ID string like `"id:FFFFFFFF"` 10 | (a.k.a. oid "2.23.133.2.1" in SubjectAlternativeName extension) 11 | 12 | Returns: 13 | An instance of `TPMManufacturerInfo` 14 | 15 | Raises: 16 | `KeyError` on unrecognized TPM manufacturer ID 17 | """ 18 | return TPM_MANUFACTURERS[id] 19 | -------------------------------------------------------------------------------- /webauthn/helpers/tpm/parse_cert_info.py: -------------------------------------------------------------------------------- 1 | from ..exceptions import InvalidTPMCertInfoStructure 2 | from .structs import ( 3 | TPM_ST, 4 | TPM_ST_MAP, 5 | TPMCertInfo, 6 | TPMCertInfoAttested, 7 | TPMCertInfoClockInfo, 8 | ) 9 | 10 | 11 | def parse_cert_info(val: bytes) -> TPMCertInfo: 12 | """ 13 | Turn `response.attestationObject.attStmt.certInfo` into structured data 14 | """ 15 | pointer = 0 16 | 17 | # The constant "TPM_GENERATED_VALUE" indicating a structure generated by TPM 18 | magic_bytes = val[pointer : pointer + 4] 19 | pointer += 4 20 | 21 | # Type of the cert info structure 22 | type_bytes = val[pointer : pointer + 2] 23 | pointer += 2 24 | mapped_type = TPM_ST_MAP[type_bytes] 25 | 26 | # Name of parent entity 27 | qualified_signer_length = int.from_bytes(val[pointer : pointer + 2], "big") 28 | pointer += 2 29 | qualified_signer = val[pointer : pointer + qualified_signer_length] 30 | pointer += qualified_signer_length 31 | 32 | # Expected hash value of `attsToBeSigned` 33 | extra_data_length = int.from_bytes(val[pointer : pointer + 2], "big") 34 | pointer += 2 35 | extra_data_bytes = val[pointer : pointer + extra_data_length] 36 | pointer += extra_data_length 37 | 38 | # Info about the TPM's internal clock 39 | clock_info_bytes = val[pointer : pointer + 17] 40 | pointer += 17 41 | 42 | # Device firmware version 43 | firmware_version_bytes = val[pointer : pointer + 8] 44 | pointer += 8 45 | 46 | # Verify that type is set to TPM_ST_ATTEST_CERTIFY. 47 | if mapped_type != TPM_ST.ATTEST_CERTIFY: 48 | raise InvalidTPMCertInfoStructure( 49 | f'Cert Info type "{mapped_type}" was not "{TPM_ST.ATTEST_CERTIFY}"' 50 | ) 51 | 52 | # Attested name 53 | attested_name_length = int.from_bytes(val[pointer : pointer + 2], "big") 54 | pointer += 2 55 | attested_name_bytes = val[pointer : pointer + attested_name_length] 56 | pointer += attested_name_length 57 | qualified_name_length = int.from_bytes(val[pointer : pointer + 2], "big") 58 | pointer += 2 59 | qualified_name_bytes = val[pointer : pointer + qualified_name_length] 60 | pointer += qualified_name_length 61 | 62 | return TPMCertInfo( 63 | magic=magic_bytes, 64 | type=mapped_type, 65 | extra_data=extra_data_bytes, 66 | attested=TPMCertInfoAttested(attested_name_bytes, qualified_name_bytes), 67 | # Note that the remaining fields in the "Standard Attestation Structure" 68 | # [TPMv2-Part1] section 31.2, i.e., qualifiedSigner, clockInfo and 69 | # firmwareVersion are ignored. These fields MAY be used as an input to risk 70 | # engines. 71 | qualified_signer=qualified_signer, 72 | clock_info=TPMCertInfoClockInfo(clock_info_bytes), 73 | firmware_version=firmware_version_bytes, 74 | ) 75 | -------------------------------------------------------------------------------- /webauthn/helpers/tpm/parse_pub_area.py: -------------------------------------------------------------------------------- 1 | from ..exceptions import InvalidTPMPubAreaStructure 2 | from .structs import ( 3 | TPM_ALG, 4 | TPM_ALG_MAP, 5 | TPMPubArea, 6 | TPMPubAreaObjectAttributes, 7 | TPMPubAreaParametersECC, 8 | TPMPubAreaParametersRSA, 9 | TPMPubAreaUnique, 10 | ) 11 | 12 | 13 | def parse_pub_area(val: bytes) -> TPMPubArea: 14 | """ 15 | Turn `response.attestationObject.attStmt.pubArea` into structured data 16 | """ 17 | pointer = 0 18 | 19 | type_bytes = val[pointer : pointer + 2] 20 | pointer += 2 21 | mapped_type = TPM_ALG_MAP[type_bytes] 22 | 23 | name_alg_bytes = val[pointer : pointer + 2] 24 | pointer += 2 25 | mapped_name_alg = TPM_ALG_MAP[name_alg_bytes] 26 | 27 | object_attributes_bytes = val[pointer : pointer + 4] 28 | pointer += 4 29 | # Parse attributes from right to left by zero-index bit position 30 | object_attributes = TPMPubAreaObjectAttributes(object_attributes_bytes) 31 | 32 | auth_policy_length = int.from_bytes(val[pointer : pointer + 2], "big") 33 | pointer += 2 34 | auth_policy_bytes = val[pointer : pointer + auth_policy_length] 35 | pointer += auth_policy_length 36 | 37 | # Decode the rest of the bytes to public key parameters 38 | if mapped_type == TPM_ALG.RSA: 39 | rsa_bytes = val[pointer : pointer + 10] 40 | pointer += 10 41 | parameters = TPMPubAreaParametersRSA(rsa_bytes) 42 | elif mapped_type == TPM_ALG.ECC: 43 | ecc_bytes = val[pointer : pointer + 8] 44 | pointer += 8 45 | # mypy will error here because of the incompatible "reassignment", but 46 | # `parameters` in `TPMPubArea` is a Union of either type so ignore the error 47 | parameters = TPMPubAreaParametersECC(ecc_bytes) # type: ignore 48 | else: 49 | raise InvalidTPMPubAreaStructure(f'Type "{mapped_type}" is unsupported') 50 | 51 | unique_length_bytes = val[pointer:] 52 | 53 | return TPMPubArea( 54 | type=mapped_type, 55 | name_alg=mapped_name_alg, 56 | object_attributes=object_attributes, 57 | auth_policy=auth_policy_bytes, 58 | parameters=parameters, 59 | unique=TPMPubAreaUnique(unique_length_bytes, mapped_type), 60 | ) 61 | -------------------------------------------------------------------------------- /webauthn/helpers/validate_certificate_chain.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from cryptography.x509 import load_der_x509_certificate 4 | from OpenSSL.crypto import X509, X509Store, X509StoreContext, X509StoreContextError 5 | 6 | from .exceptions import InvalidCertificateChain 7 | from .pem_cert_bytes_to_open_ssl_x509 import pem_cert_bytes_to_open_ssl_x509 8 | 9 | 10 | def validate_certificate_chain( 11 | *, 12 | x5c: List[bytes], 13 | pem_root_certs_bytes: Optional[List[bytes]] = None, 14 | ) -> bool: 15 | """Validate that the certificates in x5c chain back to a known root certificate 16 | 17 | Args: 18 | `x5c`: X5C certificates from a registration response's attestation statement 19 | (optional) `pem_root_certs_bytes`: Any additional (PEM-formatted) 20 | root certificates that may complete the certificate chain 21 | 22 | Raises: 23 | `helpers.exceptions.InvalidCertificateChain` if chain cannot be validated 24 | """ 25 | if pem_root_certs_bytes is None or len(pem_root_certs_bytes) < 1: 26 | # We have no root certs to chain back to, so just pass on validation 27 | return True 28 | 29 | # Make sure we have at least one certificate to try and link back to a root cert 30 | if len(x5c) < 1: 31 | raise InvalidCertificateChain("x5c was empty") 32 | 33 | # Prepare leaf cert 34 | try: 35 | leaf_cert_bytes = x5c[0] 36 | leaf_cert_crypto = load_der_x509_certificate(leaf_cert_bytes) 37 | leaf_cert = X509().from_cryptography(leaf_cert_crypto) 38 | except Exception as exc: 39 | raise InvalidCertificateChain("Could not prepare leaf cert") from exc 40 | 41 | # Prepare any intermediate certs 42 | try: 43 | # May be an empty array, that's fine 44 | intermediate_certs_bytes = x5c[1:] 45 | intermediate_certs_crypto = [ 46 | load_der_x509_certificate(cert) for cert in intermediate_certs_bytes 47 | ] 48 | intermediate_certs = [X509().from_cryptography(cert) for cert in intermediate_certs_crypto] 49 | except Exception as exc: 50 | raise InvalidCertificateChain("Could not prepare intermediate certs") from exc 51 | 52 | # Prepare a collection of possible root certificates 53 | cert_store = _generate_new_cert_store() 54 | try: 55 | for cert in pem_root_certs_bytes: 56 | cert_store.add_cert(pem_cert_bytes_to_open_ssl_x509(cert)) 57 | except Exception as exc: 58 | raise InvalidCertificateChain("Could not prepare root certs") from exc 59 | 60 | # Load certs into a "context" for validation 61 | context = X509StoreContext( 62 | store=cert_store, 63 | certificate=leaf_cert, 64 | chain=intermediate_certs, 65 | ) 66 | 67 | # Validate the chain (will raise if it can't) 68 | try: 69 | context.verify_certificate() 70 | except X509StoreContextError as exc: 71 | raise InvalidCertificateChain("Certificate chain could not be validated") from exc 72 | 73 | return True 74 | 75 | 76 | def _generate_new_cert_store() -> X509Store: 77 | """ 78 | Something that can be patched during testing to return an X509Store instance with its time 79 | adjusted to some datetime in the past. This allows full certificate validity checks even after 80 | cert expiration in the real world. See here: 81 | 82 | https://www.pyopenssl.org/en/stable/api/crypto.html#OpenSSL.crypto.X509Store.set_time 83 | """ 84 | return X509Store() 85 | -------------------------------------------------------------------------------- /webauthn/helpers/verify_safetynet_timestamp.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def verify_safetynet_timestamp(timestamp_ms: int) -> None: 5 | """Handle time drift between an RP and the Google SafetyNet API servers with a window of 6 | time within which the response is valid 7 | """ 8 | # Buffer period in ms 9 | grace_ms = 10 * 1000 10 | # Get "now" in ms 11 | now = int(time.time()) * 1000 12 | 13 | # Make sure the response was generated in the past 14 | if timestamp_ms > (now + grace_ms): 15 | raise ValueError(f"Payload timestamp {timestamp_ms} was later than {now} + {grace_ms}") 16 | 17 | # Make sure the response arrived within the grace period 18 | if timestamp_ms < (now - grace_ms): 19 | raise ValueError("Payload has expired") 20 | -------------------------------------------------------------------------------- /webauthn/helpers/verify_signature.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from cryptography.hazmat.primitives.asymmetric.dsa import DSAPublicKey 4 | from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey 5 | from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PublicKey 6 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey 7 | from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey 8 | from cryptography.hazmat.primitives.asymmetric.x448 import X448PublicKey 9 | from cryptography.hazmat.primitives.asymmetric.padding import MGF1, PSS, PKCS1v15 10 | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey 11 | 12 | from .algorithms import ( 13 | get_ec2_sig_alg, 14 | get_rsa_pkcs1_sig_alg, 15 | get_rsa_pss_sig_alg, 16 | is_rsa_pkcs, 17 | is_rsa_pss, 18 | ) 19 | from .cose import COSEAlgorithmIdentifier 20 | from .exceptions import UnsupportedAlgorithm, UnsupportedPublicKey 21 | 22 | 23 | def verify_signature( 24 | *, 25 | public_key: Union[ 26 | EllipticCurvePublicKey, 27 | RSAPublicKey, 28 | Ed25519PublicKey, 29 | DSAPublicKey, 30 | Ed448PublicKey, 31 | X25519PublicKey, 32 | X448PublicKey, 33 | ], 34 | signature_alg: COSEAlgorithmIdentifier, 35 | signature: bytes, 36 | data: bytes, 37 | ) -> None: 38 | """Verify a signature was signed with the private key corresponding to the provided 39 | public key. 40 | 41 | Args: 42 | `public_key`: A public key loaded via cryptography's `load_der_public_key`, `load_der_x509_certificate`, etc... 43 | `signature_alg`: Algorithm ID used to sign the signature 44 | `signature`: Signature to verify 45 | `data`: Data signed by private key 46 | 47 | Raises: 48 | `webauthn.helpers.exceptions.UnsupportedAlgorithm` when the algorithm is not a recognized COSE algorithm ID 49 | `webauthn.helpers.exceptions.UnsupportedPublicKey` when the public key is not a valid EC2, RSA, or OKP certificate 50 | `cryptography.exceptions.InvalidSignature` when the signature cannot be verified 51 | """ 52 | if isinstance(public_key, EllipticCurvePublicKey): 53 | public_key.verify(signature, data, get_ec2_sig_alg(signature_alg)) 54 | elif isinstance(public_key, RSAPublicKey): 55 | if is_rsa_pkcs(signature_alg): 56 | public_key.verify(signature, data, PKCS1v15(), get_rsa_pkcs1_sig_alg(signature_alg)) 57 | elif is_rsa_pss(signature_alg): 58 | rsa_alg = get_rsa_pss_sig_alg(signature_alg) 59 | public_key.verify( 60 | signature, 61 | data, 62 | PSS(mgf=MGF1(rsa_alg), salt_length=PSS.MAX_LENGTH), 63 | rsa_alg, 64 | ) 65 | else: 66 | raise UnsupportedAlgorithm(f"Unrecognized RSA signature alg {signature_alg}") 67 | elif isinstance(public_key, Ed25519PublicKey): 68 | public_key.verify(signature, data) 69 | else: 70 | raise UnsupportedPublicKey( 71 | f"Unsupported public key for signature verification: {public_key}" 72 | ) 73 | -------------------------------------------------------------------------------- /webauthn/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duo-labs/py_webauthn/67cacb6038c7fbc18ba532e14c0f8d18015bf7b4/webauthn/py.typed -------------------------------------------------------------------------------- /webauthn/registration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duo-labs/py_webauthn/67cacb6038c7fbc18ba532e14c0f8d18015bf7b4/webauthn/registration/__init__.py -------------------------------------------------------------------------------- /webauthn/registration/formats/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duo-labs/py_webauthn/67cacb6038c7fbc18ba532e14c0f8d18015bf7b4/webauthn/registration/formats/__init__.py -------------------------------------------------------------------------------- /webauthn/registration/formats/android_safetynet.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from dataclasses import dataclass 3 | import hashlib 4 | import json 5 | from typing import List 6 | 7 | from cryptography import x509 8 | from cryptography.exceptions import InvalidSignature 9 | from cryptography.x509.oid import NameOID 10 | 11 | from webauthn.helpers.cose import COSEAlgorithmIdentifier 12 | from webauthn.helpers import ( 13 | base64url_to_bytes, 14 | parse_cbor, 15 | validate_certificate_chain, 16 | verify_safetynet_timestamp, 17 | verify_signature, 18 | ) 19 | from webauthn.helpers.exceptions import ( 20 | InvalidCertificateChain, 21 | InvalidRegistrationResponse, 22 | ) 23 | from webauthn.helpers.known_root_certs import globalsign_r2, globalsign_root_ca 24 | from webauthn.helpers.structs import AttestationStatement 25 | 26 | 27 | @dataclass 28 | class SafetyNetJWSHeader: 29 | """Properties in the Header of a SafetyNet JWS""" 30 | 31 | alg: str 32 | x5c: List[str] 33 | 34 | 35 | @dataclass 36 | class SafetyNetJWSPayload: 37 | """Properties in the Payload of a SafetyNet JWS 38 | 39 | Values below correspond to camelCased properties in the JWS itself. This class 40 | handles converting the properties to Pythonic snake_case. 41 | """ 42 | 43 | nonce: str 44 | timestamp_ms: int 45 | apk_package_name: str 46 | apk_digest_sha256: str 47 | cts_profile_match: bool 48 | apk_certificate_digest_sha256: List[str] 49 | basic_integrity: bool 50 | 51 | 52 | def verify_android_safetynet( 53 | *, 54 | attestation_statement: AttestationStatement, 55 | attestation_object: bytes, 56 | client_data_json: bytes, 57 | pem_root_certs_bytes: List[bytes], 58 | verify_timestamp_ms: bool = True, 59 | ) -> bool: 60 | """Verify an "android-safetynet" attestation statement 61 | 62 | See https://www.w3.org/TR/webauthn-2/#sctn-android-safetynet-attestation 63 | 64 | Notes: 65 | - `verify_timestamp_ms` is a kind of escape hatch specifically for enabling 66 | testing of this method. Without this we can't use static responses in unit 67 | tests because they'll always evaluate as expired. This flag can be removed 68 | from this method if we ever figure out how to dynamically create 69 | safetynet-formatted responses that can be immediately tested. 70 | """ 71 | 72 | if not attestation_statement.ver: 73 | # As of this writing, there is only one format of the SafetyNet response and 74 | # ver is reserved for future use (so for now just make sure it's present) 75 | raise InvalidRegistrationResponse("Attestation statement was missing version (SafetyNet)") 76 | 77 | if not attestation_statement.response: 78 | raise InvalidRegistrationResponse("Attestation statement was missing response (SafetyNet)") 79 | 80 | # Begin peeling apart the JWS in the attestation statement response 81 | jws = attestation_statement.response.decode("ascii") 82 | jws_parts = jws.split(".") 83 | 84 | if len(jws_parts) != 3: 85 | raise InvalidRegistrationResponse("Response JWS did not have three parts (SafetyNet)") 86 | 87 | header_json = json.loads(base64url_to_bytes(jws_parts[0])) 88 | payload_json = json.loads(base64url_to_bytes(jws_parts[1])) 89 | 90 | header = SafetyNetJWSHeader( 91 | alg=header_json.get("alg", ""), 92 | x5c=header_json.get("x5c", []), 93 | ) 94 | payload = SafetyNetJWSPayload( 95 | nonce=payload_json.get("nonce", ""), 96 | timestamp_ms=payload_json.get("timestampMs", 0), 97 | apk_package_name=payload_json.get("apkPackageName", ""), 98 | apk_digest_sha256=payload_json.get("apkDigestSha256", ""), 99 | cts_profile_match=payload_json.get("ctsProfileMatch", False), 100 | apk_certificate_digest_sha256=payload_json.get("apkCertificateDigestSha256", []), 101 | basic_integrity=payload_json.get("basicIntegrity", False), 102 | ) 103 | 104 | signature_bytes_str: str = jws_parts[2] 105 | 106 | # Verify that the nonce attribute in the payload of response is identical to the 107 | # Base64 encoding of the SHA-256 hash of the concatenation of authenticatorData and 108 | # clientDataHash. 109 | 110 | # Extract attStmt bytes from attestation_object 111 | attestation_dict = parse_cbor(attestation_object) 112 | authenticator_data_bytes = attestation_dict["authData"] 113 | 114 | # Generate a hash of client_data_json 115 | client_data_hash = hashlib.sha256() 116 | client_data_hash.update(client_data_json) 117 | client_data_hash_bytes = client_data_hash.digest() 118 | 119 | nonce_data = b"".join( 120 | [ 121 | authenticator_data_bytes, 122 | client_data_hash_bytes, 123 | ] 124 | ) 125 | # Start with a sha256 hash 126 | nonce_data_hash = hashlib.sha256() 127 | nonce_data_hash.update(nonce_data) 128 | nonce_data_hash_bytes = nonce_data_hash.digest() 129 | # Encode to base64 130 | nonce_data_hash_bytes = base64.b64encode(nonce_data_hash_bytes) 131 | # Finish by decoding to string 132 | nonce_data_str = nonce_data_hash_bytes.decode("utf-8") 133 | 134 | if payload.nonce != nonce_data_str: 135 | raise InvalidRegistrationResponse("Payload nonce was not expected value (SafetyNet)") 136 | 137 | # Verify that the SafetyNet response actually came from the SafetyNet service 138 | # by following the steps in the SafetyNet online documentation. 139 | x5c = [base64url_to_bytes(cert) for cert in header.x5c] 140 | 141 | if not payload.basic_integrity: 142 | raise InvalidRegistrationResponse("Could not verify device integrity (SafetyNet)") 143 | 144 | if verify_timestamp_ms: 145 | try: 146 | verify_safetynet_timestamp(payload.timestamp_ms) 147 | except ValueError as err: 148 | raise InvalidRegistrationResponse(f"{err} (SafetyNet)") 149 | 150 | # Verify that the leaf certificate was issued to the hostname attest.android.com 151 | attestation_cert = x509.load_der_x509_certificate(x5c[0]) 152 | cert_common_name = attestation_cert.subject.get_attributes_for_oid( 153 | NameOID.COMMON_NAME, 154 | )[0] 155 | 156 | if cert_common_name.value != "attest.android.com": 157 | raise InvalidRegistrationResponse( 158 | 'Certificate common name was not "attest.android.com" (SafetyNet)' 159 | ) 160 | 161 | # Validate certificate chain 162 | try: 163 | # Include known root certificates for this attestation format with whatever 164 | # other certs were provided 165 | pem_root_certs_bytes.append(globalsign_r2) 166 | pem_root_certs_bytes.append(globalsign_root_ca) 167 | 168 | validate_certificate_chain( 169 | x5c=x5c, 170 | pem_root_certs_bytes=pem_root_certs_bytes, 171 | ) 172 | except InvalidCertificateChain as err: 173 | raise InvalidRegistrationResponse(f"{err} (SafetyNet)") 174 | 175 | # Verify signature 176 | verification_data = f"{jws_parts[0]}.{jws_parts[1]}".encode("utf-8") 177 | signature_bytes = base64url_to_bytes(signature_bytes_str) 178 | 179 | if header.alg != "RS256": 180 | raise InvalidRegistrationResponse(f"JWS header alg was not RS256: {header.alg} (SafetyNet") 181 | 182 | # Get cert public key bytes 183 | attestation_cert_pub_key = attestation_cert.public_key() 184 | 185 | try: 186 | verify_signature( 187 | public_key=attestation_cert_pub_key, 188 | signature_alg=COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, 189 | signature=signature_bytes, 190 | data=verification_data, 191 | ) 192 | except InvalidSignature: 193 | raise InvalidRegistrationResponse( 194 | "Could not verify attestation statement signature (Packed)" 195 | ) 196 | 197 | return True 198 | -------------------------------------------------------------------------------- /webauthn/registration/formats/apple.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from typing import List 3 | 4 | from cryptography import x509 5 | from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat 6 | from cryptography.x509 import ( 7 | Extension, 8 | ExtensionNotFound, 9 | ObjectIdentifier, 10 | UnrecognizedExtension, 11 | ) 12 | 13 | from webauthn.helpers import ( 14 | decode_credential_public_key, 15 | decoded_public_key_to_cryptography, 16 | parse_cbor, 17 | validate_certificate_chain, 18 | ) 19 | from webauthn.helpers.exceptions import ( 20 | InvalidCertificateChain, 21 | InvalidRegistrationResponse, 22 | ) 23 | from webauthn.helpers.known_root_certs import apple_webauthn_root_ca 24 | from webauthn.helpers.structs import AttestationStatement 25 | 26 | 27 | def verify_apple( 28 | *, 29 | attestation_statement: AttestationStatement, 30 | attestation_object: bytes, 31 | client_data_json: bytes, 32 | credential_public_key: bytes, 33 | pem_root_certs_bytes: List[bytes], 34 | ) -> bool: 35 | """ 36 | https://www.w3.org/TR/webauthn-2/#sctn-apple-anonymous-attestation 37 | """ 38 | 39 | if not attestation_statement.x5c: 40 | raise InvalidRegistrationResponse("Attestation statement was missing x5c (Apple)") 41 | 42 | # Validate the certificate chain 43 | try: 44 | # Include known root certificates for this attestation format 45 | pem_root_certs_bytes.append(apple_webauthn_root_ca) 46 | 47 | validate_certificate_chain( 48 | x5c=attestation_statement.x5c, 49 | pem_root_certs_bytes=pem_root_certs_bytes, 50 | ) 51 | except InvalidCertificateChain as err: 52 | raise InvalidRegistrationResponse(f"{err} (Apple)") 53 | 54 | # Concatenate authenticatorData and clientDataHash to form nonceToHash. 55 | attestation_dict = parse_cbor(attestation_object) 56 | authenticator_data_bytes = attestation_dict["authData"] 57 | 58 | client_data_hash = hashlib.sha256() 59 | client_data_hash.update(client_data_json) 60 | client_data_hash_bytes = client_data_hash.digest() 61 | 62 | nonce_to_hash = b"".join( 63 | [ 64 | authenticator_data_bytes, 65 | client_data_hash_bytes, 66 | ] 67 | ) 68 | 69 | # Perform SHA-256 hash of nonceToHash to produce nonce. 70 | nonce = hashlib.sha256() 71 | nonce.update(nonce_to_hash) 72 | nonce_bytes = nonce.digest() 73 | 74 | # Verify that nonce equals the value of the extension with 75 | # OID 1.2.840.113635.100.8.2 in credCert. 76 | attestation_cert_bytes = attestation_statement.x5c[0] 77 | attestation_cert = x509.load_der_x509_certificate(attestation_cert_bytes) 78 | cert_extensions = attestation_cert.extensions 79 | 80 | # Still no documented name for this OID... 81 | ext_1_2_840_113635_100_8_2_oid = "1.2.840.113635.100.8.2" 82 | try: 83 | ext_1_2_840_113635_100_8_2: Extension = cert_extensions.get_extension_for_oid( 84 | ObjectIdentifier(ext_1_2_840_113635_100_8_2_oid) 85 | ) 86 | except ExtensionNotFound: 87 | raise InvalidRegistrationResponse( 88 | f"Certificate missing extension {ext_1_2_840_113635_100_8_2_oid} (Apple)" 89 | ) 90 | 91 | # Peel apart the Extension into an UnrecognizedExtension, then the bytes we actually 92 | # want 93 | ext_value_wrapper: UnrecognizedExtension = ext_1_2_840_113635_100_8_2.value 94 | # Ignore the first six ASN.1 structure bytes that define the nonce as an 95 | # OCTET STRING. Should trim off '0$\xa1"\x04' 96 | ext_value: bytes = ext_value_wrapper.value[6:] 97 | 98 | if ext_value != nonce_bytes: 99 | raise InvalidRegistrationResponse("Certificate nonce was not expected value (Apple)") 100 | 101 | # Verify that the credential public key equals the Subject Public Key of credCert. 102 | attestation_cert_pub_key = attestation_cert.public_key() 103 | attestation_cert_pub_key_bytes = attestation_cert_pub_key.public_bytes( 104 | Encoding.DER, 105 | PublicFormat.SubjectPublicKeyInfo, 106 | ) 107 | # Convert our raw public key bytes into the same format cryptography generates for 108 | # the cert subject key 109 | decoded_pub_key = decode_credential_public_key(credential_public_key) 110 | pub_key_crypto = decoded_public_key_to_cryptography(decoded_pub_key) 111 | pub_key_crypto_bytes = pub_key_crypto.public_bytes( 112 | Encoding.DER, 113 | PublicFormat.SubjectPublicKeyInfo, 114 | ) 115 | 116 | if attestation_cert_pub_key_bytes != pub_key_crypto_bytes: 117 | raise InvalidRegistrationResponse( 118 | "Certificate public key did not match credential public key (Apple)" 119 | ) 120 | 121 | return True 122 | -------------------------------------------------------------------------------- /webauthn/registration/formats/fido_u2f.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from typing import List 3 | 4 | from cryptography import x509 5 | from cryptography.exceptions import InvalidSignature 6 | from cryptography.hazmat.primitives.asymmetric.ec import ( 7 | SECP256R1, 8 | EllipticCurvePublicKey, 9 | ) 10 | 11 | from webauthn.helpers import ( 12 | aaguid_to_string, 13 | validate_certificate_chain, 14 | verify_signature, 15 | ) 16 | from webauthn.helpers.cose import COSEAlgorithmIdentifier 17 | from webauthn.helpers.decode_credential_public_key import ( 18 | DecodedEC2PublicKey, 19 | decode_credential_public_key, 20 | ) 21 | from webauthn.helpers.exceptions import ( 22 | InvalidCertificateChain, 23 | InvalidRegistrationResponse, 24 | ) 25 | from webauthn.helpers.structs import AttestationStatement 26 | 27 | 28 | def verify_fido_u2f( 29 | *, 30 | attestation_statement: AttestationStatement, 31 | client_data_json: bytes, 32 | rp_id_hash: bytes, 33 | credential_id: bytes, 34 | credential_public_key: bytes, 35 | aaguid: bytes, 36 | pem_root_certs_bytes: List[bytes], 37 | ) -> bool: 38 | """Verify a "fido-u2f" attestation statement 39 | 40 | See https://www.w3.org/TR/webauthn-2/#sctn-fido-u2f-attestation 41 | """ 42 | if not attestation_statement.sig: 43 | raise InvalidRegistrationResponse("Attestation statement was missing signature (FIDO-U2F)") 44 | 45 | if not attestation_statement.x5c: 46 | raise InvalidRegistrationResponse( 47 | "Attestation statement was missing certificate (FIDO-U2F)" 48 | ) 49 | 50 | if len(attestation_statement.x5c) > 1: 51 | raise InvalidRegistrationResponse( 52 | "Attestation statement contained too many certificates (FIDO-U2F)" 53 | ) 54 | 55 | # Validate the certificate chain 56 | try: 57 | validate_certificate_chain( 58 | x5c=attestation_statement.x5c, 59 | pem_root_certs_bytes=pem_root_certs_bytes, 60 | ) 61 | except InvalidCertificateChain as err: 62 | raise InvalidRegistrationResponse(f"{err} (FIDO-U2F)") 63 | 64 | # FIDO spec requires AAGUID in U2F attestations to be all zeroes 65 | # See https://fidoalliance.org/specs/fido-v2.1-rd-20191217/fido-client-to-authenticator-protocol-v2.1-rd-20191217.html#u2f-authenticatorMakeCredential-interoperability 66 | actual_aaguid = aaguid_to_string(aaguid) 67 | expected_aaguid = "00000000-0000-0000-0000-000000000000" 68 | if actual_aaguid != expected_aaguid: 69 | raise InvalidRegistrationResponse( 70 | f"AAGUID {actual_aaguid} was not expected {expected_aaguid} (FIDO-U2F)" 71 | ) 72 | 73 | # Get the public key from the leaf certificate 74 | leaf_cert_bytes = attestation_statement.x5c[0] 75 | leaf_cert = x509.load_der_x509_certificate(leaf_cert_bytes) 76 | leaf_cert_pub_key = leaf_cert.public_key() 77 | 78 | # We need the cert's x and y points so make sure they exist 79 | if not isinstance(leaf_cert_pub_key, EllipticCurvePublicKey): 80 | raise InvalidRegistrationResponse("Leaf cert was not an EC2 certificate (FIDO-U2F)") 81 | 82 | if not isinstance(leaf_cert_pub_key.curve, SECP256R1): 83 | raise InvalidRegistrationResponse("Leaf cert did not use P-256 curve (FIDO-U2F)") 84 | 85 | decoded_public_key = decode_credential_public_key(credential_public_key) 86 | if not isinstance(decoded_public_key, DecodedEC2PublicKey): 87 | raise InvalidRegistrationResponse("Credential public key was not EC2 (FIDO-U2F)") 88 | 89 | # Convert the public key to "Raw ANSI X9.62 public key format" 90 | public_key_u2f = b"".join( 91 | [ 92 | bytes([0x04]), 93 | decoded_public_key.x, 94 | decoded_public_key.y, 95 | ] 96 | ) 97 | 98 | # Generate a hash of client_data_json 99 | client_data_hash = hashlib.sha256() 100 | client_data_hash.update(client_data_json) 101 | client_data_hash_bytes = client_data_hash.digest() 102 | 103 | # Prepare the signature base (called "verificationData" in the WebAuthn spec) 104 | verification_data = b"".join( 105 | [ 106 | bytes([0x00]), 107 | rp_id_hash, 108 | client_data_hash_bytes, 109 | credential_id, 110 | public_key_u2f, 111 | ] 112 | ) 113 | 114 | try: 115 | verify_signature( 116 | public_key=leaf_cert_pub_key, 117 | signature_alg=COSEAlgorithmIdentifier.ECDSA_SHA_256, 118 | signature=attestation_statement.sig, 119 | data=verification_data, 120 | ) 121 | except InvalidSignature: 122 | raise InvalidRegistrationResponse( 123 | "Could not verify attestation statement signature (FIDO-U2F)" 124 | ) 125 | 126 | # If we make it to here we're all good 127 | return True 128 | -------------------------------------------------------------------------------- /webauthn/registration/formats/packed.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from typing import List 3 | 4 | from cryptography import x509 5 | from cryptography.exceptions import InvalidSignature 6 | 7 | from webauthn.helpers import ( 8 | decode_credential_public_key, 9 | decoded_public_key_to_cryptography, 10 | parse_cbor, 11 | validate_certificate_chain, 12 | verify_signature, 13 | ) 14 | from webauthn.helpers.exceptions import ( 15 | InvalidCertificateChain, 16 | InvalidRegistrationResponse, 17 | ) 18 | from webauthn.helpers.structs import AttestationStatement 19 | 20 | 21 | def verify_packed( 22 | *, 23 | attestation_statement: AttestationStatement, 24 | attestation_object: bytes, 25 | client_data_json: bytes, 26 | credential_public_key: bytes, 27 | pem_root_certs_bytes: List[bytes], 28 | ) -> bool: 29 | """Verify a "packed" attestation statement 30 | 31 | See https://www.w3.org/TR/webauthn-2/#sctn-packed-attestation 32 | """ 33 | if not attestation_statement.sig: 34 | raise InvalidRegistrationResponse("Attestation statement was missing signature (Packed)") 35 | 36 | if not attestation_statement.alg: 37 | raise InvalidRegistrationResponse("Attestation statement was missing algorithm (Packed)") 38 | 39 | # Extract attStmt bytes from attestation_object 40 | attestation_dict = parse_cbor(attestation_object) 41 | authenticator_data_bytes = attestation_dict["authData"] 42 | 43 | # Generate a hash of client_data_json 44 | client_data_hash = hashlib.sha256() 45 | client_data_hash.update(client_data_json) 46 | client_data_hash_bytes = client_data_hash.digest() 47 | 48 | verification_data = b"".join( 49 | [ 50 | authenticator_data_bytes, 51 | client_data_hash_bytes, 52 | ] 53 | ) 54 | 55 | if attestation_statement.x5c: 56 | # Validate the certificate chain 57 | try: 58 | validate_certificate_chain( 59 | x5c=attestation_statement.x5c, 60 | pem_root_certs_bytes=pem_root_certs_bytes, 61 | ) 62 | except InvalidCertificateChain as err: 63 | raise InvalidRegistrationResponse(f"{err} (Packed)") 64 | 65 | attestation_cert_bytes = attestation_statement.x5c[0] 66 | attestation_cert = x509.load_der_x509_certificate(attestation_cert_bytes) 67 | attestation_cert_pub_key = attestation_cert.public_key() 68 | 69 | try: 70 | verify_signature( 71 | public_key=attestation_cert_pub_key, 72 | signature_alg=attestation_statement.alg, 73 | signature=attestation_statement.sig, 74 | data=verification_data, 75 | ) 76 | except InvalidSignature: 77 | raise InvalidRegistrationResponse( 78 | "Could not verify attestation statement signature (Packed)" 79 | ) 80 | else: 81 | # Self Attestation 82 | decoded_pub_key = decode_credential_public_key(credential_public_key) 83 | 84 | if decoded_pub_key.alg != attestation_statement.alg: 85 | raise InvalidRegistrationResponse( 86 | f"Credential public key alg {decoded_pub_key.alg} did not equal attestation statement alg {attestation_statement.alg}" 87 | ) 88 | 89 | public_key = decoded_public_key_to_cryptography(decoded_pub_key) 90 | 91 | try: 92 | verify_signature( 93 | public_key=public_key, 94 | signature_alg=attestation_statement.alg, 95 | signature=attestation_statement.sig, 96 | data=verification_data, 97 | ) 98 | except InvalidSignature: 99 | raise InvalidRegistrationResponse( 100 | "Could not verify attestation statement signature (Packed|Self)" 101 | ) 102 | 103 | return True 104 | -------------------------------------------------------------------------------- /webauthn/registration/generate_registration_options.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from webauthn.helpers import generate_challenge, generate_user_handle, byteslike_to_bytes 4 | from webauthn.helpers.cose import COSEAlgorithmIdentifier 5 | from webauthn.helpers.structs import ( 6 | AttestationConveyancePreference, 7 | AuthenticatorSelectionCriteria, 8 | PublicKeyCredentialCreationOptions, 9 | PublicKeyCredentialDescriptor, 10 | PublicKeyCredentialParameters, 11 | PublicKeyCredentialRpEntity, 12 | PublicKeyCredentialUserEntity, 13 | ResidentKeyRequirement, 14 | PublicKeyCredentialHint, 15 | ) 16 | 17 | 18 | def _generate_pub_key_cred_params( 19 | supported_algs: List[COSEAlgorithmIdentifier], 20 | ) -> List[PublicKeyCredentialParameters]: 21 | """ 22 | Take an array of algorithm ID ints and return an array of PublicKeyCredentialParameters 23 | """ 24 | return [PublicKeyCredentialParameters(type="public-key", alg=alg) for alg in supported_algs] 25 | 26 | 27 | default_supported_pub_key_algs = [ 28 | COSEAlgorithmIdentifier.ECDSA_SHA_256, 29 | COSEAlgorithmIdentifier.EDDSA, 30 | COSEAlgorithmIdentifier.ECDSA_SHA_512, 31 | COSEAlgorithmIdentifier.RSASSA_PSS_SHA_256, 32 | COSEAlgorithmIdentifier.RSASSA_PSS_SHA_384, 33 | COSEAlgorithmIdentifier.RSASSA_PSS_SHA_512, 34 | COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, 35 | COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384, 36 | COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512, 37 | ] 38 | default_supported_pub_key_params = _generate_pub_key_cred_params( 39 | default_supported_pub_key_algs, 40 | ) 41 | 42 | 43 | def generate_registration_options( 44 | *, 45 | rp_id: str, 46 | rp_name: str, 47 | user_name: str, 48 | user_id: Optional[bytes] = None, 49 | user_display_name: Optional[str] = None, 50 | challenge: Optional[bytes] = None, 51 | timeout: int = 60000, 52 | attestation: AttestationConveyancePreference = AttestationConveyancePreference.NONE, 53 | authenticator_selection: Optional[AuthenticatorSelectionCriteria] = None, 54 | exclude_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None, 55 | supported_pub_key_algs: Optional[List[COSEAlgorithmIdentifier]] = None, 56 | hints: Optional[List[PublicKeyCredentialHint]] = None, 57 | ) -> PublicKeyCredentialCreationOptions: 58 | """Generate options for registering a credential via navigator.credentials.create() 59 | 60 | Args: 61 | `rp_id`: A unique, constant identifier for this Relying Party. 62 | `rp_name`: A user-friendly, readable name for the Relying Party. 63 | `user_name`: A value that will help the user identify which account this credential is associated with. Can be an email address, etc... 64 | (optional) `user_id`: A collection of random bytes that identify a user account. For privacy reasons it should NOT be something like an email address. Defaults to 64 random bytes. 65 | (optional) `user_display_name`: A user-friendly representation of their account. Can be a full name ,etc... Defaults to the value of `user_name`. 66 | (optional) `challenge`: A byte sequence for the authenticator to return back in its response. Defaults to 64 random bytes. 67 | (optional) `timeout`: How long in milliseconds the browser should give the user to choose an authenticator. This value is a *hint* and may be ignored by the browser. 68 | (optional) `attestation`: The level of attestation to be provided by the authenticator. 69 | (optional) `authenticator_selection`: Require certain characteristics about an authenticator, like attachment, support for resident keys, user verification, etc... 70 | (optional) `exclude_credentials`: A list of credentials the user has previously registered so that they cannot re-register them. 71 | (optional) `supported_pub_key_algs`: A list of public key algorithm IDs the RP chooses to restrict support to. Defaults to all supported algorithm IDs. 72 | 73 | Returns: 74 | Registration options ready for the browser. Consider using `helpers.options_to_json()` in this library to quickly convert the options to JSON. 75 | """ 76 | 77 | if not rp_id: 78 | raise ValueError("rp_id cannot be an empty string") 79 | 80 | if not rp_name: 81 | raise ValueError("rp_name cannot be an empty string") 82 | 83 | if not user_name: 84 | raise ValueError("user_name cannot be an empty string") 85 | 86 | if user_id: 87 | if not isinstance(user_id, bytes): 88 | raise ValueError("user_id must be bytes") 89 | else: 90 | user_id = generate_user_handle() 91 | 92 | ######## 93 | # Set defaults for required values 94 | ######## 95 | 96 | if not user_display_name: 97 | user_display_name = user_name 98 | 99 | pub_key_cred_params = default_supported_pub_key_params 100 | if supported_pub_key_algs: 101 | pub_key_cred_params = _generate_pub_key_cred_params(supported_pub_key_algs) 102 | 103 | if not challenge: 104 | challenge = generate_challenge() 105 | 106 | if not exclude_credentials: 107 | exclude_credentials = [] 108 | 109 | ######## 110 | # Generate the actual options 111 | ######## 112 | 113 | options = PublicKeyCredentialCreationOptions( 114 | rp=PublicKeyCredentialRpEntity( 115 | name=rp_name, 116 | id=rp_id, 117 | ), 118 | user=PublicKeyCredentialUserEntity( 119 | id=user_id, 120 | name=user_name, 121 | display_name=user_display_name, 122 | ), 123 | challenge=challenge, 124 | pub_key_cred_params=pub_key_cred_params, 125 | timeout=timeout, 126 | exclude_credentials=exclude_credentials, 127 | attestation=attestation, 128 | hints=hints, 129 | ) 130 | 131 | ######## 132 | # Set optional values if specified 133 | ######## 134 | 135 | if authenticator_selection is not None: 136 | # "Relying Parties SHOULD set [requireResidentKey] to true if, and only if, 137 | # residentKey is set to "required"" 138 | # 139 | # See https://www.w3.org/TR/webauthn-2/#dom-authenticatorselectioncriteria-requireresidentkey 140 | if authenticator_selection.resident_key == ResidentKeyRequirement.REQUIRED: 141 | authenticator_selection.require_resident_key = True 142 | options.authenticator_selection = authenticator_selection 143 | 144 | return options 145 | --------------------------------------------------------------------------------