├── 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 | Click here to start
50 |
51 |
52 |
Touch your authenticator device now...
53 |
Cancel
54 |
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 | Click here to start
53 |
54 |
55 |
Touch your authenticator device now...
56 |
Cancel
57 |
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 |
--------------------------------------------------------------------------------