├── fido2 ├── py.typed ├── __init__.py ├── ctap2 │ ├── __init__.py │ └── config.py ├── attestation │ ├── __init__.py │ ├── apple.py │ ├── u2f.py │ ├── android.py │ └── packed.py ├── features.py ├── rpid.py ├── hid │ ├── linux.py │ ├── openbsd.py │ ├── base.py │ └── netbsd.py ├── payment.py ├── ctap.py └── cbor.py ├── tests ├── __init__.py ├── device │ ├── test_hid.py │ ├── test_credentials.py │ ├── __init__.py │ ├── test_credblob.py │ ├── test_info.py │ ├── test_clientpin.py │ ├── test_bioenroll.py │ ├── test_payment.py │ ├── test_client.py │ └── test_largeblobs.py ├── conftest.py ├── test_hid.py ├── test_tpm.py ├── test_pcsc.py ├── test_server.py ├── test_rpid.py └── test_utils.py ├── examples ├── server │ ├── server │ │ ├── __init__.py │ │ ├── static │ │ │ ├── index.html │ │ │ ├── register.html │ │ │ ├── authenticate.html │ │ │ └── webauthn-json.browser-ponyfill.js │ │ └── server.py │ ├── pyproject.toml │ └── README.adoc ├── u2f_nfc.py ├── acr122u.py ├── get_info.py ├── bio_enrollment.py ├── credential.py ├── cred_blob.py ├── resident_key.py ├── multi_device.py ├── exampleutils.py ├── large_blobs.py ├── verify_attestation.py ├── prf.py ├── verify_attestation_mds3.py ├── hmac_secret.py └── acr1252u.py ├── docs ├── favicon.ico ├── index.rst ├── Makefile ├── make.bat └── conf.py ├── mypy.ini ├── .github ├── dependabot.yml └── workflows │ └── build.yml ├── .gitignore ├── .pre-commit-config.yaml ├── COPYING ├── RELEASE.adoc ├── pyproject.toml └── README.adoc /fido2/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/server/server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yubico/python-fido2/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | files = fido2/ 3 | check_untyped_defs = True 4 | 5 | [mypy-smartcard.*] 6 | ignore_missing_imports = True 7 | -------------------------------------------------------------------------------- /tests/device/test_hid.py: -------------------------------------------------------------------------------- 1 | def test_ping(device): 2 | msg1 = b"hello world!" 3 | msg2 = b" " 4 | msg3 = b"" 5 | assert device.ping(msg1) == msg1 6 | assert device.ping(msg2) == msg2 7 | assert device.ping(msg3) == msg3 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | github-actions: 9 | patterns: 10 | - "*" 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg 3 | *.egg-info 4 | build/ 5 | dist/ 6 | .eggs/ 7 | .idea/ 8 | .ropeproject/ 9 | ChangeLog 10 | man/*.1 11 | poetry.lock 12 | **/_build 13 | 14 | # Unit test / coverage reports 15 | htmlcov/ 16 | .tox/ 17 | .coverage 18 | .coverage.* 19 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_addoption(parser): 2 | parser.addoption("--reader", action="store") 3 | parser.addoption("--no-device", action="store_true") 4 | parser.addoption("--ep-rp-id", action="store") 5 | parser.addoption("--ccid", action="store_true") 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.14.2 4 | hooks: 5 | # Run the linter 6 | - id: ruff-check 7 | args: [ --fix ] 8 | # Run the formatter 9 | - id: ruff-format 10 | - repo: https://github.com/pre-commit/mirrors-mypy 11 | rev: v1.18.2 12 | hooks: 13 | - id: mypy 14 | exclude: ^docs/ 15 | files: ^fido2/ 16 | - repo: https://github.com/RobertCraigie/pyright-python 17 | rev: v1.1.407 18 | hooks: 19 | - id: pyright 20 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. python-fido2 documentation master file, created by 2 | sphinx-quickstart on Fri Nov 8 11:41:52 2024. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to python-fido2's documentation! 7 | ======================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | autoapi/index 14 | 15 | 16 | Indices and tables 17 | ================== 18 | 19 | * :ref:`genindex` 20 | * :ref:`modindex` 21 | * :ref:`search` 22 | 23 | -------------------------------------------------------------------------------- /examples/server/server/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Fido 2.0 webauthn demo 4 | 10 | 11 | 12 |

WebAuthn demo using python-fido2

13 |

This demo requires a browser supporting the WebAuthn API!

14 |
15 | 16 |

Available actions

17 | Register
18 | Authenticate
19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/server/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fido2-example-server" 3 | version = "0.1.0" 4 | description = "Example server for python-fido2" 5 | authors = [ 6 | { name = "Dain Nilsson", email = "" } 7 | ] 8 | requires-python = ">=3.10, <4" 9 | license = "Apache-2" 10 | dependencies = [ 11 | "Flask (>=2.0, <3)", 12 | "fido2", 13 | ] 14 | 15 | [tool.poetry.dependencies] 16 | fido2 = {path = "../.."} 17 | 18 | [tool.poetry] 19 | requires-poetry = ">=2.0" 20 | packages = [ 21 | { include = "server" }, 22 | ] 23 | 24 | [build-system] 25 | requires = ["poetry-core>=2.0"] 26 | build-backend = "poetry.core.masonry.api" 27 | 28 | [project.scripts] 29 | server = "server.server:main" 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /tests/device/test_credentials.py: -------------------------------------------------------------------------------- 1 | from fido2.server import Fido2Server 2 | 3 | 4 | def test_make_assert(client, pin_protocol, algorithm): 5 | rp = {"id": "example.com", "name": "Example RP"} 6 | server = Fido2Server(rp) 7 | user = {"id": b"user_id", "name": "A. User"} 8 | 9 | create_options, state = server.register_begin(user) 10 | 11 | # Create a credential 12 | result = client.make_credential( 13 | { 14 | **create_options["publicKey"], 15 | "pubKeyCredParams": [algorithm], 16 | } 17 | ) 18 | 19 | auth_data = server.register_complete(state, result) 20 | cred = auth_data.credential_data 21 | assert cred.public_key[3] == algorithm["alg"] 22 | credentials = [cred] 23 | 24 | # Get assertion 25 | request_options, state = server.authenticate_begin(credentials) 26 | 27 | # Authenticate the credential 28 | result = client.get_assertion(request_options.public_key).get_response(0) 29 | cred_data = server.authenticate_complete(state, credentials, result) 30 | assert cred_data == cred 31 | -------------------------------------------------------------------------------- /examples/u2f_nfc.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from fido2.ctap1 import Ctap1 4 | from fido2.pcsc import CtapPcscDevice 5 | from fido2.utils import sha256 6 | 7 | dev = next(CtapPcscDevice.list_devices(), None) 8 | if not dev: 9 | print("No NFC u2f device found") 10 | sys.exit(1) 11 | 12 | chal = sha256(b"AAA") 13 | appid = sha256(b"BBB") 14 | 15 | ctap1 = Ctap1(dev) 16 | 17 | print("version:", ctap1.get_version()) 18 | 19 | # True - make extended APDU and send it to key 20 | # ISO 7816-3:2006. page 33, 12.1.3 Decoding conventions for command APDUs 21 | # ISO 7816-3:2006. page 34, 12.2 Command-response pair transmission by T=0 22 | # False - make group of short (less than 255 bytes length) APDU 23 | # and send them to key. ISO 7816-3:2005, page 9, 5.1.1.1 Command chaining 24 | dev.use_ext_apdu = False 25 | 26 | reg = ctap1.register(chal, appid) 27 | print("register:", reg) 28 | 29 | 30 | reg.verify(appid, chal) 31 | print("Register message verify OK") 32 | 33 | 34 | auth = ctap1.authenticate(chal, appid, reg.key_handle) 35 | print("authenticate result: ", auth) 36 | 37 | res = auth.verify(appid, chal, reg.public_key) 38 | print("Authenticate message verify OK") 39 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Yubico AB 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 19 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 20 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 21 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 22 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 24 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /RELEASE.adoc: -------------------------------------------------------------------------------- 1 | == Release instructions 2 | * Create a release branch: 3 | 4 | $ git checkout -b release/x.y.z 5 | 6 | * Update the version in pyproject.toml and make sure the NEWS file has an entry 7 | for it, and the correct release date. 8 | * Commit the changes, and push the new branch. 9 | 10 | $ git push -u origin release/x.y.z 11 | 12 | * Wait for CI to complete, and make sure nothing fails. 13 | 14 | * Create a signed tag using the version number as name: 15 | 16 | $ git tag -s -m x.y.z x.y.z 17 | 18 | * Build the release: 19 | 20 | $ uv build 21 | 22 | * Sign the release: 23 | 24 | $ gpg --detach-sign -a dist/fido2-x.y.z.tar.gz 25 | $ gpg --detach-sign -a dist/fido2-x.y.z-py3-none-any.whl 26 | 27 | * Upload the release to PyPI: 28 | 29 | $ uv publish 30 | 31 | * Add the .tar.gz, the .whl and .sig files to a new Github release, using the 32 | latest NEWS entry as description. 33 | 34 | * Merge and delete the release branch, and push the tag: 35 | 36 | $ git checkout main 37 | $ git merge --ff release/x.y.z 38 | $ git branch -d release/x.y.z 39 | $ git push && git push --tags 40 | $ git push origin :release/x.y.z 41 | 42 | * Bump the version number by incrementing the PATCH version and appending -dev.0 43 | in pyproject.toml and add a new entry (unreleased) to the NEWS file. 44 | 45 | # pyproject.toml: 46 | version = "x.y.q-dev.0" 47 | 48 | * Commit and push the change: 49 | 50 | $ git commit -a -m "Bump version." && git push 51 | -------------------------------------------------------------------------------- /fido2/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /tests/device/__init__.py: -------------------------------------------------------------------------------- 1 | from fido2.client import UserInteraction 2 | 3 | TEST_PIN = "a1b2c3d4" 4 | 5 | 6 | class Printer: 7 | def __init__(self, capmanager): 8 | self.capmanager = capmanager 9 | 10 | def print(self, *messages): 11 | with self.capmanager.global_and_fixture_disabled(): 12 | print("") 13 | for m in messages: 14 | print(m) 15 | 16 | def touch(self): 17 | self.print("👉 Touch the Authenticator") 18 | 19 | def insert(self, nfc=False): 20 | self.print( 21 | "♻️ " 22 | + ( 23 | "Place the Authenticator on the NFC reader" 24 | if nfc 25 | else "Connect the Authenticator" 26 | ) 27 | ) 28 | 29 | def remove(self, nfc=False): 30 | self.print( 31 | "🚫 " 32 | + ( 33 | "Remove the Authenticator from the NFC reader" 34 | if nfc 35 | else "Disconnect the Authenticator" 36 | ) 37 | ) 38 | 39 | 40 | # Handle user interaction 41 | class CliInteraction(UserInteraction): 42 | def __init__(self, printer, pin=TEST_PIN): 43 | self.printer = printer 44 | self.pin = pin 45 | 46 | def prompt_up(self): 47 | self.printer.touch() 48 | 49 | def request_pin(self, permissions, rd_id): 50 | return self.pin 51 | 52 | def request_uv(self, permissions, rd_id): 53 | self.printer.print("User Verification required.") 54 | return True 55 | -------------------------------------------------------------------------------- /tests/device/test_credblob.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from fido2.server import Fido2Server 6 | 7 | 8 | @pytest.fixture(autouse=True, scope="module") 9 | def preconditions(dev_manager): 10 | if "credBlob" not in dev_manager.info.extensions: 11 | pytest.skip("CredBlob not supported by authenticator") 12 | 13 | 14 | def test_read_write(client, ctap2, clear_creds): 15 | rp = {"id": "example.com", "name": "Example RP"} 16 | server = Fido2Server(rp) 17 | user = {"id": b"user_id", "name": "A. User"} 18 | 19 | create_options, state = server.register_begin( 20 | user, 21 | resident_key_requirement="required", 22 | user_verification="required", 23 | ) 24 | 25 | # Create a credential 26 | blob = os.urandom(32) 27 | result = client.make_credential( 28 | { 29 | **create_options["publicKey"], 30 | "extensions": {"credBlob": blob}, 31 | } 32 | ) 33 | auth_data = server.register_complete(state, result) 34 | credentials = [auth_data.credential_data] 35 | 36 | assert auth_data.extensions["credBlob"] is True 37 | 38 | request_options, state = server.authenticate_begin( 39 | credentials, user_verification="required" 40 | ) 41 | 42 | selection = client.get_assertion( 43 | { 44 | **request_options["publicKey"], 45 | "extensions": {"getCredBlob": True}, 46 | } 47 | ) 48 | result = selection.get_response(0) 49 | 50 | assert result.response.authenticator_data.extensions.get("credBlob") == blob 51 | -------------------------------------------------------------------------------- /fido2/ctap2/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from .base import ( # noqa 29 | Ctap2, 30 | Info, 31 | AttestationResponse, 32 | AssertionResponse, 33 | ) 34 | 35 | from .pin import ClientPin, PinProtocolV1, PinProtocolV2 # noqa 36 | from .credman import CredentialManagement # noqa 37 | from .bio import FPBioEnrollment, CaptureError # noqa 38 | from .blob import LargeBlobs # noqa 39 | from .config import Config # noqa 40 | -------------------------------------------------------------------------------- /examples/server/server/static/register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Fido 2.0 webauthn demo 4 | 5 | 34 | 35 | 41 | 42 | 43 |

WebAuthn demo using python-fido2

44 |

This demo requires a browser supporting the WebAuthn API!

45 |
46 | 47 |

Register a credential

48 |
49 | 50 |
51 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /examples/server/server/static/authenticate.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Fido 2.0 webauthn demo 4 | 5 | 35 | 36 | 37 | 38 | 44 | 45 | 46 |

WebAuthn demo using python-fido2

47 |

This demo requires a browser supporting the WebAuthn API!

48 |
49 | 50 |

Authenticate using a credential

51 |
52 | 53 |
54 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /examples/server/README.adoc: -------------------------------------------------------------------------------- 1 | == WebAuthn Server Example 2 | This example shows a minimal website that uses python-fido2 to implement 3 | WebAuthn credential registration, and use. 4 | 5 | 6 | === Running 7 | To run this sample, you will need `poetry`. For instructions on installing 8 | `poetry`, see https://python-poetry.org/. 9 | 10 | Run the following command in the `examples/server` directory to set up the 11 | example: 12 | 13 | $ poetry install 14 | 15 | Once the environment has been created, you can run the server by running: 16 | 17 | $ poetry run server 18 | 19 | When the server is running, use a browser supporting WebAuthn and open 20 | http://localhost:5000 to access the website. 21 | 22 | NOTE: Webauthn requires a secure context (HTTPS), which involves 23 | obtaining a valid TLS certificate. However, most browsers also treat 24 | http://localhost as a secure context. This example runs without TLS 25 | as a demo, but otherwise you should always use HTTPS with a valid 26 | certificate when using Webauthn. 27 | 28 | === Using the website 29 | The site allows you to register a WebAuthn credential, and to authenticate it. 30 | Credentials are only stored in memory, and stopping the server will cause it to 31 | "forget" any registered credentials. 32 | 33 | ==== Registration 34 | 1. Click on the `Register` link to begin credential registration. 35 | 2. If not already inserted, insert your U2F/FIDO2 Authenticator now. 36 | 3. Touch the button to activate the Authenticator. 37 | 4. A popup will indicate whether the registration was successful. Click `OK`. 38 | 39 | ==== Authentication 40 | NOTE: You must register a credential prior to authentication. 41 | 42 | 1. Click on the `Authenticate` link to begin authentication. 43 | 2. If not already inserted, insert your U2F/FIDO2 Authenticator now. 44 | 3. Touch the button to activate the Authenticator. 45 | 4. A popup will indicate whether the authentication was successful. Click `OK`. 46 | -------------------------------------------------------------------------------- /fido2/attestation/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from .android import AndroidSafetynetAttestation # noqa: F401 29 | from .apple import AppleAttestation # noqa: F401 30 | from .base import ( # noqa: F401 31 | Attestation, 32 | AttestationResult, 33 | AttestationType, 34 | AttestationVerifier, 35 | InvalidData, 36 | InvalidSignature, 37 | NoneAttestation, 38 | UnsupportedAttestation, 39 | UnsupportedType, 40 | UntrustedAttestation, 41 | verify_x509_chain, 42 | ) 43 | from .packed import PackedAttestation # noqa: F401 44 | from .tpm import TpmAttestation # noqa: F401 45 | from .u2f import FidoU2FAttestation # noqa: F401 46 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fido2" 3 | version = "2.1.0-dev.0" 4 | description = "FIDO2/WebAuthn library for implementing clients and servers." 5 | authors = [{ name = "Dain Nilsson", email = "" }] 6 | readme = "README.adoc" 7 | requires-python = ">=3.10, <4" 8 | license = { file = "COPYING" } 9 | keywords = ["fido2", "webauthn", "ctap", "u2f"] 10 | classifiers = [ 11 | "License :: OSI Approved :: BSD License", 12 | "License :: OSI Approved :: Apache Software License", 13 | "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", 14 | "Operating System :: MacOS", 15 | "Operating System :: Microsoft :: Windows", 16 | "Operating System :: POSIX :: Linux", 17 | "Development Status :: 5 - Production/Stable", 18 | "Intended Audience :: Developers", 19 | "Intended Audience :: System Administrators", 20 | "Topic :: Internet", 21 | "Topic :: Security :: Cryptography", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | ] 24 | dependencies = ["cryptography (>=2.6, !=35, <49)"] 25 | 26 | [project.optional-dependencies] 27 | pcsc = ["pyscard (>=1.9, <3)"] 28 | 29 | [dependency-groups] 30 | dev = [ 31 | "pytest>=8.0,<9", 32 | "sphinx>=7.4,<8", 33 | "sphinx-rtd-theme>=3,<4", 34 | "sphinx-autoapi>=3.3.3,<4", 35 | ] 36 | 37 | [project.urls] 38 | Homepage = "https://github.com/Yubico/python-fido2" 39 | 40 | [tool.poetry] 41 | include = [ 42 | { path = "COPYING", format = "sdist" }, 43 | { path = "COPYING.MPLv2", format = "sdist" }, 44 | { path = "COPYING.APLv2", format = "sdist" }, 45 | { path = "NEWS", format = "sdist" }, 46 | { path = "README.adoc", format = "sdist" }, 47 | { path = "tests/", format = "sdist" }, 48 | { path = "examples/", format = "sdist" }, 49 | ] 50 | 51 | [build-system] 52 | requires = ["poetry-core>=2.0"] 53 | build-backend = "poetry.core.masonry.api" 54 | 55 | [tool.pytest.ini_options] 56 | testpaths = ["tests"] 57 | 58 | [tool.ruff.lint] 59 | extend-select = ["E", "I", "S"] 60 | exclude = ["tests/*"] 61 | 62 | [tool.pyright] 63 | venvPath = "." 64 | venv = ".venv" 65 | exclude = ["tests/", "docs/", "examples/"] 66 | reportPrivateImportUsage = false 67 | -------------------------------------------------------------------------------- /tests/test_hid.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from fido2.hid.base import parse_report_descriptor 29 | import pytest 30 | 31 | 32 | def test_parse_report_descriptor_1(): 33 | max_in_size, max_out_size = parse_report_descriptor( 34 | bytes.fromhex( 35 | "06d0f10901a1010920150026ff007508954081020921150026ff00750895409102c0" 36 | ) 37 | ) 38 | 39 | assert max_in_size == 64 40 | assert max_out_size == 64 41 | 42 | 43 | def test_parse_report_descriptor_2(): 44 | with pytest.raises(ValueError): 45 | parse_report_descriptor( 46 | bytes.fromhex( 47 | "05010902a1010901a10005091901290515002501950575018102950175038101" 48 | "05010930093109381581257f750895038106c0c0" 49 | ) 50 | ) 51 | -------------------------------------------------------------------------------- /tests/device/test_info.py: -------------------------------------------------------------------------------- 1 | from fido2.webauthn import Aaguid 2 | 3 | 4 | def assert_list_of(typ, value): 5 | assert isinstance(value, list) 6 | for v in value: 7 | assert isinstance(v, typ) 8 | 9 | 10 | def assert_dict_of(k_type, v_type, value): 11 | assert isinstance(value, dict) 12 | for k, v in value.items(): 13 | assert isinstance(k, k_type) 14 | assert isinstance(v, v_type) 15 | 16 | 17 | def assert_unique(value): 18 | assert len(set(value)) == len(value) 19 | 20 | 21 | def test_get_info_fields(ctap2): 22 | info = ctap2.get_info() 23 | 24 | assert_list_of(str, info.versions) 25 | assert len(info.versions) > 0 26 | 27 | assert_list_of(str, info.extensions) 28 | assert isinstance(info.aaguid, Aaguid) 29 | assert_dict_of(str, bool | None, info.options) 30 | assert isinstance(info.max_msg_size, int) 31 | assert_list_of(int, info.pin_uv_protocols) 32 | assert_unique(info.pin_uv_protocols) 33 | assert isinstance(info.max_creds_in_list, int) 34 | assert isinstance(info.max_cred_id_length, int) 35 | assert_list_of(str, info.transports) 36 | assert_unique(info.transports) 37 | 38 | assert_list_of(dict, info.algorithms) 39 | assert isinstance(info.max_large_blob, int) 40 | assert isinstance(info.force_pin_change, bool) 41 | assert isinstance(info.min_pin_length, int) 42 | assert info.min_pin_length >= 4 43 | assert isinstance(info.firmware_version, int) 44 | assert isinstance(info.max_cred_blob_length, int) 45 | assert isinstance(info.max_rpids_for_min_pin, int) 46 | assert isinstance(info.preferred_platform_uv_attempts, int) 47 | assert isinstance(info.uv_modality, int) 48 | assert_dict_of(str, int, info.certifications) 49 | 50 | assert isinstance(info.remaining_disc_creds, int | None) 51 | assert_list_of(int, info.vendor_prototype_config_commands) 52 | assert_list_of(str, info.attestation_formats) 53 | assert_unique(info.attestation_formats) 54 | assert len(info.attestation_formats) > 0 55 | 56 | assert isinstance(info.uv_count_since_pin, int | None) 57 | assert isinstance(info.long_touch_for_reset, bool) 58 | 59 | 60 | def test_enc_identifier_changes(ctap2): 61 | if ctap2.info.enc_identifier: 62 | assert ctap2.get_info().enc_identifier != ctap2.get_info().enc_identifier 63 | -------------------------------------------------------------------------------- /fido2/attestation/apple.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from __future__ import annotations 29 | 30 | from cryptography import x509 31 | from cryptography.hazmat.backends import default_backend 32 | from cryptography.hazmat.primitives.constant_time import bytes_eq 33 | 34 | from ..utils import sha256 35 | from .base import ( 36 | Attestation, 37 | AttestationResult, 38 | AttestationType, 39 | InvalidData, 40 | catch_builtins, 41 | ) 42 | 43 | OID_APPLE = x509.ObjectIdentifier("1.2.840.113635.100.8.2") 44 | 45 | 46 | class AppleAttestation(Attestation): 47 | FORMAT = "apple" 48 | 49 | @catch_builtins 50 | def verify(self, statement, auth_data, client_data_hash): 51 | x5c = statement["x5c"] 52 | expected_nonce = sha256(auth_data + client_data_hash) 53 | cert = x509.load_der_x509_certificate(x5c[0], default_backend()) 54 | ext = cert.extensions.get_extension_for_oid(OID_APPLE) 55 | # Sequence of single element of octet string 56 | ext_nonce = ext.value.public_bytes()[6:] 57 | if not bytes_eq(expected_nonce, ext_nonce): 58 | raise InvalidData("Nonce does not match!") 59 | return AttestationResult(AttestationType.ANON_CA, x5c) 60 | -------------------------------------------------------------------------------- /examples/acr122u.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from fido2.pcsc import CtapPcscDevice 4 | 5 | 6 | class Acr122uPcscDevice(object): 7 | def __init__(self, pcsc_device): 8 | self.pcsc = pcsc_device 9 | 10 | def reader_version(self): 11 | """ 12 | Get reader's version from reader 13 | :return: string. Reader's version 14 | """ 15 | 16 | try: 17 | result, sw1, sw2 = self.pcsc.apdu_exchange(b"\xff\x00\x48\x00\x00") 18 | if len(result) > 0: 19 | str_result = result + bytes([sw1]) + bytes([sw2]) 20 | str_result = str_result.decode("utf-8") 21 | return str_result 22 | except Exception as e: 23 | print("Get version error:", e) 24 | return "n/a" 25 | 26 | def led_control( 27 | self, 28 | red=False, 29 | green=False, 30 | blink_count=0, 31 | red_end_blink=False, 32 | green_end_blink=False, 33 | ): 34 | """ 35 | Reader's led control 36 | :param red: boolean. red led on 37 | :param green: boolean. green let on 38 | :param blink_count: int. if needs to blink value > 0. blinks count 39 | :param red_end_blink: boolean. 40 | state of red led at the end of blinking 41 | :param green_end_blink: boolean. 42 | state of green led at the end of blinking 43 | :return: 44 | """ 45 | 46 | try: 47 | if blink_count > 0: 48 | cbyte = ( 49 | 0b00001100 50 | + (0b01 if red_end_blink else 0b00) 51 | + (0b10 if green_end_blink else 0b00) 52 | ) 53 | cbyte |= (0b01000000 if red else 0b00000000) + ( 54 | 0b10000000 if green else 0b00000000 55 | ) 56 | else: 57 | cbyte = 0b00001100 + (0b01 if red else 0b00) + (0b10 if green else 0b00) 58 | 59 | apdu = ( 60 | b"\xff\x00\x40" 61 | + bytes([cbyte & 0xFF]) 62 | + b"\4" 63 | + b"\5\3" 64 | + bytes([blink_count]) 65 | + b"\0" 66 | ) 67 | self.pcsc.apdu_exchange(apdu) 68 | 69 | except Exception as e: 70 | print("LED control error:", e) 71 | 72 | 73 | dev = next(CtapPcscDevice.list_devices()) 74 | 75 | print("CONNECT: %s" % dev) 76 | pcsc_device = Acr122uPcscDevice(dev) 77 | pcsc_device.led_control(False, True, 0) 78 | print("version: %s" % pcsc_device.reader_version()) 79 | pcsc_device.led_control(True, False, 0) 80 | time.sleep(1) 81 | pcsc_device.led_control(False, True, 3) 82 | -------------------------------------------------------------------------------- /examples/get_info.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | """ 29 | Connects to each attached FIDO device, and: 30 | 1. If the device supports CBOR commands, perform a getInfo command. 31 | 2. If the device supports WINK, perform the wink command. 32 | """ 33 | 34 | from fido2.ctap2 import Ctap2 35 | from fido2.hid import CAPABILITY, CtapHidDevice 36 | 37 | try: 38 | from fido2.pcsc import CtapPcscDevice 39 | except ImportError: 40 | CtapPcscDevice = None 41 | 42 | 43 | def enumerate_devices(): 44 | for dev in CtapHidDevice.list_devices(): 45 | yield dev 46 | if CtapPcscDevice: 47 | for dev in CtapPcscDevice.list_devices(): 48 | yield dev 49 | 50 | 51 | for dev in enumerate_devices(): 52 | print("CONNECT: %s" % dev) 53 | print("Product name: %s" % dev.product_name) 54 | print("Serial number: %s" % dev.serial_number) 55 | print("CTAPHID protocol version: %d" % dev.version) 56 | 57 | if dev.capabilities & CAPABILITY.CBOR: 58 | ctap2 = Ctap2(dev) 59 | info = ctap2.get_info() 60 | print("DEVICE INFO: %s" % info) 61 | else: 62 | print("Device does not support CBOR") 63 | 64 | if dev.capabilities & CAPABILITY.WINK: 65 | dev.wink() 66 | print("WINK sent!") 67 | else: 68 | print("Device does not support WINK") 69 | 70 | dev.close() 71 | -------------------------------------------------------------------------------- /fido2/attestation/u2f.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from __future__ import annotations 29 | 30 | from cryptography import x509 31 | from cryptography.exceptions import InvalidSignature as _InvalidSignature 32 | from cryptography.hazmat.backends import default_backend 33 | 34 | from ..cose import ES256 35 | from .base import ( 36 | Attestation, 37 | AttestationResult, 38 | AttestationType, 39 | InvalidSignature, 40 | catch_builtins, 41 | ) 42 | 43 | 44 | class FidoU2FAttestation(Attestation): 45 | FORMAT = "fido-u2f" 46 | 47 | @catch_builtins 48 | def verify(self, statement, auth_data, client_data_hash): 49 | cd = auth_data.credential_data 50 | assert cd is not None # noqa: S101 51 | pk = b"\x04" + cd.public_key[-2] + cd.public_key[-3] 52 | x5c = statement["x5c"] 53 | FidoU2FAttestation.verify_signature( 54 | auth_data.rp_id_hash, 55 | client_data_hash, 56 | cd.credential_id, 57 | pk, 58 | x5c[0], 59 | statement["sig"], 60 | ) 61 | return AttestationResult(AttestationType.BASIC, x5c) 62 | 63 | @staticmethod 64 | def verify_signature( 65 | app_param, client_param, key_handle, public_key, cert_bytes, signature 66 | ): 67 | m = b"\0" + app_param + client_param + key_handle + public_key 68 | cert = x509.load_der_x509_certificate(cert_bytes, default_backend()) 69 | try: 70 | ES256.from_cryptography_key(cert.public_key()).verify(m, signature) 71 | except _InvalidSignature: 72 | raise InvalidSignature() 73 | -------------------------------------------------------------------------------- /tests/device/test_clientpin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fido2.ctap import CtapError 4 | from fido2.ctap2.pin import ClientPin 5 | 6 | from . import TEST_PIN 7 | 8 | 9 | @pytest.fixture(autouse=True, scope="module") 10 | def preconditions(dev_manager): 11 | if not ClientPin.is_supported(dev_manager.info): 12 | pytest.skip("ClientPin not supported by authenticator") 13 | 14 | 15 | @pytest.fixture 16 | def client_pin(ctap2, pin_protocol): 17 | return ClientPin(ctap2, pin_protocol) 18 | 19 | 20 | def test_pin_validation(dev_manager, client_pin): 21 | assert dev_manager.ctap2.get_info().options["clientPin"] is True 22 | assert client_pin.get_pin_retries()[0] == 8 23 | 24 | # Wrong PIN decreases the retries remaining 25 | for retries in range(7, 4, -1): 26 | # Third attempt uses AUTH_BLOCKED 27 | with pytest.raises(CtapError, match="PIN_(INVALID|AUTH_BLOCKED)"): 28 | client_pin.get_pin_token("123456") 29 | assert client_pin.get_pin_retries()[0] == retries 30 | 31 | # Now soft-locked, does not decrement or unlock with any PIN 32 | for pin in (TEST_PIN, "123456"): 33 | with pytest.raises(CtapError, match="PIN_AUTH_BLOCKED"): 34 | client_pin.get_pin_token(pin) 35 | assert client_pin.get_pin_retries()[0] == retries 36 | 37 | dev_manager.reconnect() 38 | client_pin = ClientPin(dev_manager.ctap2, client_pin.protocol) 39 | 40 | # Wrong PIN decreases the retries remaining again 41 | with pytest.raises(CtapError, match="PIN_INVALID"): 42 | client_pin.get_pin_token("123456") 43 | assert client_pin.get_pin_retries()[0] == retries - 1 44 | 45 | # Unlocks with correct PIN 46 | token = client_pin.get_pin_token(TEST_PIN) 47 | assert client_pin.get_pin_retries()[0] == 8 48 | assert token 49 | 50 | 51 | def test_change_pin(client_pin): 52 | client_pin.get_pin_token(TEST_PIN) 53 | 54 | new_pin = TEST_PIN[::-1] 55 | 56 | client_pin.change_pin(TEST_PIN, new_pin) 57 | with pytest.raises(CtapError, match="PIN_INVALID"): 58 | client_pin.get_pin_token(TEST_PIN) 59 | 60 | client_pin.get_pin_token(new_pin) 61 | 62 | client_pin.change_pin(new_pin, TEST_PIN) 63 | client_pin.get_pin_token(TEST_PIN) 64 | 65 | 66 | def test_set_and_reset(dev_manager, client_pin, factory_reset): 67 | assert dev_manager.ctap2.get_info().options["clientPin"] is True 68 | assert client_pin.get_pin_retries()[0] == 8 69 | 70 | factory_reset() 71 | client_pin = ClientPin(dev_manager.ctap2, client_pin.protocol) 72 | # Factory reset clears the PIN 73 | assert dev_manager.ctap2.get_info().options["clientPin"] is False 74 | with pytest.raises(CtapError, match="PIN_NOT_SET"): 75 | client_pin.get_pin_retries() 76 | 77 | # Setup includes setting the default PIN. More correct would be to just set 78 | # the PIN ourselves here and test that, but then we need another factory reset 79 | dev_manager.setup() 80 | assert dev_manager.ctap2.get_info().options["clientPin"] is True 81 | assert client_pin.get_pin_retries()[0] == 8 82 | -------------------------------------------------------------------------------- /tests/test_tpm.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | import unittest 29 | 30 | from fido2.attestation.tpm import TpmAttestationFormat, TpmPublicFormat 31 | 32 | 33 | class TestTpmObject(unittest.TestCase): 34 | def test_parse_tpm(self): 35 | data = bytes.fromhex( 36 | "ff54434780170022000b68cec627cc6411099a1f809fde4379f649aa170c7072d1adf230de439efc80810014f7c8b0cdeb31328648130a19733d6fff16e76e1300000003ef605603446ed8c56aa7608d01a6ea5651ee67a8a20022000bdf681917e18529c61e1b85a1e7952f3201eb59c609ed5d8e217e5de76b228bbd0022000b0a10d216b0c3ab82bfdc1f0a016ab9493384c7aee1937ee8800f76b30c9b71a7" # noqa 37 | ) 38 | 39 | tpm = TpmAttestationFormat.parse(data) 40 | self.assertEqual( 41 | tpm.data, bytes.fromhex("f7c8b0cdeb31328648130a19733d6fff16e76e13") 42 | ) 43 | 44 | def test_parse_too_short_of_a_tpm(self): 45 | with self.assertRaises(ValueError): 46 | TpmAttestationFormat.parse(bytes.fromhex("ff5443")) 47 | with self.assertRaises(ValueError) as e: 48 | data = bytes.fromhex( 49 | "ff54434780170022000b68cec627cc6411099a1f809fde4379f649aa170c7072d1adf230de439efc80810014f7c8b0cdeb31328648" # noqa 50 | ) 51 | TpmAttestationFormat.parse(data) 52 | self.assertEqual( 53 | e.exception.args[0], "Not enough data to read (need: 20, had: 9)." 54 | ) 55 | 56 | def test_parse_public_ecc(self): 57 | data = bytes.fromhex( 58 | "0023000b00060472000000100010000300100020b9174cd199f77552afcffe6b1f069c032ffdc4f56068dec4e189e7967b3bf6b0002037bf8aa7d93fddb9507319141c6fa31c8e48a1c6da013603a9f6e3913d157c66" # noqa 59 | ) 60 | TpmPublicFormat.parse(data) 61 | -------------------------------------------------------------------------------- /fido2/features.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | 29 | import warnings 30 | 31 | 32 | class FeatureNotEnabledError(Exception): 33 | pass 34 | 35 | 36 | class _Feature: 37 | def __init__(self, name: str, desc: str): 38 | self._enabled: bool | None = None 39 | self._name = name 40 | self._desc = desc 41 | 42 | @property 43 | def enabled(self) -> bool: 44 | self.warn() 45 | return self._enabled is True 46 | 47 | @enabled.setter 48 | def enabled(self, value: bool) -> None: 49 | if self._enabled is not None: 50 | raise ValueError( 51 | f"{self._name} has already been configured with {self._enabled}" 52 | ) 53 | self._enabled = value 54 | 55 | def require(self, state=True) -> None: 56 | if self._enabled != state: 57 | self.warn() 58 | raise FeatureNotEnabledError( 59 | f"Usage requires {self._name}.enabled = {state}" 60 | ) 61 | 62 | def warn(self) -> None: 63 | if self._enabled is None: 64 | warnings.warn( 65 | f"""Deprecated use of {self._name}. 66 | 67 | You are using deprecated functionality which will change in the next major version of 68 | python-fido2. You can opt-in to use the new functionality now by adding the following 69 | to your code somewhere where it gets executed prior to using the affected functionality: 70 | 71 | import fido2.features 72 | fido2.features.{self._name}.enabled = True 73 | 74 | To silence this warning but retain the current behavior, instead set enabled to False: 75 | fido2.features.{self._name}.enabled = False 76 | 77 | {self._desc} 78 | """, 79 | DeprecationWarning, 80 | ) 81 | -------------------------------------------------------------------------------- /tests/test_pcsc.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from unittest import mock 29 | 30 | import pytest 31 | 32 | from fido2.hid import CTAPHID 33 | 34 | 35 | @pytest.fixture(autouse=True, scope="module") 36 | def preconditions(): 37 | global CtapPcscDevice 38 | try: 39 | from fido2.pcsc import CtapPcscDevice 40 | except ImportError: 41 | pytest.skip("pyscard is not installed") 42 | 43 | 44 | def test_pcsc_call_cbor(): 45 | connection = mock.Mock() 46 | connection.transmit.side_effect = [(b"U2F_V2", 0x90, 0x00), (b"", 0x90, 0x00)] 47 | 48 | CtapPcscDevice(connection, "Mock") 49 | 50 | connection.transmit.assert_called_with( 51 | [0x80, 0x10, 0x80, 0x00, 0x01, 0x04, 0x00], None 52 | ) 53 | 54 | 55 | def test_pcsc_call_u2f(): 56 | connection = mock.Mock() 57 | connection.transmit.side_effect = [ 58 | (b"U2F_V2", 0x90, 0x00), 59 | (b"", 0x90, 0x00), 60 | (b"u2f_resp", 0x90, 0x00), 61 | ] 62 | 63 | dev = CtapPcscDevice(connection, "Mock") 64 | res = dev.call(CTAPHID.MSG, b"\x00\x01\x00\x00\x05" + b"\x01" * 5 + b"\x00") 65 | 66 | connection.transmit.assert_called_with( 67 | [0x00, 0x01, 0x00, 0x00, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00], None 68 | ) 69 | assert res == b"u2f_resp\x90\x00" 70 | 71 | 72 | def test_pcsc_call_version_2(): 73 | connection = mock.Mock() 74 | connection.transmit.side_effect = [(b"U2F_V2", 0x90, 0x00), (b"", 0x90, 0x00)] 75 | 76 | dev = CtapPcscDevice(connection, "Mock") 77 | 78 | assert dev.version == 2 79 | 80 | 81 | def test_pcsc_call_version_1(): 82 | connection = mock.Mock() 83 | connection.transmit.side_effect = [(b"U2F_V2", 0x90, 0x00), (b"", 0x63, 0x85)] 84 | 85 | dev = CtapPcscDevice(connection, "Mock") 86 | 87 | assert dev.version == 1 88 | -------------------------------------------------------------------------------- /examples/bio_enrollment.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | """ 29 | Connects to the first FIDO device found over USB, and attempts to enroll a new 30 | fingerprint. This requires that a PIN is already set. 31 | 32 | NOTE: This uses a draft bio enrollment specification which is not yet final. 33 | Consider this highly experimental. 34 | """ 35 | 36 | import sys 37 | from getpass import getpass 38 | 39 | from fido2.ctap2 import CaptureError, Ctap2, FPBioEnrollment 40 | from fido2.ctap2.bio import BioEnrollment 41 | from fido2.ctap2.pin import ClientPin 42 | from fido2.hid import CtapHidDevice 43 | 44 | pin = None 45 | uv = "discouraged" 46 | 47 | for dev in CtapHidDevice.list_devices(): 48 | try: 49 | ctap = Ctap2(dev) 50 | if BioEnrollment.is_supported(ctap.info): 51 | break 52 | except Exception: # noqa: S112 53 | continue 54 | else: 55 | print("No Authenticator supporting bioEnroll found") 56 | sys.exit(1) 57 | 58 | if not ctap.info.options.get("clientPin"): 59 | print("PIN not set for the device!") 60 | sys.exit(1) 61 | 62 | # Authenticate with PIN 63 | print("Preparing to enroll a new fingerprint.") 64 | pin = getpass("Please enter PIN: ") 65 | client_pin = ClientPin(ctap) 66 | pin_token = client_pin.get_pin_token(pin, ClientPin.PERMISSION.BIO_ENROLL) 67 | bio = FPBioEnrollment(ctap, client_pin.protocol, pin_token) 68 | 69 | print(bio.enumerate_enrollments()) 70 | 71 | # Start enrollment 72 | enroller = bio.enroll() 73 | template_id = None 74 | while template_id is None: 75 | print("Press your fingerprint against the sensor now...") 76 | try: 77 | template_id = enroller.capture() 78 | print(enroller.remaining, "more scans needed.") 79 | except CaptureError as e: 80 | print(e) 81 | bio.set_name(template_id, "Example") 82 | 83 | print("Fingerprint registered successfully with ID:", template_id) 84 | -------------------------------------------------------------------------------- /fido2/rpid.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | """ 29 | These functions validate RP_ID and APP_ID according to simplified TLD+1 rules, 30 | using a bundled copy of the public suffix list fetched from: 31 | 32 | https://publicsuffix.org/list/public_suffix_list.dat 33 | 34 | Advanced APP_ID values pointing to JSON files containing valid facets are not 35 | supported by this implementation. 36 | """ 37 | 38 | from __future__ import annotations 39 | 40 | import os 41 | from urllib.parse import urlparse 42 | 43 | tld_fname = os.path.join(os.path.dirname(__file__), "public_suffix_list.dat") 44 | with open(tld_fname, "rb") as f: 45 | suffixes = [ 46 | entry 47 | for entry in (line.decode("utf8").strip() for line in f.readlines()) 48 | if entry and not entry.startswith("//") 49 | ] 50 | 51 | 52 | def verify_rp_id(rp_id: str, origin: str) -> bool: 53 | """Checks if a Webauthn RP ID is usable for a given origin. 54 | 55 | :param rp_id: The RP ID to validate. 56 | :param origin: The origin of the request. 57 | :return: True if the RP ID is usable by the origin, False if not. 58 | """ 59 | if not rp_id: 60 | return False 61 | 62 | url = urlparse(origin) 63 | host = url.hostname 64 | # Note that Webauthn requires a secure context, i.e. an origin with https scheme. 65 | # However, most browsers also treat http://localhost as a secure context. See 66 | # https://groups.google.com/a/chromium.org/g/blink-dev/c/RC9dSw-O3fE/m/E3_0XaT0BAAJ 67 | if ( 68 | url.scheme != "https" 69 | and (url.scheme, host) != ("http", "localhost") 70 | and not (url.scheme == "http" and host and host.endswith(".localhost")) 71 | ): 72 | return False 73 | if host == rp_id: 74 | return True 75 | if host and host.endswith("." + rp_id) and rp_id not in suffixes: 76 | return True 77 | return False 78 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from fido2.server import Fido2Server 4 | from fido2.utils import websafe_encode 5 | from fido2.webauthn import ( 6 | AttestedCredentialData, 7 | AuthenticationResponse, 8 | AuthenticatorAssertionResponse, 9 | AuthenticatorData, 10 | CollectedClientData, 11 | PublicKeyCredentialRpEntity, 12 | UserVerificationRequirement, 13 | ) 14 | 15 | from .test_ctap2 import _ATT_CRED_DATA, _CRED_ID 16 | 17 | 18 | class TestPublicKeyCredentialRpEntity(unittest.TestCase): 19 | def test_id_hash(self): 20 | rp = PublicKeyCredentialRpEntity(name="Example", id="example.com") 21 | rp_id_hash = ( 22 | b"\xa3y\xa6\xf6\xee\xaf\xb9\xa5^7\x8c\x11\x804\xe2u\x1eh/" 23 | b"\xab\x9f-0\xab\x13\xd2\x12U\x86\xce\x19G" 24 | ) 25 | self.assertEqual(rp.id_hash, rp_id_hash) 26 | 27 | 28 | USER = {"id": b"user_id", "name": "A. User"} 29 | 30 | 31 | class TestFido2Server(unittest.TestCase): 32 | def test_register_begin_rp(self): 33 | rp = PublicKeyCredentialRpEntity(name="Example", id="example.com") 34 | server = Fido2Server(rp) 35 | 36 | request, state = server.register_begin(USER) 37 | 38 | self.assertEqual( 39 | request["publicKey"]["rp"], {"id": "example.com", "name": "Example"} 40 | ) 41 | 42 | def test_register_begin_custom_challenge(self): 43 | rp = PublicKeyCredentialRpEntity(name="Example", id="example.com") 44 | server = Fido2Server(rp) 45 | 46 | challenge = b"1234567890123456" 47 | request, state = server.register_begin(USER, challenge=challenge) 48 | 49 | self.assertEqual(request["publicKey"]["challenge"], websafe_encode(challenge)) 50 | 51 | def test_register_begin_custom_challenge_too_short(self): 52 | rp = PublicKeyCredentialRpEntity(name="Example", id="example.com") 53 | server = Fido2Server(rp) 54 | 55 | challenge = b"123456789012345" 56 | with self.assertRaises(ValueError): 57 | request, state = server.register_begin(USER, challenge=challenge) 58 | 59 | def test_authenticate_complete_invalid_signature(self): 60 | rp = PublicKeyCredentialRpEntity(name="Example", id="example.com") 61 | server = Fido2Server(rp) 62 | 63 | state = { 64 | "challenge": "GAZPACHO!", 65 | "user_verification": UserVerificationRequirement.PREFERRED, 66 | } 67 | client_data = CollectedClientData.create( 68 | CollectedClientData.TYPE.GET, 69 | "GAZPACHO!", 70 | "https://example.com", 71 | ) 72 | _AUTH_DATA = bytes.fromhex( 73 | "A379A6F6EEAFB9A55E378C118034E2751E682FAB9F2D30AB13D2125586CE1947010000001D" 74 | ) 75 | response = AuthenticationResponse( 76 | raw_id=_CRED_ID, 77 | response=AuthenticatorAssertionResponse( 78 | client_data=client_data, 79 | authenticator_data=AuthenticatorData(_AUTH_DATA), 80 | signature=b"INVALID", 81 | ), 82 | ) 83 | 84 | with self.assertRaisesRegex(ValueError, "Invalid signature."): 85 | server.authenticate_complete( 86 | state, 87 | [AttestedCredentialData(_ATT_CRED_DATA)], 88 | response, 89 | ) 90 | -------------------------------------------------------------------------------- /fido2/attestation/android.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from __future__ import annotations 29 | 30 | import json 31 | 32 | from cryptography import x509 33 | from cryptography.hazmat.backends import default_backend 34 | from cryptography.hazmat.primitives.constant_time import bytes_eq 35 | 36 | from ..cose import CoseKey 37 | from ..utils import sha256, websafe_decode 38 | from .base import ( 39 | Attestation, 40 | AttestationResult, 41 | AttestationType, 42 | InvalidData, 43 | catch_builtins, 44 | ) 45 | 46 | 47 | class AndroidSafetynetAttestation(Attestation): 48 | FORMAT = "android-safetynet" 49 | 50 | def __init__(self, allow_rooted: bool = False): 51 | self.allow_rooted = allow_rooted 52 | 53 | @catch_builtins 54 | def verify(self, statement, auth_data, client_data_hash): 55 | jwt = statement["response"] 56 | header, payload, sig = (websafe_decode(x) for x in jwt.split(b".")) 57 | data = json.loads(payload.decode("utf8")) 58 | if not self.allow_rooted and data["ctsProfileMatch"] is not True: 59 | raise InvalidData("ctsProfileMatch must be true!") 60 | expected_nonce = sha256(auth_data + client_data_hash) 61 | if not bytes_eq(expected_nonce, websafe_decode(data["nonce"])): 62 | raise InvalidData("Nonce does not match!") 63 | 64 | data = json.loads(header.decode("utf8")) 65 | x5c = [websafe_decode(x) for x in data["x5c"]] 66 | cert = x509.load_der_x509_certificate(x5c[0], default_backend()) 67 | 68 | cn = cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME) 69 | if cn[0].value != "attest.android.com": 70 | raise InvalidData("Certificate not issued to attest.android.com!") 71 | 72 | CoseKey.for_name(data["alg"]).from_cryptography_key(cert.public_key()).verify( 73 | jwt.rsplit(b".", 1)[0], sig 74 | ) 75 | return AttestationResult(AttestationType.BASIC, x5c) 76 | -------------------------------------------------------------------------------- /tests/test_rpid.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | # Copyright (c) 2013 Yubico AB 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or 7 | # without modification, are permitted provided that the following 8 | # conditions are met: 9 | # 10 | # 1. Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # 2. Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following 14 | # disclaimer in the documentation and/or other materials provided 15 | # with the distribution. 16 | # 17 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 20 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 21 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 22 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 23 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 26 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 27 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 28 | # POSSIBILITY OF SUCH DAMAGE. 29 | 30 | import unittest 31 | 32 | from fido2.rpid import verify_rp_id 33 | 34 | 35 | class TestRpId(unittest.TestCase): 36 | def test_valid_ids(self): 37 | self.assertTrue(verify_rp_id("example.com", "https://register.example.com")) 38 | self.assertTrue(verify_rp_id("example.com", "https://fido.example.com")) 39 | self.assertTrue(verify_rp_id("example.com", "https://www.example.com:444")) 40 | 41 | def test_invalid_ids(self): 42 | self.assertFalse(verify_rp_id("example.com", "http://example.com")) 43 | self.assertFalse(verify_rp_id("example.com", "http://www.example.com")) 44 | self.assertFalse(verify_rp_id("example.com", "https://example-test.com")) 45 | 46 | self.assertFalse( 47 | verify_rp_id("companyA.hosting.example.com", "https://register.example.com") 48 | ) 49 | self.assertFalse( 50 | verify_rp_id( 51 | "companyA.hosting.example.com", "https://companyB.hosting.example.com" 52 | ) 53 | ) 54 | 55 | def test_suffix_list(self): 56 | self.assertFalse(verify_rp_id("co.uk", "https://foobar.co.uk")) 57 | self.assertTrue(verify_rp_id("foobar.co.uk", "https://site.foobar.co.uk")) 58 | self.assertFalse(verify_rp_id("appspot.com", "https://example.appspot.com")) 59 | self.assertTrue( 60 | verify_rp_id("example.appspot.com", "https://example.appspot.com") 61 | ) 62 | 63 | def test_localhost_http_secure_context(self): 64 | # Localhost and subdomains are secure contexts in most browsers 65 | self.assertTrue(verify_rp_id("localhost", "http://localhost")) 66 | self.assertTrue(verify_rp_id("localhost", "http://example.localhost")) 67 | self.assertTrue(verify_rp_id("example.localhost", "http://example.localhost")) 68 | self.assertTrue(verify_rp_id("localhost", "http://localhost:8000")) 69 | self.assertFalse(verify_rp_id("localhost", "http://")) 70 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | os: [ubuntu-latest, windows-latest, macos-latest] 13 | python: ['3.10', '3.11', '3.12', '3.13', '3.14', 'pypy3.10'] 14 | architecture: [x86, x64] 15 | exclude: 16 | - os: ubuntu-latest 17 | architecture: x86 18 | - os: macos-latest 19 | architecture: x86 20 | - os: windows-latest 21 | python: pypy3 22 | - os: macos-latest 23 | python: pypy3 24 | - os: macos-latest 25 | python: 3.10 26 | 27 | name: ${{ matrix.os }} Py ${{ matrix.python }} ${{ matrix.architecture }} 28 | steps: 29 | - uses: actions/checkout@v6 30 | 31 | - name: Install uv 32 | uses: astral-sh/setup-uv@v7 33 | with: 34 | enable-cache: false 35 | 36 | - name: Set up Python 37 | uses: actions/setup-python@v6 38 | with: 39 | python-version: ${{ matrix.python }} 40 | architecture: ${{ matrix.architecture }} 41 | 42 | - name: Install dependencies 43 | if: "startsWith(matrix.os, 'ubuntu')" 44 | run: | 45 | sudo apt-get install -qq swig libpcsclite-dev 46 | 47 | - name: Install the project 48 | run: uv sync --all-extras 49 | 50 | - name: Run pre-commit 51 | if: "!startsWith(matrix.python, 'pypy')" 52 | shell: bash 53 | run: | 54 | uv tool install pre-commit 55 | echo $(python --version) | grep -q "Python 3.10" && export SKIP=pyright 56 | pre-commit run --all-files 57 | 58 | - name: Run unit tests 59 | run: uv run pytest --no-device 60 | 61 | build: 62 | #needs: test 63 | runs-on: ubuntu-latest 64 | name: Build Python source .tar.gz 65 | 66 | steps: 67 | - uses: actions/checkout@v4 68 | 69 | - name: Install uv 70 | uses: astral-sh/setup-uv@v7 71 | with: 72 | enable-cache: false 73 | 74 | - name: Set up Python 75 | uses: actions/setup-python@v6 76 | with: 77 | python-version: 3.x 78 | 79 | - name: Build source package 80 | run: | 81 | # poetry will by default set all timestamps to 0, which Debian doesn't allow 82 | export SOURCE_DATE_EPOCH=$(git show --no-patch --format=%ct) 83 | uv build 84 | 85 | - name: Upload source package 86 | uses: actions/upload-artifact@v5 87 | with: 88 | name: fido2-python-sdist 89 | path: dist 90 | 91 | docs: 92 | runs-on: ubuntu-latest 93 | name: Build sphinx documentation 94 | 95 | steps: 96 | - uses: actions/checkout@v6 97 | 98 | - name: Install uv 99 | uses: astral-sh/setup-uv@v7 100 | with: 101 | enable-cache: false 102 | 103 | - name: Set up Python 104 | uses: actions/setup-python@v6 105 | with: 106 | python-version: 3.14 107 | 108 | - name: Build sphinx documentation 109 | run: uv run make -C docs/ html 110 | 111 | - name: Upload documentation 112 | uses: actions/upload-artifact@v5 113 | with: 114 | name: python-fido2-docs 115 | path: docs/_build/html 116 | -------------------------------------------------------------------------------- /tests/device/test_bioenroll.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fido2.client import ClientError, DefaultClientDataCollector, Fido2Client 4 | from fido2.ctap import CtapError 5 | from fido2.ctap2.bio import BioEnrollment, CaptureError, FPBioEnrollment 6 | from fido2.ctap2.pin import ClientPin 7 | from fido2.server import Fido2Server 8 | 9 | from . import TEST_PIN, CliInteraction 10 | 11 | 12 | @pytest.fixture(autouse=True, scope="module") 13 | def preconditions(dev_manager): 14 | if not BioEnrollment.is_supported(dev_manager.info): 15 | pytest.skip("BioEnrollment not supported by authenticator") 16 | if dev_manager.info.options["uv"]: 17 | pytest.skip("UV already configured") 18 | 19 | 20 | def get_bio(ctap2, pin_protocol=None, permissions=ClientPin.PERMISSION.BIO_ENROLL): 21 | if pin_protocol: 22 | token = ClientPin(ctap2, pin_protocol).get_pin_token(TEST_PIN, permissions) 23 | else: 24 | token = None 25 | return FPBioEnrollment(ctap2, pin_protocol, token) 26 | 27 | 28 | def test_get_sensor_info(ctap2): 29 | bio = get_bio(ctap2) 30 | info = bio.get_fingerprint_sensor_info() 31 | assert info.get(2) in (1, None) 32 | assert info.get(3, 1) > 0 33 | assert info.get(8, 1) > 0 34 | 35 | 36 | def test_enroll_use_delete(device, ctap2, pin_protocol, printer): 37 | bio = get_bio(ctap2, pin_protocol) 38 | assert len(bio.enumerate_enrollments()) == 0 39 | 40 | context = bio.enroll() 41 | template_id = None 42 | while template_id is None: 43 | printer.print("Press your fingerprint against the sensor now...") 44 | try: 45 | template_id = context.capture() 46 | printer.print(f"{context.remaining} more scans needed.") 47 | except CaptureError as e: 48 | printer.print(e) 49 | 50 | enrollments = bio.enumerate_enrollments() 51 | assert len(enrollments) == 1 52 | assert enrollments[template_id] in ("", None) 53 | 54 | # Test name/rename 55 | info = bio.get_fingerprint_sensor_info() 56 | fname = "Test 1" 57 | bio.set_name(template_id, fname) 58 | 59 | enrollments = bio.enumerate_enrollments() 60 | assert len(enrollments) == 1 61 | assert enrollments[template_id] == fname 62 | 63 | fname = "Test".ljust(info.get(8, 0), "!") 64 | bio.set_name(template_id, fname) 65 | enrollments = bio.enumerate_enrollments() 66 | assert len(enrollments) == 1 67 | assert enrollments[template_id] == fname 68 | 69 | # Create a credential using fingerprint 70 | rp = {"id": "example.com", "name": "Example RP"} 71 | server = Fido2Server(rp) 72 | user = {"id": b"user_id", "name": "A. User"} 73 | create_options, state = server.register_begin(user, user_verification="required") 74 | 75 | client = Fido2Client( 76 | device, 77 | client_data_collector=DefaultClientDataCollector("https://example.com"), 78 | user_interaction=CliInteraction(printer, "WrongPin"), 79 | ) 80 | 81 | # Allow multiple attempts 82 | for _ in range(3): 83 | try: 84 | result = client.make_credential(create_options.public_key) 85 | break 86 | except ClientError as e: 87 | if e.cause.code == CtapError.ERR.UV_INVALID: 88 | continue 89 | raise 90 | 91 | server.register_complete(state, result) 92 | 93 | # Delete fingerprint 94 | bio = get_bio(ctap2, pin_protocol) 95 | bio.remove_enrollment(template_id) 96 | assert len(bio.enumerate_enrollments()) == 0 97 | -------------------------------------------------------------------------------- /examples/credential.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | """ 29 | Connects to the first FIDO device found (starts from USB, then looks into NFC), 30 | creates a new credential for it, and authenticates the credential. 31 | This works with both FIDO 2.0 devices as well as with U2F devices. 32 | On Windows, the native WebAuthn API will be used. 33 | """ 34 | 35 | from exampleutils import get_client 36 | 37 | from fido2.server import Fido2Server 38 | 39 | # Locate a suitable FIDO authenticator 40 | client, info = get_client() 41 | 42 | 43 | # Prefer UV if supported and configured 44 | if info and (info.options.get("uv") or info.options.get("bioEnroll")): 45 | uv = "preferred" 46 | print("Authenticator supports User Verification") 47 | else: 48 | uv = "discouraged" 49 | 50 | 51 | server = Fido2Server({"id": "example.com", "name": "Example RP"}, attestation="direct") 52 | 53 | user = {"id": b"user_id", "name": "A. User"} 54 | 55 | 56 | # Prepare parameters for makeCredential 57 | create_options, state = server.register_begin( 58 | user, user_verification=uv, authenticator_attachment="cross-platform" 59 | ) 60 | 61 | # Create a credential 62 | result = client.make_credential(create_options["publicKey"]) 63 | 64 | # Complete registration 65 | auth_data = server.register_complete(state, result) 66 | credentials = [auth_data.credential_data] 67 | 68 | print("New credential created!") 69 | response = result.response 70 | 71 | print("CLIENT DATA:", response.client_data) 72 | print("ATTESTATION OBJECT:", response.attestation_object) 73 | print() 74 | print("CREDENTIAL DATA:", auth_data.credential_data) 75 | 76 | 77 | # Prepare parameters for getAssertion 78 | request_options, state = server.authenticate_begin(credentials, user_verification=uv) 79 | 80 | # Authenticate the credential 81 | results = client.get_assertion(request_options["publicKey"]) 82 | 83 | # Only one cred in allowCredentials, only one response. 84 | result = results.get_response(0) 85 | 86 | # Complete authenticator 87 | server.authenticate_complete(state, credentials, result) 88 | 89 | print("Credential authenticated!") 90 | response = result.response 91 | 92 | print("CLIENT DATA:", response.client_data) 93 | print() 94 | print("AUTH DATA:", response.authenticator_data) 95 | -------------------------------------------------------------------------------- /fido2/hid/linux.py: -------------------------------------------------------------------------------- 1 | # Original work Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # Modified work Copyright 2020 Yubico AB. All Rights Reserved. 16 | # This file, with modifications, is licensed under the above Apache License. 17 | 18 | from __future__ import annotations 19 | 20 | import fcntl 21 | import glob 22 | import logging 23 | import struct 24 | import sys 25 | from array import array 26 | 27 | from .base import FileCtapHidConnection, HidDescriptor, parse_report_descriptor 28 | 29 | # Don't typecheck this file on Windows 30 | assert sys.platform != "win32" # noqa: S101 31 | 32 | logger = logging.getLogger(__name__) 33 | 34 | # hidraw.h 35 | HIDIOCGRAWINFO = 0x80084803 36 | HIDIOCGRDESCSIZE = 0x80044801 37 | HIDIOCGRDESC = 0x90044802 38 | HIDIOCGRAWNAME = 0x90044804 39 | HIDIOCGRAWUNIQ = 0x90044808 40 | 41 | 42 | class LinuxCtapHidConnection(FileCtapHidConnection): 43 | def write_packet(self, data): 44 | # Prepend the report ID 45 | super().write_packet(b"\0" + data) 46 | 47 | 48 | def open_connection(descriptor): 49 | return LinuxCtapHidConnection(descriptor) 50 | 51 | 52 | def get_descriptor(path): 53 | with open(path, "rb") as f: 54 | # Read VID, PID 55 | buf = array("B", [0] * (4 + 2 + 2)) 56 | fcntl.ioctl(f, HIDIOCGRAWINFO, buf, True) 57 | _, vid, pid = struct.unpack(" 1 else None 63 | 64 | # Read unique ID 65 | try: 66 | buf = array("B", [0] * 64) 67 | length = fcntl.ioctl(f, HIDIOCGRAWUNIQ, buf, True) 68 | serial = ( 69 | bytearray(buf[: (length - 1)]).decode("utf-8") if length > 1 else None 70 | ) 71 | except OSError: 72 | serial = None 73 | 74 | # Read report descriptor 75 | buf = array("B", [0] * 4) 76 | fcntl.ioctl(f, HIDIOCGRDESCSIZE, buf, True) 77 | size = struct.unpack(" bytes: 53 | """Reads a CTAP HID packet""" 54 | 55 | @abc.abstractmethod 56 | def write_packet(self, data: bytes) -> None: 57 | """Writes a CTAP HID packet""" 58 | 59 | @abc.abstractmethod 60 | def close(self) -> None: 61 | """Closes the connection""" 62 | 63 | 64 | class FileCtapHidConnection(CtapHidConnection): 65 | """Basic CtapHidConnection implementation which uses a path to a file descriptor""" 66 | 67 | def __init__(self, descriptor): 68 | self.handle = os.open(descriptor.path, os.O_RDWR) 69 | self.descriptor = descriptor 70 | 71 | def close(self): 72 | os.close(self.handle) 73 | 74 | def write_packet(self, data): 75 | if os.write(self.handle, data) != len(data): 76 | raise OSError("failed to write entire packet") 77 | 78 | def read_packet(self): 79 | return os.read(self.handle, self.descriptor.report_size_in) 80 | 81 | 82 | REPORT_DESCRIPTOR_KEY_MASK = 0xFC 83 | SIZE_MASK = ~REPORT_DESCRIPTOR_KEY_MASK 84 | OUTPUT_ITEM = 0x90 85 | INPUT_ITEM = 0x80 86 | COLLECTION_ITEM = 0xA0 87 | REPORT_COUNT = 0x94 88 | REPORT_SIZE = 0x74 89 | USAGE_PAGE = 0x04 90 | USAGE = 0x08 91 | 92 | 93 | def parse_report_descriptor(data: bytes) -> tuple[int, int]: 94 | # Parse report descriptor data 95 | usage, usage_page = None, None 96 | max_input_size, max_output_size = None, None 97 | report_count, report_size = None, None 98 | remaining = 4 99 | while data and remaining: 100 | head, data = struct.unpack_from(">B", data)[0], data[1:] 101 | key, size = REPORT_DESCRIPTOR_KEY_MASK & head, SIZE_MASK & head 102 | value = struct.unpack_from(" bool: 63 | return info.options.get("authnrCfg") is True 64 | 65 | def __init__( 66 | self, 67 | ctap: Ctap2, 68 | pin_uv_protocol: PinProtocol | None = None, 69 | pin_uv_token: bytes | None = None, 70 | ): 71 | if not self.is_supported(ctap.info): 72 | raise ValueError("Authenticator does not support Config") 73 | 74 | self.ctap = ctap 75 | self.pin_uv = ( 76 | _PinUv(pin_uv_protocol, pin_uv_token) 77 | if pin_uv_protocol and pin_uv_token 78 | else None 79 | ) 80 | self._subcommands = self.ctap.info.authenticator_config_commands 81 | 82 | def _call(self, sub_cmd, params=None): 83 | if self._subcommands is not None and sub_cmd not in self._subcommands: 84 | raise ValueError(f"Config command {sub_cmd} not supported by Authenticator") 85 | 86 | if self.pin_uv: 87 | msg = b"\xff" * 32 + b"\x0d" + struct.pack(" None: 98 | """Enables Enterprise Attestation. 99 | 100 | If already enabled, this command is ignored. 101 | """ 102 | self._call(Config.CMD.ENABLE_ENTERPRISE_ATT) 103 | 104 | def toggle_always_uv(self) -> None: 105 | """Toggle the alwaysUV setting. 106 | 107 | When true, the Authenticator always requires UV for credential assertion. 108 | """ 109 | self._call(Config.CMD.TOGGLE_ALWAYS_UV) 110 | 111 | def set_min_pin_length( 112 | self, 113 | min_pin_length: int | None = None, 114 | rp_ids: list[str] | None = None, 115 | force_change_pin: bool = False, 116 | pin_complexity_policy: bool = False, 117 | ) -> None: 118 | """Set the minimum PIN length allowed when setting/changing the PIN. 119 | 120 | :param min_pin_length: The minimum PIN length the Authenticator should allow. 121 | :param rp_ids: A list of RP IDs which should be allowed to get the current 122 | minimum PIN length. 123 | :param force_change_pin: True if the Authenticator should enforce changing the 124 | PIN before the next use. 125 | :param pin_complexity_policy: True if the Authenticator should enforce an 126 | additional PIN complexity policy beyond minPINLength. 127 | """ 128 | params: dict[int, Any] = {Config.PARAM.FORCE_CHANGE_PIN: force_change_pin} 129 | if min_pin_length is not None: 130 | params[Config.PARAM.NEW_MIN_PIN_LENGTH] = min_pin_length 131 | if rp_ids is not None: 132 | params[Config.PARAM.MIN_PIN_LENGTH_RPIDS] = rp_ids 133 | if pin_complexity_policy: 134 | if self.ctap.info.pin_complexity_policy is None: 135 | raise ValueError( 136 | "Authenticator does not support setting PIN complexity policy" 137 | ) 138 | params[Config.PARAM.PIN_COMPLEXITY_POLICY] = True 139 | self._call(Config.CMD.SET_MIN_PIN_LENGTH, params) 140 | -------------------------------------------------------------------------------- /tests/device/test_largeblobs.py: -------------------------------------------------------------------------------- 1 | import os 2 | import struct 3 | 4 | import pytest 5 | 6 | from fido2 import cbor 7 | from fido2.ctap import CtapError 8 | from fido2.ctap2.blob import LargeBlobs 9 | from fido2.ctap2.pin import ClientPin 10 | from fido2.server import Fido2Server 11 | from fido2.utils import sha256, websafe_decode, websafe_encode 12 | 13 | from . import TEST_PIN 14 | 15 | 16 | @pytest.fixture(autouse=True, scope="module") 17 | def preconditions(dev_manager): 18 | if not LargeBlobs.is_supported(dev_manager.info): 19 | pytest.skip("LargeBlobs not supported by authenticator") 20 | 21 | 22 | def get_lb(ctap2, pin_protocol, permissions=ClientPin.PERMISSION.LARGE_BLOB_WRITE): 23 | token = ClientPin(ctap2, pin_protocol).get_pin_token(TEST_PIN, permissions) 24 | return LargeBlobs(ctap2, pin_protocol, token) 25 | 26 | 27 | def test_read_write(ctap2, pin_protocol): 28 | lb = get_lb(ctap2, pin_protocol) 29 | assert len(lb.read_blob_array()) == 0 30 | 31 | key1 = os.urandom(32) 32 | data1 = b"test data" 33 | key2 = os.urandom(32) 34 | data2 = b"some other data" 35 | 36 | assert lb.get_blob(key1) is None 37 | lb.put_blob(key1, data1) 38 | assert lb.get_blob(key1) == data1 39 | assert len(lb.read_blob_array()) == 1 40 | 41 | lb.put_blob(key2, data2) 42 | assert lb.get_blob(key1) == data1 43 | assert lb.get_blob(key2) == data2 44 | assert len(lb.read_blob_array()) == 2 45 | 46 | lb.delete_blob(key1) 47 | assert lb.get_blob(key1) is None 48 | assert lb.get_blob(key2) == data2 49 | assert len(lb.read_blob_array()) == 1 50 | 51 | lb.delete_blob(key2) 52 | assert lb.get_blob(key2) is None 53 | assert len(lb.read_blob_array()) == 0 54 | 55 | 56 | def test_invalid_checksum(ctap2, pin_protocol): 57 | lb = get_lb(ctap2, pin_protocol) 58 | 59 | data = cbor.encode([]) 60 | # Set the checksum to an invalid value to ensure the authenticator checks it 61 | data += sha256(data)[:15] + b"\xff" 62 | offset = 0 63 | size = len(data) 64 | 65 | msg = b"\xff" * 32 + b"\x0c\x00" + struct.pack(" CollectedClientData: 82 | return super().create( 83 | type=type, 84 | challenge=challenge, 85 | origin=origin, 86 | cross_origin=cross_origin, 87 | payment=dict(kwargs.pop("payment")), 88 | **kwargs, 89 | ) 90 | 91 | 92 | class PaymentClientDataCollector(DefaultClientDataCollector): 93 | """ClientDataCollector for the WebAuthn "payment" extension. 94 | 95 | This class can be used together with the CTAP2 "thirdPartyPayment" extension to 96 | enable third-party payment confirmation. It collects the necessary client data and 97 | validates the options provided by the client. 98 | """ 99 | 100 | def collect_client_data(self, options): 101 | # Get the effective RP ID from the request options, falling back to the origin 102 | rp_id = self.get_rp_id(options, self._origin) 103 | inputs = options.extensions or {} 104 | data = AuthenticationExtensionsPaymentInputs.from_dict(inputs.get("payment")) 105 | if data and data.is_payment: 106 | if isinstance(options, PublicKeyCredentialCreationOptions): 107 | sel = options.authenticator_selection 108 | if ( 109 | not sel 110 | or sel.authenticator_attachment 111 | not in ( 112 | AuthenticatorAttachment.PLATFORM, 113 | # This is against the spec, but we need cross-platform 114 | AuthenticatorAttachment.CROSS_PLATFORM, 115 | ) 116 | or sel.resident_key 117 | not in ( 118 | ResidentKeyRequirement.REQUIRED, 119 | ResidentKeyRequirement.PREFERRED, 120 | ) 121 | or sel.user_verification != UserVerificationRequirement.REQUIRED 122 | ): 123 | raise ValueError("Invalid options for payment extension") 124 | elif isinstance(options, PublicKeyCredentialRequestOptions): 125 | # NOTE: We skip RP ID validation, as per the spec 126 | return ( 127 | CollectedClientPaymentData.create( 128 | type="payment.get", 129 | origin=self._origin, 130 | challenge=options.challenge, 131 | payment=CollectedClientAdditionalPaymentData( 132 | rp_id=data.rp_id, 133 | top_origin=data.top_origin, 134 | payee_name=data.payee_name, 135 | payee_origin=data.payee_origin, 136 | total=data.total, 137 | instrument=data.instrument, 138 | ), 139 | ), 140 | rp_id, 141 | ) 142 | 143 | # Validate that the RP ID is valid for the given origin 144 | self.verify_rp_id(rp_id, self._origin) 145 | return super().collect_client_data(options) 146 | -------------------------------------------------------------------------------- /examples/acr1252u.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from fido2.pcsc import CtapPcscDevice 4 | 5 | # control codes: 6 | # 3225264 - magic number!!! 7 | # 0x42000000 + 3500 - cross platform way 8 | C_CODE = 3225264 9 | 10 | 11 | class Acr1252uPcscDevice(object): 12 | def __init__(self, pcsc_device): 13 | self.pcsc = pcsc_device 14 | 15 | def reader_version(self): 16 | try: 17 | res = self.pcsc.control_exchange(C_CODE, b"\xe0\x00\x00\x18\x00") 18 | 19 | if len(res) > 0 and res.find(b"\xe1\x00\x00\x00") == 0: 20 | reslen = res[4] 21 | if reslen == len(res) - 5: 22 | strres = res[5 : 5 + reslen].decode("utf-8") 23 | return strres 24 | except Exception as e: 25 | print("Get version error:", e) 26 | return "n/a" 27 | 28 | def reader_serial_number(self): 29 | try: 30 | res = self.pcsc.control_exchange(C_CODE, b"\xe0\x00\x00\x33\x00") 31 | 32 | if len(res) > 0 and res.find(b"\xe1\x00\x00\x00") == 0: 33 | reslen = res[4] 34 | if reslen == len(res) - 5: 35 | strres = res[5 : 5 + reslen].decode("utf-8") 36 | return strres 37 | except Exception as e: 38 | print("Get serial number error:", e) 39 | return "n/a" 40 | 41 | def led_control(self, red=False, green=False): 42 | try: 43 | cbyte = (0b01 if red else 0b00) + (0b10 if green else 0b00) 44 | result = self.pcsc.control_exchange( 45 | C_CODE, b"\xe0\x00\x00\x29\x01" + bytes([cbyte]) 46 | ) 47 | 48 | if len(result) > 0 and result.find(b"\xe1\x00\x00\x00") == 0: 49 | result_length = result[4] 50 | if result_length == 1: 51 | ex_red = bool(result[5] & 0b01) 52 | ex_green = bool(result[5] & 0b10) 53 | return True, ex_red, ex_green 54 | except Exception as e: 55 | print("LED control error:", e) 56 | 57 | return False, False, False 58 | 59 | def led_status(self): 60 | try: 61 | result = self.pcsc.control_exchange(C_CODE, b"\xe0\x00\x00\x29\x00") 62 | 63 | if len(result) > 0 and result.find(b"\xe1\x00\x00\x00") == 0: 64 | result_length = result[4] 65 | if result_length == 1: 66 | ex_red = bool(result[5] & 0b01) 67 | ex_green = bool(result[5] & 0b10) 68 | return True, ex_red, ex_green 69 | except Exception as e: 70 | print("LED status error:", e) 71 | 72 | return False, False, False 73 | 74 | def get_polling_settings(self): 75 | try: 76 | res = self.pcsc.control_exchange(C_CODE, b"\xe0\x00\x00\x23\x00") 77 | 78 | if len(res) > 0 and res.find(b"\xe1\x00\x00\x00") == 0: 79 | reslen = res[4] 80 | if reslen == 1: 81 | return True, res[5] 82 | except Exception as e: 83 | print("Get polling settings error:", e) 84 | 85 | return False, 0 86 | 87 | def set_polling_settings(self, settings): 88 | try: 89 | res = self.pcsc.control_exchange( 90 | C_CODE, b"\xe0\x00\x00\x23\x01" + bytes([settings & 0xFF]) 91 | ) 92 | 93 | if len(res) > 0 and res.find(b"\xe1\x00\x00\x00") == 0: 94 | reslen = res[4] 95 | if reslen == 1: 96 | return True, res[5] 97 | except Exception as e: 98 | print("Set polling settings error:", e) 99 | 100 | return False, 0 101 | 102 | def get_picc_operation_parameter(self): 103 | try: 104 | res = self.pcsc.control_exchange(C_CODE, b"\xe0\x00\x00\x20\x00") 105 | 106 | if len(res) > 0 and res.find(b"\xe1\x00\x00\x00") == 0: 107 | reslen = res[4] 108 | if reslen == 1: 109 | return True, res[5] 110 | except Exception as e: 111 | print("Get PICC Operating Parameter error:", e) 112 | 113 | return False, 0 114 | 115 | def set_picc_operation_parameter(self, param): 116 | try: 117 | res = self.pcsc.control_exchange( 118 | C_CODE, b"\xe0\x00\x00\x20\x01" + bytes([param]) 119 | ) 120 | 121 | if len(res) > 0 and res.find(b"\xe1\x00\x00\x00") == 0: 122 | reslen = res[4] 123 | if reslen == 1: 124 | return True, res[5] 125 | except Exception as e: 126 | print("Set PICC Operating Parameter error:", e) 127 | 128 | return False, 0 129 | 130 | 131 | dev = next(CtapPcscDevice.list_devices()) 132 | 133 | print("CONNECT: %s" % dev) 134 | pcsc_device = Acr1252uPcscDevice(dev) 135 | if pcsc_device is not None: 136 | print("version: %s" % pcsc_device.reader_version()) 137 | print("serial number: %s" % pcsc_device.reader_serial_number()) 138 | print("") 139 | 140 | result, settings = pcsc_device.set_polling_settings(0x8B) 141 | print("write polling settings: %r 0x%x" % (result, settings)) 142 | 143 | result, settings = pcsc_device.get_polling_settings() 144 | print("polling settings: %r 0x%x" % (result, settings)) 145 | set_desc = [ 146 | [0, "Auto PICC Polling"], 147 | [1, "Turn off Antenna Field if no PICC is found"], 148 | [2, "Turn off Antenna Field if the PICC is inactive"], 149 | [3, "Activate the PICC when detected"], 150 | [7, "Enforce ISO 14443-A Part 4"], 151 | ] 152 | for x in set_desc: 153 | print(x[1], "on" if settings & (1 << x[0]) else "off") 154 | interval_desc = [250, 500, 1000, 2500] 155 | print("PICC Poll Interval for PICC", interval_desc[(settings >> 4) & 0b11], "ms") 156 | print("") 157 | 158 | print( 159 | "PICC operation parameter: %r 0x%x" % pcsc_device.get_picc_operation_parameter() 160 | ) 161 | print("") 162 | 163 | result, red, green = pcsc_device.led_control(True, False) 164 | print("led control result:", result, "red:", red, "green:", green) 165 | 166 | result, red, green = pcsc_device.led_status() 167 | print("led state result:", result, "red:", red, "green:", green) 168 | 169 | time.sleep(1) 170 | pcsc_device.led_control(False, False) 171 | -------------------------------------------------------------------------------- /fido2/ctap.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from __future__ import annotations 29 | 30 | import abc 31 | from enum import IntEnum, unique 32 | from threading import Event 33 | from typing import Callable, Iterator 34 | 35 | 36 | @unique 37 | class STATUS(IntEnum): 38 | """Status code for CTAP keep-alive message.""" 39 | 40 | PROCESSING = 1 41 | UPNEEDED = 2 42 | 43 | 44 | class CtapDevice(abc.ABC): 45 | """ 46 | CTAP-capable device. 47 | 48 | Subclasses of this should implement :func:`call`, as well as :func:`list_devices`, 49 | which should return a generator over discoverable devices. 50 | """ 51 | 52 | @property 53 | @abc.abstractmethod 54 | def capabilities(self) -> int: 55 | """Get device capabilities""" 56 | 57 | @abc.abstractmethod 58 | def call( 59 | self, 60 | cmd: int, 61 | data: bytes = b"", 62 | event: Event | None = None, 63 | on_keepalive: Callable[[STATUS], None] | None = None, 64 | ) -> bytes: 65 | """Sends a command to the authenticator, and reads the response. 66 | 67 | :param cmd: The integer value of the command. 68 | :param data: The payload of the command. 69 | :param event: An optional threading.Event which can be used to cancel 70 | the invocation. 71 | :param on_keepalive: An optional callback to handle keep-alive messages 72 | from the authenticator. The function is only called once for 73 | consecutive keep-alive messages with the same status. 74 | :return: The response from the authenticator. 75 | """ 76 | 77 | def close(self) -> None: 78 | """Close the device, releasing any held resources.""" 79 | 80 | def __enter__(self): 81 | return self 82 | 83 | def __exit__(self, typ, value, traceback): 84 | self.close() 85 | 86 | @classmethod 87 | @abc.abstractmethod 88 | def list_devices(cls) -> Iterator[CtapDevice]: 89 | """Generates instances of cls for discoverable devices.""" 90 | 91 | 92 | class CtapError(Exception): 93 | """Error returned from the Authenticator when a command fails.""" 94 | 95 | class UNKNOWN_ERR(int): 96 | """CTAP error status code that is not recognized.""" 97 | 98 | name = "UNKNOWN_ERR" 99 | 100 | @property 101 | def value(self) -> int: 102 | return int(self) 103 | 104 | def __repr__(self): 105 | return "" % self 106 | 107 | def __str__(self): 108 | return f"0x{self:02X} - UNKNOWN" 109 | 110 | @unique 111 | class ERR(IntEnum): 112 | """CTAP status codes. 113 | 114 | https://fidoalliance.org/specs/fido-v2.1-rd-20201208/fido-client-to-authenticator-protocol-v2.1-rd-20201208.html#error-responses 115 | """ 116 | 117 | SUCCESS = 0x00 118 | INVALID_COMMAND = 0x01 119 | INVALID_PARAMETER = 0x02 120 | INVALID_LENGTH = 0x03 121 | INVALID_SEQ = 0x04 122 | TIMEOUT = 0x05 123 | CHANNEL_BUSY = 0x06 124 | LOCK_REQUIRED = 0x0A 125 | INVALID_CHANNEL = 0x0B 126 | CBOR_UNEXPECTED_TYPE = 0x11 127 | INVALID_CBOR = 0x12 128 | MISSING_PARAMETER = 0x14 129 | LIMIT_EXCEEDED = 0x15 130 | # UNSUPPORTED_EXTENSION = 0x16 # No longer in spec 131 | FP_DATABASE_FULL = 0x17 132 | LARGE_BLOB_STORAGE_FULL = 0x18 133 | CREDENTIAL_EXCLUDED = 0x19 134 | PROCESSING = 0x21 135 | INVALID_CREDENTIAL = 0x22 136 | USER_ACTION_PENDING = 0x23 137 | OPERATION_PENDING = 0x24 138 | NO_OPERATIONS = 0x25 139 | UNSUPPORTED_ALGORITHM = 0x26 140 | OPERATION_DENIED = 0x27 141 | KEY_STORE_FULL = 0x28 142 | # NOT_BUSY = 0x29 # No longer in spec 143 | # NO_OPERATION_PENDING = 0x2A # No longer in spec 144 | UNSUPPORTED_OPTION = 0x2B 145 | INVALID_OPTION = 0x2C 146 | KEEPALIVE_CANCEL = 0x2D 147 | NO_CREDENTIALS = 0x2E 148 | USER_ACTION_TIMEOUT = 0x2F 149 | NOT_ALLOWED = 0x30 150 | PIN_INVALID = 0x31 151 | PIN_BLOCKED = 0x32 152 | PIN_AUTH_INVALID = 0x33 153 | PIN_AUTH_BLOCKED = 0x34 154 | PIN_NOT_SET = 0x35 155 | PUAT_REQUIRED = 0x36 156 | PIN_POLICY_VIOLATION = 0x37 157 | PIN_TOKEN_EXPIRED = 0x38 158 | REQUEST_TOO_LARGE = 0x39 159 | ACTION_TIMEOUT = 0x3A 160 | UP_REQUIRED = 0x3B 161 | UV_BLOCKED = 0x3C 162 | INTEGRITY_FAILURE = 0x3D 163 | INVALID_SUBCOMMAND = 0x3E 164 | UV_INVALID = 0x3F 165 | UNAUTHORIZED_PERMISSION = 0x40 166 | OTHER = 0x7F 167 | SPEC_LAST = 0xDF 168 | EXTENSION_FIRST = 0xE0 169 | EXTENSION_LAST = 0xEF 170 | VENDOR_FIRST = 0xF0 171 | VENDOR_LAST = 0xFF 172 | 173 | def __str__(self): 174 | return f"0x{self.value:02X} - {self.name}" 175 | 176 | def __init__(self, code: int): 177 | try: 178 | self.code = CtapError.ERR(code) 179 | except ValueError: 180 | self.code = CtapError.UNKNOWN_ERR(code) # type: ignore 181 | super().__init__(f"CTAP error: {self.code}") 182 | -------------------------------------------------------------------------------- /fido2/cbor.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | 29 | """ 30 | Minimal CBOR implementation supporting a subset of functionality and types 31 | required for FIDO 2 CTAP. 32 | 33 | Use the :func:`encode`, :func:`decode` and :func:`decode_from` functions to encode 34 | and decode objects to/from CBOR. 35 | """ 36 | 37 | from __future__ import annotations 38 | 39 | import struct 40 | from types import UnionType 41 | from typing import Any, Callable, Mapping, Sequence, TypeAlias 42 | 43 | CborType: TypeAlias = int | bool | str | bytes | Sequence[Any] | Mapping[Any, Any] 44 | 45 | # TODO: Requires Python 3.12, replace with collections.abc.Buffer 46 | Buffer: TypeAlias = bytes | bytearray | memoryview 47 | 48 | 49 | def _dump_int(data: int, mt: int = 0) -> bytes: 50 | if data < 0: 51 | mt = 1 52 | data = -1 - data 53 | 54 | mt = mt << 5 55 | if data <= 23: 56 | args: Any = (">B", mt | data) 57 | elif data <= 0xFF: 58 | args = (">BB", mt | 24, data) 59 | elif data <= 0xFFFF: 60 | args = (">BH", mt | 25, data) 61 | elif data <= 0xFFFFFFFF: 62 | args = (">BI", mt | 26, data) 63 | else: 64 | args = (">BQ", mt | 27, data) 65 | return struct.pack(*args) 66 | 67 | 68 | def _dump_bool(data: bool) -> bytes: 69 | return b"\xf5" if data else b"\xf4" 70 | 71 | 72 | def _dump_list(data: Sequence[CborType]) -> bytes: 73 | return _dump_int(len(data), mt=4) + b"".join([encode(x) for x in data]) 74 | 75 | 76 | def _sort_keys(entry): 77 | key = entry[0] 78 | return key[0], len(key), key 79 | 80 | 81 | def _dump_dict(data: Mapping[CborType, CborType]) -> bytes: 82 | items = [(encode(k), encode(v)) for k, v in data.items()] 83 | items.sort(key=_sort_keys) 84 | return _dump_int(len(items), mt=5) + b"".join([k + v for (k, v) in items]) 85 | 86 | 87 | def _dump_bytes(data: bytes) -> bytes: 88 | return _dump_int(len(data), mt=2) + data 89 | 90 | 91 | def _dump_text(data: str) -> bytes: 92 | data_bytes = data.encode("utf8") 93 | return _dump_int(len(data_bytes), mt=3) + data_bytes 94 | 95 | 96 | _SERIALIZERS: Sequence[tuple[type | UnionType, Callable[[Any], bytes]]] = [ 97 | (bool, _dump_bool), 98 | (int, _dump_int), 99 | (str, _dump_text), 100 | (Buffer, _dump_bytes), 101 | (Mapping, _dump_dict), 102 | (Sequence, _dump_list), 103 | ] 104 | 105 | 106 | def _load_int(ai: int, data: bytes) -> tuple[int, bytes]: 107 | if ai < 24: 108 | return ai, data 109 | elif ai == 24: 110 | return data[0], data[1:] 111 | elif ai == 25: 112 | return struct.unpack_from(">H", data)[0], data[2:] 113 | elif ai == 26: 114 | return struct.unpack_from(">I", data)[0], data[4:] 115 | elif ai == 27: 116 | return struct.unpack_from(">Q", data)[0], data[8:] 117 | raise ValueError("Invalid additional information") 118 | 119 | 120 | def _load_nint(ai: int, data: bytes) -> tuple[int, bytes]: 121 | val, rest = _load_int(ai, data) 122 | return -1 - val, rest 123 | 124 | 125 | def _load_bool(ai: int, data: bytes) -> tuple[bool, bytes]: 126 | return ai == 21, data 127 | 128 | 129 | def _load_bytes(ai: int, data: bytes) -> tuple[bytes, bytes]: 130 | ln, data = _load_int(ai, data) 131 | return data[:ln], data[ln:] 132 | 133 | 134 | def _load_text(ai: int, data: bytes) -> tuple[str, bytes]: 135 | enc, rest = _load_bytes(ai, data) 136 | return enc.decode("utf8"), rest 137 | 138 | 139 | def _load_array(ai: int, data: bytes) -> tuple[Sequence[CborType], bytes]: 140 | ln, data = _load_int(ai, data) 141 | values = [] 142 | for i in range(ln): 143 | val, data = decode_from(data) 144 | values.append(val) 145 | return values, data 146 | 147 | 148 | def _load_map(ai: int, data: bytes) -> tuple[Mapping[CborType, CborType], bytes]: 149 | ln, data = _load_int(ai, data) 150 | values = {} 151 | for i in range(ln): 152 | k, data = decode_from(data) 153 | v, data = decode_from(data) 154 | values[k] = v 155 | return values, data 156 | 157 | 158 | _DESERIALIZERS = { 159 | 0: _load_int, 160 | 1: _load_nint, 161 | 2: _load_bytes, 162 | 3: _load_text, 163 | 4: _load_array, 164 | 5: _load_map, 165 | 7: _load_bool, 166 | } 167 | 168 | 169 | def encode(data: CborType) -> bytes: 170 | """Encodes data to a CBOR byte string.""" 171 | for k, v in _SERIALIZERS: 172 | if isinstance(data, k): 173 | return v(data) 174 | raise ValueError(f"Unsupported value: {data!r}") 175 | 176 | 177 | def decode_from(data: bytes) -> tuple[Any, bytes]: 178 | """Decodes a CBOR-encoded value from the start of a byte string. 179 | 180 | Additional data after a valid CBOR object is returned as well. 181 | 182 | :return: The decoded object, and any remaining data.""" 183 | fb = data[0] 184 | return _DESERIALIZERS[fb >> 5](fb & 0b11111, data[1:]) 185 | 186 | 187 | def decode(data) -> CborType: 188 | """Decodes data from a CBOR-encoded byte string. 189 | 190 | Also validates that no extra data follows the encoded object. 191 | """ 192 | value, rest = decode_from(data) 193 | if rest != b"": 194 | raise ValueError("Extraneous data") 195 | return value 196 | -------------------------------------------------------------------------------- /examples/server/server/static/webauthn-json.browser-ponyfill.js: -------------------------------------------------------------------------------- 1 | // src/webauthn-json/base64url.ts 2 | function base64urlToBuffer(baseurl64String) { 3 | const padding = "==".slice(0, (4 - baseurl64String.length % 4) % 4); 4 | const base64String = baseurl64String.replace(/-/g, "+").replace(/_/g, "/") + padding; 5 | const str = atob(base64String); 6 | const buffer = new ArrayBuffer(str.length); 7 | const byteView = new Uint8Array(buffer); 8 | for (let i = 0; i < str.length; i++) { 9 | byteView[i] = str.charCodeAt(i); 10 | } 11 | return buffer; 12 | } 13 | function bufferToBase64url(buffer) { 14 | const byteView = new Uint8Array(buffer); 15 | let str = ""; 16 | for (const charCode of byteView) { 17 | str += String.fromCharCode(charCode); 18 | } 19 | const base64String = btoa(str); 20 | const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); 21 | return base64urlString; 22 | } 23 | 24 | // src/webauthn-json/convert.ts 25 | var copyValue = "copy"; 26 | var convertValue = "convert"; 27 | function convert(conversionFn, schema, input) { 28 | if (schema === copyValue) { 29 | return input; 30 | } 31 | if (schema === convertValue) { 32 | return conversionFn(input); 33 | } 34 | if (schema instanceof Array) { 35 | return input.map((v) => convert(conversionFn, schema[0], v)); 36 | } 37 | if (schema instanceof Object) { 38 | const output = {}; 39 | for (const [key, schemaField] of Object.entries(schema)) { 40 | if (schemaField.derive) { 41 | const v = schemaField.derive(input); 42 | if (v !== void 0) { 43 | input[key] = v; 44 | } 45 | } 46 | if (!(key in input)) { 47 | if (schemaField.required) { 48 | throw new Error(`Missing key: ${key}`); 49 | } 50 | continue; 51 | } 52 | if (input[key] == null) { 53 | output[key] = null; 54 | continue; 55 | } 56 | output[key] = convert(conversionFn, schemaField.schema, input[key]); 57 | } 58 | return output; 59 | } 60 | } 61 | function derived(schema, derive) { 62 | return { 63 | required: true, 64 | schema, 65 | derive 66 | }; 67 | } 68 | function required(schema) { 69 | return { 70 | required: true, 71 | schema 72 | }; 73 | } 74 | function optional(schema) { 75 | return { 76 | required: false, 77 | schema 78 | }; 79 | } 80 | 81 | // src/webauthn-json/basic/schema.ts 82 | var publicKeyCredentialDescriptorSchema = { 83 | type: required(copyValue), 84 | id: required(convertValue), 85 | transports: optional(copyValue) 86 | }; 87 | var simplifiedExtensionsSchema = { 88 | appid: optional(copyValue), 89 | appidExclude: optional(copyValue), 90 | credProps: optional(copyValue) 91 | }; 92 | var simplifiedClientExtensionResultsSchema = { 93 | appid: optional(copyValue), 94 | appidExclude: optional(copyValue), 95 | credProps: optional(copyValue) 96 | }; 97 | var credentialCreationOptions = { 98 | publicKey: required({ 99 | rp: required(copyValue), 100 | user: required({ 101 | id: required(convertValue), 102 | name: required(copyValue), 103 | displayName: required(copyValue) 104 | }), 105 | challenge: required(convertValue), 106 | pubKeyCredParams: required(copyValue), 107 | timeout: optional(copyValue), 108 | excludeCredentials: optional([publicKeyCredentialDescriptorSchema]), 109 | authenticatorSelection: optional(copyValue), 110 | attestation: optional(copyValue), 111 | extensions: optional(simplifiedExtensionsSchema) 112 | }), 113 | signal: optional(copyValue) 114 | }; 115 | var publicKeyCredentialWithAttestation = { 116 | type: required(copyValue), 117 | id: required(copyValue), 118 | rawId: required(convertValue), 119 | authenticatorAttachment: optional(copyValue), 120 | response: required({ 121 | clientDataJSON: required(convertValue), 122 | attestationObject: required(convertValue), 123 | transports: derived(copyValue, (response) => { 124 | var _a; 125 | return ((_a = response.getTransports) == null ? void 0 : _a.call(response)) || []; 126 | }) 127 | }), 128 | clientExtensionResults: derived(simplifiedClientExtensionResultsSchema, (pkc) => pkc.getClientExtensionResults()) 129 | }; 130 | var credentialRequestOptions = { 131 | mediation: optional(copyValue), 132 | publicKey: required({ 133 | challenge: required(convertValue), 134 | timeout: optional(copyValue), 135 | rpId: optional(copyValue), 136 | allowCredentials: optional([publicKeyCredentialDescriptorSchema]), 137 | userVerification: optional(copyValue), 138 | extensions: optional(simplifiedExtensionsSchema) 139 | }), 140 | signal: optional(copyValue) 141 | }; 142 | var publicKeyCredentialWithAssertion = { 143 | type: required(copyValue), 144 | id: required(copyValue), 145 | rawId: required(convertValue), 146 | authenticatorAttachment: optional(copyValue), 147 | response: required({ 148 | clientDataJSON: required(convertValue), 149 | authenticatorData: required(convertValue), 150 | signature: required(convertValue), 151 | userHandle: required(convertValue) 152 | }), 153 | clientExtensionResults: derived(simplifiedClientExtensionResultsSchema, (pkc) => pkc.getClientExtensionResults()) 154 | }; 155 | 156 | // src/webauthn-json/basic/api.ts 157 | function createRequestFromJSON(requestJSON) { 158 | return convert(base64urlToBuffer, credentialCreationOptions, requestJSON); 159 | } 160 | function createResponseToJSON(credential) { 161 | return convert(bufferToBase64url, publicKeyCredentialWithAttestation, credential); 162 | } 163 | function getRequestFromJSON(requestJSON) { 164 | return convert(base64urlToBuffer, credentialRequestOptions, requestJSON); 165 | } 166 | function getResponseToJSON(credential) { 167 | return convert(bufferToBase64url, publicKeyCredentialWithAssertion, credential); 168 | } 169 | 170 | // src/webauthn-json/basic/supported.ts 171 | function supported() { 172 | return !!(navigator.credentials && navigator.credentials.create && navigator.credentials.get && window.PublicKeyCredential); 173 | } 174 | 175 | // src/webauthn-json/browser-ponyfill.ts 176 | async function create(options) { 177 | const response = await navigator.credentials.create(options); 178 | response.toJSON = () => createResponseToJSON(response); 179 | return response; 180 | } 181 | async function get(options) { 182 | const response = await navigator.credentials.get(options); 183 | response.toJSON = () => getResponseToJSON(response); 184 | return response; 185 | } 186 | export { 187 | create, 188 | get, 189 | createRequestFromJSON as parseCreationOptionsFromJSON, 190 | getRequestFromJSON as parseRequestOptionsFromJSON, 191 | supported 192 | }; 193 | //# sourceMappingURL=webauthn-json.browser-ponyfill.js.map 194 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | 18 | import tomllib 19 | 20 | sys.path.insert(0, os.path.abspath("../")) 21 | 22 | 23 | def get_version(): 24 | with open("../pyproject.toml", "rb") as f: 25 | pyproject = tomllib.load(f) 26 | 27 | return pyproject["project"]["version"] 28 | 29 | 30 | # -- Project information ----------------------------------------------------- 31 | 32 | project = "python-fido2" 33 | copyright = "2024, Yubico" 34 | author = "Yubico" 35 | 36 | # The full version, including alpha/beta/rc tags 37 | release = get_version() 38 | 39 | # The short X.Y version 40 | version = ".".join(release.split(".")[:2]) 41 | 42 | # -- General configuration --------------------------------------------------- 43 | 44 | # If your documentation needs a minimal Sphinx version, state it here. 45 | # 46 | # needs_sphinx = '1.0' 47 | 48 | # Add any Sphinx extension module names here, as strings. They can be 49 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 50 | # ones. 51 | extensions = [ 52 | "autoapi.extension", 53 | "sphinx.ext.autodoc.typehints", 54 | "sphinx.ext.doctest", 55 | "sphinx.ext.intersphinx", 56 | "sphinx.ext.viewcode", 57 | ] 58 | 59 | autodoc_typehints = "description" 60 | 61 | # Add any paths that contain templates here, relative to this directory. 62 | templates_path = ["_templates"] 63 | 64 | # The suffix(es) of source filenames. 65 | # You can specify multiple suffix as a list of string: 66 | # 67 | # source_suffix = ['.rst', '.md'] 68 | source_suffix = ".rst" 69 | 70 | # The master toctree document. 71 | master_doc = "index" 72 | 73 | # The language for content autogenerated by Sphinx. Refer to documentation 74 | # for a list of supported languages. 75 | # 76 | # This is also used if you do content translation via gettext catalogs. 77 | # Usually you set "language" from the command line for these cases. 78 | language = "en" 79 | 80 | # List of patterns, relative to source directory, that match files and 81 | # directories to ignore when looking for source files. 82 | # This pattern also affects html_static_path and html_extra_path . 83 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = "sphinx" 87 | 88 | 89 | # -- Options for HTML output ------------------------------------------------- 90 | 91 | # The theme to use for HTML and HTML Help pages. See the documentation for 92 | # a list of builtin themes. 93 | # 94 | html_theme = "sphinx_rtd_theme" 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | # 100 | # html_theme_options = {} 101 | 102 | html_favicon = "favicon.ico" 103 | 104 | # Add any paths that contain custom static files (such as style sheets) here, 105 | # relative to this directory. They are copied after the builtin static files, 106 | # so a file named "default.css" will overwrite the builtin "default.css". 107 | html_static_path = ["_static"] 108 | 109 | # Don't show a "View page source" link on each page. 110 | html_show_sourcelink = False 111 | 112 | # Custom sidebar templates, must be a dictionary that maps document names 113 | # to template names. 114 | # 115 | # The default sidebars (for documents that don't match any pattern) are 116 | # defined by theme itself. Builtin themes are using these templates by 117 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 118 | # 'searchbox.html']``. 119 | # 120 | # html_sidebars = {} 121 | 122 | 123 | # -- Options for HTMLHelp output --------------------------------------------- 124 | 125 | # Output file base name for HTML help builder. 126 | htmlhelp_basename = "python-fido2doc" 127 | 128 | 129 | # -- Options for LaTeX output ------------------------------------------------ 130 | 131 | latex_elements = { 132 | # The paper size ('letterpaper' or 'a4paper'). 133 | # 134 | # 'papersize': 'letterpaper', 135 | # The font size ('10pt', '11pt' or '12pt'). 136 | # 137 | # 'pointsize': '10pt', 138 | # Additional stuff for the LaTeX preamble. 139 | # 140 | # 'preamble': '', 141 | # Latex figure (float) alignment 142 | # 143 | # 'figure_align': 'htbp', 144 | } 145 | 146 | # Grouping the document tree into LaTeX files. List of tuples 147 | # (source start file, target name, title, 148 | # author, documentclass [howto, manual, or own class]). 149 | latex_documents = [ 150 | ( 151 | master_doc, 152 | "python-fido2.tex", 153 | "python-fido2 Documentation", 154 | "Yubico", 155 | "manual", 156 | ) 157 | ] 158 | 159 | 160 | # -- Options for manual page output ------------------------------------------ 161 | 162 | # One entry per manual page. List of tuples 163 | # (source start file, name, description, authors, manual section). 164 | man_pages = [(master_doc, "python-fido2", "python-fido2 Documentation", [author], 1)] 165 | 166 | 167 | # -- Options for Texinfo output ---------------------------------------------- 168 | 169 | # Grouping the document tree into Texinfo files. List of tuples 170 | # (source start file, target name, title, author, 171 | # dir menu entry, description, category) 172 | texinfo_documents = [ 173 | ( 174 | master_doc, 175 | "python-fido2", 176 | "python-fido2 Documentation", 177 | author, 178 | "python-fido2", 179 | "One line description of project.", 180 | "Miscellaneous", 181 | ) 182 | ] 183 | 184 | 185 | # -- Extension configuration ------------------------------------------------- 186 | 187 | # -- Options for intersphinx extension --------------------------------------- 188 | 189 | # Example configuration for intersphinx: refer to the Python standard library. 190 | intersphinx_mapping = { 191 | "python": ("https://docs.python.org/", None), 192 | "cryptography": ("https://cryptography.io/en/latest/", None), 193 | } 194 | 195 | 196 | # Custom config 197 | autoapi_dirs = ["../fido2"] 198 | autoapi_options = [ 199 | "members", 200 | "undoc-members", 201 | "show-inheritance", 202 | "show-module-summary", 203 | "imported-members", 204 | ] 205 | autoapi_ignore = ["*/fido2/hid/*", "*/fido2/win_api.py"] 206 | 207 | 208 | def skip_member(app, what, name, obj, skip, options): 209 | if what == "data" and name.endswith(".logger"): 210 | return True 211 | 212 | 213 | def setup(sphinx): 214 | sphinx.connect("autoapi-skip-member", skip_member) 215 | --------------------------------------------------------------------------------