From f2e5a91e63952b3bd8af67f9f00697c8467d3a8a Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Mon, 27 Jan 2025 12:42:05 +0100 Subject: [PATCH] Add client tests --- tests/device/__init__.py | 28 +++++++++- tests/device/conftest.py | 27 ++++++---- tests/device/test_client.py | 101 ++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 tests/device/test_client.py diff --git a/tests/device/__init__.py b/tests/device/__init__.py index bbd2fc8..bf8692d 100644 --- a/tests/device/__init__.py +++ b/tests/device/__init__.py @@ -10,9 +10,33 @@ def __init__(self, capmanager): def print(self, *messages): with self.capmanager.global_and_fixture_disabled(): + print("") for m in messages: print(m) + def touch(self): + self.print("👉 Touch the Authenticator") + + def insert(self, nfc=False): + self.print( + "♻️ " + + ( + "Place the Authenticator on the NFC reader" + if nfc + else "Connect the Authenticator" + ) + ) + + def remove(self, nfc=False): + self.print( + "🚫 " + + ( + "Remove the Authenticator from the NFC reader" + if nfc + else "Disconnect the Authenticator" + ) + ) + # Handle user interaction class CliInteraction(UserInteraction): @@ -21,11 +45,11 @@ def __init__(self, printer, pin=TEST_PIN): self.pin = pin def prompt_up(self): - self.printer.print("\nTouch your authenticator device now...\n") + self.printer.touch() def request_pin(self, permissions, rd_id): return self.pin def request_uv(self, permissions, rd_id): - self.printer.print("\nUser Verification required.") + self.printer.print("User Verification required.") return True diff --git a/tests/device/conftest.py b/tests/device/conftest.py index 348ffae..6f97dfd 100644 --- a/tests/device/conftest.py +++ b/tests/device/conftest.py @@ -21,13 +21,13 @@ def __init__(self, printer, reader_name): self.printer = printer self.printer.print( + "⚠️ Tests will now run against a connected FIDO authenticator. ⚠️ ", "", - "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!", - "Tests will now run against a connected FIDO authenticator.", + "You may be prompted to interact with the authenticator throughout these " + "tests.", "", - "THESE TESTS ARE DESTRUCTIVE!", + " ☠️ WARNING! THESE TESTS ARE DESTRUCTIVE! ☠️ ", "ANY CREDENTIALS ON THIS AUTHENTICATOR WILL BE PERMANENTLY DELETED!", - "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!", ) if reader_name: @@ -51,6 +51,11 @@ def __init__(self, printer, reader_name): if self.has_ctap2(): options = Ctap2(self.device).info.options if options.get("clientPin") or options.get("uv"): + self.printer.print( + "As a precaution, these tests will not run on an authenticator " + "which is configured with any form of UV. Factory reset the " + "authenticator prior to running tests against it." + ) pytest.exit("Authenticator must be in a newly-reset state!") self.setup() @@ -79,7 +84,7 @@ def select(descriptor): exc_info=True, ) - self.printer.print("Insert and touch the authenticator to test...") + self.printer.touch() descriptors: set[str | bytes] = set() threads: list[Thread] = [] @@ -124,7 +129,7 @@ def _connect(): connection = ExclusiveConnectCardConnection(reader.createConnection()) return CtapPcscDevice(connection, reader.name) - self.printer.print("Remove Authenticator from the NFC reader now...") + self.printer.remove(nfc=True) removed = False while not event.wait(0.5): try: @@ -138,7 +143,7 @@ def _connect(): pass # Expected, ignore except (NoCardException, KeyError): if not removed: - self.printer.print("Place Authenticator on the NFC reader now...") + self.printer.insert(nfc=True) removed = True raise Exception("Failed to (re-)connect to Authenticator") @@ -168,7 +173,7 @@ def on_keepalive(status): if status != prompted[0]: prompted[0] = status if status == 2: - self.printer.print("Touch the Authenticator now...") + self.printer.touch() return on_keepalive @@ -186,12 +191,12 @@ def _reconnect_usb(self): info = Ctap2(self._dev).info logger.debug(f"Reconnect over USB: {dev_path}") - self.printer.print("", "Disconnect the Authenticator now...") + self.printer.remove() ds = {d.path for d in list_descriptors()} while dev_path in ds: event.wait(0.5) ds = {d.path for d in list_descriptors()} - self.printer.print("Re-insert the Authenticator now...") + self.printer.insert() ds2 = ds while True: event.wait(0.5) @@ -214,7 +219,7 @@ def reconnect(self): return self._dev def factory_reset(self, setup=False): - self.printer.print("", "PERFORMING FACTORY RESET!") + self.printer.print("⚠️ PERFORMING FACTORY RESET! ⚠️ ") self.reconnect() diff --git a/tests/device/test_client.py b/tests/device/test_client.py new file mode 100644 index 0000000..dad4716 --- /dev/null +++ b/tests/device/test_client.py @@ -0,0 +1,101 @@ +from fido2.ctap2.pin import ClientPin +from fido2.ctap2.credman import CredentialManagement +from fido2.server import Fido2Server, to_descriptor +from fido2.client import ClientError +from . import TEST_PIN + +import pytest +import os + + +rp = {"id": "example.com", "name": "Example RP"} +user = {"id": b"user_id", "name": "A. User"} +server = Fido2Server(rp) + + +@pytest.fixture(scope="module") +def excluded_match(dev_manager): + if dev_manager.has_ctap2(): + return "CREDENTIAL_EXCLUDED" + return "DEVICE_INELIGIBLE" + + +@pytest.fixture(scope="module") +def credential(dev_manager): + create_options, state = server.register_begin(user) + result = dev_manager.client.make_credential(create_options.public_key) + auth_data = server.register_complete(state, result) + return auth_data.credential_data + + +@pytest.fixture(scope="module") +def discoverable_credential(dev_manager): + if not dev_manager.has_ctap2(): + pytest.skip("Authenticator does not support CTAP 2") + + create_options, state = server.register_begin( + user, + resident_key_requirement="required", + ) + result = dev_manager.client.make_credential(create_options.public_key) + auth_data = server.register_complete(state, result) + yield auth_data.credential_data + + # Delete credential via credman, or factory reset + if CredentialManagement.is_supported(dev_manager.info): + client_pin = ClientPin(dev_manager.ctap2) + token = client_pin.get_pin_token(TEST_PIN, ClientPin.PERMISSION.CREDENTIAL_MGMT) + credman = CredentialManagement(dev_manager.ctap2, client_pin.protocol, token) + cred_id = {"id": auth_data.credential_data.credential_id, "type": "public-key"} + credman.delete_cred(cred_id) + else: + dev_manager.factory_reset(setup=True) + + +def test_exclude_credentials_single(credential, client, excluded_match): + create_options, state = server.register_begin(user, [credential]) + with pytest.raises(ClientError, match=excluded_match): + client.make_credential(create_options.public_key) + + +def test_exclude_credentials_multiple(credential, client, excluded_match): + exclude = [{"id": os.urandom(32), "type": "public-key"} for _ in range(5)] + exclude.insert(3, to_descriptor(credential)) + create_options, state = server.register_begin(user, exclude) + with pytest.raises(ClientError, match=excluded_match): + client.make_credential(create_options.public_key) + + +def test_exclude_credentials_others(credential, client): + exclude = [{"id": os.urandom(32), "type": "public-key"} for _ in range(5)] + create_options, state = server.register_begin(user, exclude) + client.make_credential(create_options.public_key) + + +def test_allow_credentials_empty(discoverable_credential, client): + request_options, state = server.authenticate_begin() + result = client.get_assertion(request_options.public_key).get_response(0) + server.authenticate_complete(state, [discoverable_credential], result) + + +def test_allow_credentials_single(credential, client): + credentials = [credential] + request_options, state = server.authenticate_begin(credentials) + result = client.get_assertion(request_options.public_key).get_response(0) + server.authenticate_complete(state, credentials, result) + + +def test_allow_credentials_multiple(credential, client): + credentials = [credential] + allow = [{"id": os.urandom(32), "type": "public-key"} for _ in range(5)] + allow.insert(3, to_descriptor(credential)) + request_options, state = server.authenticate_begin(allow) + result = client.get_assertion(request_options.public_key).get_response(0) + server.authenticate_complete(state, credentials, result) + + +def test_allow_credentials_ineligible(credential, client): + allow = [{"id": os.urandom(32), "type": "public-key"} for _ in range(5)] + request_options, state = server.authenticate_begin(allow) + with pytest.raises(ClientError, match="DEVICE_INELIGIBLE"): + client.get_assertion(request_options.public_key)