From 6fd7f91ec664c8c465aaaae189d1e44f44a66aa2 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Sat, 20 Jan 2024 19:30:15 +0100 Subject: [PATCH 1/8] nk3: Extract basic functionality into trussed module This patch introduces a pynitrokey.trussed module that contains the functionality that is common for all Nitrokey devices using the Trussed framework: communication via CTAPHID, basic commands using admin-app and provisioner-app, firmware updates using the Nitrokey container format and the LPC55 and NRF52 bootloader implementations. --- pynitrokey/cli/nk3/__init__.py | 21 +- pynitrokey/cli/nk3/test.py | 27 +- pynitrokey/cli/nk3/update.py | 2 +- pynitrokey/nk3/__init__.py | 10 +- pynitrokey/nk3/admin_app.py | 6 +- pynitrokey/nk3/bootloader.py | 80 ++++++ pynitrokey/nk3/device.py | 86 ++----- pynitrokey/nk3/provisioner_app.py | 2 +- pynitrokey/nk3/updates.py | 23 +- pynitrokey/nk3/utils.py | 223 +---------------- pynitrokey/trussed/__init__.py | 10 + pynitrokey/{nk3 => trussed}/base.py | 28 ++- .../{nk3 => trussed}/bootloader/__init__.py | 49 ++-- .../{nk3 => trussed}/bootloader/lpc55.py | 44 ++-- .../{nk3 => trussed}/bootloader/nrf52.py | 37 ++- .../bootloader/nrf52_upload/README.md | 0 .../bootloader/nrf52_upload/__init__.py | 0 .../bootloader/nrf52_upload/dfu/__init__.py | 0 .../bootloader/nrf52_upload/dfu/crc16.py | 0 .../bootloader/nrf52_upload/dfu/dfu_cc_pb2.py | 0 .../nrf52_upload/dfu/dfu_transport.py | 0 .../nrf52_upload/dfu/dfu_transport_serial.py | 0 .../nrf52_upload/dfu/dfu_trigger.py | 0 .../nrf52_upload/dfu/init_packet_pb.py | 0 .../bootloader/nrf52_upload/dfu/manifest.py | 0 .../bootloader/nrf52_upload/dfu/model.py | 0 .../bootloader/nrf52_upload/dfu/nrfhex.py | 0 .../bootloader/nrf52_upload/dfu/package.py | 0 .../bootloader/nrf52_upload/dfu/signing.py | 0 .../bootloader/nrf52_upload/exceptions.py | 0 .../nrf52_upload/lister/__init__.py | 0 .../nrf52_upload/lister/device_lister.py | 0 .../nrf52_upload/lister/enumerated_device.py | 0 .../nrf52_upload/lister/lister_backend.py | 0 .../nrf52_upload/lister/unix/__init__.py | 0 .../nrf52_upload/lister/unix/unix_lister.py | 0 .../nrf52_upload/lister/windows/__init__.py | 0 .../nrf52_upload/lister/windows/constants.py | 0 .../lister/windows/lister_win32.py | 0 .../nrf52_upload/lister/windows/structures.py | 0 pynitrokey/trussed/device.py | 84 +++++++ pynitrokey/trussed/utils.py | 233 ++++++++++++++++++ pyproject.toml | 4 +- 43 files changed, 554 insertions(+), 415 deletions(-) create mode 100644 pynitrokey/nk3/bootloader.py create mode 100644 pynitrokey/trussed/__init__.py rename pynitrokey/{nk3 => trussed}/base.py (56%) rename pynitrokey/{nk3 => trussed}/bootloader/__init__.py (84%) rename pynitrokey/{nk3 => trussed}/bootloader/lpc55.py (79%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52.py (89%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/README.md (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/__init__.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/dfu/__init__.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/dfu/crc16.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/dfu/dfu_cc_pb2.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/dfu/dfu_transport.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/dfu/dfu_transport_serial.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/dfu/dfu_trigger.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/dfu/init_packet_pb.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/dfu/manifest.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/dfu/model.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/dfu/nrfhex.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/dfu/package.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/dfu/signing.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/exceptions.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/lister/__init__.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/lister/device_lister.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/lister/enumerated_device.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/lister/lister_backend.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/lister/unix/__init__.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/lister/unix/unix_lister.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/lister/windows/__init__.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/lister/windows/constants.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/lister/windows/lister_win32.py (100%) rename pynitrokey/{nk3 => trussed}/bootloader/nrf52_upload/lister/windows/structures.py (100%) create mode 100644 pynitrokey/trussed/device.py create mode 100644 pynitrokey/trussed/utils.py diff --git a/pynitrokey/cli/nk3/__init__.py b/pynitrokey/cli/nk3/__init__.py index 19090afc..28af4431 100644 --- a/pynitrokey/cli/nk3/__init__.py +++ b/pynitrokey/cli/nk3/__init__.py @@ -30,19 +30,20 @@ from pynitrokey.nk3 import list as list_nk3 from pynitrokey.nk3 import open as open_nk3 from pynitrokey.nk3.admin_app import AdminApp -from pynitrokey.nk3.base import Nitrokey3Base -from pynitrokey.nk3.bootloader import ( - FirmwareContainer, - Nitrokey3Bootloader, - parse_firmware_image, -) +from pynitrokey.nk3.bootloader import Nitrokey3Bootloader from pynitrokey.nk3.device import BootMode, Nitrokey3Device from pynitrokey.nk3.exceptions import TimeoutException from pynitrokey.nk3.provisioner_app import ProvisionerApp from pynitrokey.nk3.updates import REPOSITORY, get_firmware_update +from pynitrokey.trussed.base import NitrokeyTrussedBase +from pynitrokey.trussed.bootloader import ( + Device, + FirmwareContainer, + parse_firmware_image, +) from pynitrokey.updates import OverwriteError -T = TypeVar("T", bound=Nitrokey3Base) +T = TypeVar("T", bound=NitrokeyTrussedBase) logger = logging.getLogger(__name__) @@ -51,7 +52,7 @@ class Context: def __init__(self, path: Optional[str]) -> None: self.path = path - def list(self) -> List[Nitrokey3Base]: + def list(self) -> List[NitrokeyTrussedBase]: if self.path: device = open_nk3(self.path) if device: @@ -75,7 +76,7 @@ def _select_unique(self, name: str, devices: List[T]) -> T: return devices[0] - def connect(self) -> Nitrokey3Base: + def connect(self) -> NitrokeyTrussedBase: return self._select_unique("Nitrokey 3", self.list()) def connect_device(self) -> Nitrokey3Device: @@ -375,7 +376,7 @@ def validate_update(image: str) -> None: Validates the given firmware image and prints the firmware version and the signer for all available variants. """ - container = FirmwareContainer.parse(image) + container = FirmwareContainer.parse(image, Device.NITROKEY3) print(f"version: {container.version}") if container.pynitrokey: print(f"pynitrokey: >= {container.pynitrokey}") diff --git a/pynitrokey/cli/nk3/test.py b/pynitrokey/cli/nk3/test.py index cd2a449e..cdf150ab 100644 --- a/pynitrokey/cli/nk3/test.py +++ b/pynitrokey/cli/nk3/test.py @@ -26,9 +26,10 @@ from pynitrokey.fido2.client import NKFido2Client from pynitrokey.helpers import local_print from pynitrokey.nk3.admin_app import AdminApp -from pynitrokey.nk3.base import Nitrokey3Base from pynitrokey.nk3.device import Nitrokey3Device -from pynitrokey.nk3.utils import Fido2Certs, Uuid, Version +from pynitrokey.nk3.utils import Fido2Certs +from pynitrokey.trussed.base import NitrokeyTrussedBase +from pynitrokey.trussed.utils import Uuid, Version logger = logging.getLogger(__name__) @@ -69,7 +70,7 @@ def __init__( self.exc_info = exc_info -TestCaseFn = Callable[[TestContext, Nitrokey3Base], TestResult] +TestCaseFn = Callable[[TestContext, NitrokeyTrussedBase], TestResult] class TestCase: @@ -133,14 +134,16 @@ def log_system() -> None: @test_case("uuid", "UUID query") -def test_uuid_query(ctx: TestContext, device: Nitrokey3Base) -> TestResult: +def test_uuid_query(ctx: TestContext, device: NitrokeyTrussedBase) -> TestResult: uuid = device.uuid() uuid_str = str(uuid) if uuid else "[not supported]" return TestResult(TestStatus.SUCCESS, uuid_str) @test_case("version", "Firmware version query") -def test_firmware_version_query(ctx: TestContext, device: Nitrokey3Base) -> TestResult: +def test_firmware_version_query( + ctx: TestContext, device: NitrokeyTrussedBase +) -> TestResult: if not isinstance(device, Nitrokey3Device): return TestResult(TestStatus.SKIPPED) version = device.version() @@ -149,7 +152,7 @@ def test_firmware_version_query(ctx: TestContext, device: Nitrokey3Base) -> Test @test_case("status", "Device status") -def test_device_status(ctx: TestContext, device: Nitrokey3Base) -> TestResult: +def test_device_status(ctx: TestContext, device: NitrokeyTrussedBase) -> TestResult: if not isinstance(device, Nitrokey3Device): return TestResult(TestStatus.SKIPPED) firmware_version = ctx.firmware_version or device.version() @@ -182,7 +185,7 @@ def test_device_status(ctx: TestContext, device: Nitrokey3Base) -> TestResult: @test_case("bootloader", "Bootloader configuration") def test_bootloader_configuration( - ctx: TestContext, device: Nitrokey3Base + ctx: TestContext, device: NitrokeyTrussedBase ) -> TestResult: if not isinstance(device, Nitrokey3Device): return TestResult(TestStatus.SKIPPED) @@ -193,7 +196,7 @@ def test_bootloader_configuration( @test_case("provisioner", "Firmware mode") -def test_firmware_mode(ctx: TestContext, device: Nitrokey3Base) -> TestResult: +def test_firmware_mode(ctx: TestContext, device: NitrokeyTrussedBase) -> TestResult: try: from smartcard import System from smartcard.CardConnection import CardConnection @@ -355,7 +358,7 @@ def select(conn: CardConnection, aid: list[int]) -> bool: @test_case("se050", "SE050") -def test_se050(ctx: TestContext, device: Nitrokey3Base) -> TestResult: +def test_se050(ctx: TestContext, device: NitrokeyTrussedBase) -> TestResult: from queue import Queue if not isinstance(device, Nitrokey3Device): @@ -428,7 +431,7 @@ def internal_se050_run( @test_case("fido2", "FIDO2") -def test_fido2(ctx: TestContext, device: Nitrokey3Base) -> TestResult: +def test_fido2(ctx: TestContext, device: NitrokeyTrussedBase) -> TestResult: if not isinstance(device, Nitrokey3Device): return TestResult(TestStatus.SKIPPED) @@ -546,7 +549,9 @@ def list_tests(selector: TestSelector) -> None: print(f"- {test_case.name}: {test_case.description}") -def run_tests(ctx: TestContext, device: Nitrokey3Base, selector: TestSelector) -> bool: +def run_tests( + ctx: TestContext, device: NitrokeyTrussedBase, selector: TestSelector +) -> bool: test_cases = selector.select() if not test_cases: raise CliException("No test cases selected", support_hint=False) diff --git a/pynitrokey/cli/nk3/update.py b/pynitrokey/cli/nk3/update.py index 815fda3d..2cd2ec46 100644 --- a/pynitrokey/cli/nk3/update.py +++ b/pynitrokey/cli/nk3/update.py @@ -17,7 +17,7 @@ from pynitrokey.cli.nk3 import Context from pynitrokey.helpers import DownloadProgressBar, ProgressBar, confirm, local_print from pynitrokey.nk3.updates import Updater, UpdateUi -from pynitrokey.nk3.utils import Version +from pynitrokey.trussed.utils import Version logger = logging.getLogger(__name__) diff --git a/pynitrokey/nk3/__init__.py b/pynitrokey/nk3/__init__.py index 5d32cc4f..12bbaf7c 100644 --- a/pynitrokey/nk3/__init__.py +++ b/pynitrokey/nk3/__init__.py @@ -9,24 +9,24 @@ from typing import List, Optional +from pynitrokey.trussed.base import NitrokeyTrussedBase + from . import bootloader -from .base import Nitrokey3Base from .device import Nitrokey3Device -VID_NITROKEY = 0x20A0 PID_NITROKEY3_DEVICE = 0x42B2 PID_NITROKEY3_LPC55_BOOTLOADER = 0x42DD PID_NITROKEY3_NRF52_BOOTLOADER = 0x42E8 -def list() -> List[Nitrokey3Base]: - devices: List[Nitrokey3Base] = [] +def list() -> List[NitrokeyTrussedBase]: + devices: List[NitrokeyTrussedBase] = [] devices.extend(bootloader.list()) devices.extend(Nitrokey3Device.list()) return devices -def open(path: str) -> Optional[Nitrokey3Base]: +def open(path: str) -> Optional[NitrokeyTrussedBase]: device = Nitrokey3Device.open(path) bootloader_device = bootloader.open(path) if device and bootloader_device: diff --git a/pynitrokey/nk3/admin_app.py b/pynitrokey/nk3/admin_app.py index d584d414..5750f8c1 100644 --- a/pynitrokey/nk3/admin_app.py +++ b/pynitrokey/nk3/admin_app.py @@ -9,9 +9,9 @@ from pynitrokey.helpers import local_critical, local_print from pynitrokey.nk3.device import Command, Nitrokey3Device +from pynitrokey.trussed.utils import Version from .device import VERSION_LEN -from .utils import Version @enum.unique @@ -130,7 +130,7 @@ def _call( data: bytes = b"", ) -> Optional[bytes]: try: - return self.device._call( + return self.device._call_nk3( Command.ADMIN, response_len=response_len, data=command.value.to_bytes(1, "big") + data, @@ -159,7 +159,7 @@ def status(self) -> Status: return status def version(self) -> Version: - reply = self.device._call(Command.VERSION, data=bytes([0x01])) + reply = self.device._call_nk3(Command.VERSION, data=bytes([0x01])) if len(reply) == VERSION_LEN: version = int.from_bytes(reply, "big") return Version.from_int(version) diff --git a/pynitrokey/nk3/bootloader.py b/pynitrokey/nk3/bootloader.py new file mode 100644 index 00000000..7c685c4b --- /dev/null +++ b/pynitrokey/nk3/bootloader.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021-2022 Nitrokey Developers +# +# Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be +# copied, modified, or distributed except according to those terms. + +from typing import List, Optional + +from pynitrokey.trussed import VID_NITROKEY +from pynitrokey.trussed.bootloader import NitrokeyTrussedBootloader +from pynitrokey.trussed.bootloader.lpc55 import NitrokeyTrussedBootloaderLpc55 +from pynitrokey.trussed.bootloader.nrf52 import NitrokeyTrussedBootloaderNrf52 + + +class Nitrokey3Bootloader(NitrokeyTrussedBootloader): + pass + + +class Nitrokey3BootloaderLpc55(NitrokeyTrussedBootloaderLpc55, Nitrokey3Bootloader): + @property + def name(self) -> str: + return "Nitrokey 3 Bootloader (LPC55)" + + @property + def pid(self) -> int: + from . import PID_NITROKEY3_LPC55_BOOTLOADER + + return PID_NITROKEY3_LPC55_BOOTLOADER + + @classmethod + def list(cls) -> List["Nitrokey3BootloaderLpc55"]: + from . import PID_NITROKEY3_LPC55_BOOTLOADER + + return cls.list_vid_pid(VID_NITROKEY, PID_NITROKEY3_LPC55_BOOTLOADER) + + +class Nitrokey3BootloaderNrf52(NitrokeyTrussedBootloaderNrf52, Nitrokey3Bootloader): + @property + def name(self) -> str: + return "Nitrokey 3 Bootloader (NRF52)" + + @property + def pid(self) -> int: + from . import PID_NITROKEY3_NRF52_BOOTLOADER + + return PID_NITROKEY3_NRF52_BOOTLOADER + + @classmethod + def list(cls) -> List["Nitrokey3BootloaderNrf52"]: + from . import PID_NITROKEY3_NRF52_BOOTLOADER + + return cls.list_vid_pid(VID_NITROKEY, PID_NITROKEY3_NRF52_BOOTLOADER) + + @classmethod + def open(cls, path: str) -> Optional["Nitrokey3BootloaderNrf52"]: + from . import PID_NITROKEY3_NRF52_BOOTLOADER + + return cls.open_vid_pid(VID_NITROKEY, PID_NITROKEY3_NRF52_BOOTLOADER, path) + + +def list() -> List[Nitrokey3Bootloader]: + devices: List[Nitrokey3Bootloader] = [] + devices.extend(Nitrokey3BootloaderLpc55.list()) + devices.extend(Nitrokey3BootloaderNrf52.list()) + return devices + + +def open(path: str) -> Optional[Nitrokey3Bootloader]: + lpc55 = Nitrokey3BootloaderLpc55.open(path) + if lpc55: + return lpc55 + + nrf52 = Nitrokey3BootloaderNrf52.open(path) + if nrf52: + return nrf52 + + return None diff --git a/pynitrokey/nk3/device.py b/pynitrokey/nk3/device.py index f73d41f9..b7616719 100644 --- a/pynitrokey/nk3/device.py +++ b/pynitrokey/nk3/device.py @@ -9,19 +9,16 @@ import enum import logging -import platform -import sys from enum import Enum -from typing import List, Optional +from typing import Optional from fido2.ctap import CtapError -from fido2.hid import CtapHidDevice, open_device +from fido2.hid import CtapHidDevice -from pynitrokey.fido2 import device_path_to_str +from pynitrokey.trussed.device import NitrokeyTrussedDevice +from pynitrokey.trussed.utils import Uuid, Version -from .base import Nitrokey3Base from .exceptions import TimeoutException -from .utils import Uuid, Version RNG_LEN = 57 UUID_LEN = 16 @@ -51,45 +48,36 @@ class BootMode(Enum): BOOTROM = enum.auto() -class Nitrokey3Device(Nitrokey3Base): +class Nitrokey3Device(NitrokeyTrussedDevice): """A Nitrokey 3 device running the firmware.""" def __init__(self, device: CtapHidDevice) -> None: - from . import PID_NITROKEY3_DEVICE, VID_NITROKEY - from .admin_app import AdminApp + super().__init__(device) - (vid, pid) = (device.descriptor.vid, device.descriptor.pid) - if (vid, pid) != (VID_NITROKEY, PID_NITROKEY3_DEVICE): - raise ValueError( - "Not a Nitrokey 3 device: expected VID:PID " - f"{VID_NITROKEY:x}:{PID_NITROKEY3_DEVICE:x}, got {vid:x}:{pid:x}" - ) + from .admin_app import AdminApp - self.device = device - self._path = device_path_to_str(device.descriptor.path) self.logger = logger.getChild(self._path) self.admin = AdminApp(self) self.admin.status() @property - def path(self) -> str: - return self._path + def pid(self) -> int: + from . import PID_NITROKEY3_DEVICE + + return PID_NITROKEY3_DEVICE @property def name(self) -> str: return "Nitrokey 3" - def close(self) -> None: - self.device.close() - def reboot(self, mode: BootMode = BootMode.FIRMWARE) -> bool: try: if mode == BootMode.FIRMWARE: - self._call(Command.REBOOT) + self._call_nk3(Command.REBOOT) elif mode == BootMode.BOOTROM: try: - self._call(Command.UPDATE) + self._call_nk3(Command.UPDATE) except CtapError as e: # The admin app returns an Invalid Length error if the user confirmation # request times out @@ -103,7 +91,7 @@ def reboot(self, mode: BootMode = BootMode.FIRMWARE) -> bool: return True def uuid(self) -> Optional[Uuid]: - uuid = self._call(Command.UUID) + uuid = self._call_nk3(Command.UUID) if len(uuid) == 0: # Firmware version 1.0.0 does not support querying the UUID return None @@ -120,53 +108,17 @@ def factory_reset(self) -> None: def factory_reset_app(self, app: str) -> None: self.admin.factory_reset_app(app) - def wink(self) -> None: - self.device.wink() - def rng(self) -> bytes: - return self._call(Command.RNG, response_len=RNG_LEN) + return self._call_nk3(Command.RNG, response_len=RNG_LEN) def otp(self, data: bytes = b"") -> bytes: - return self._call(Command.OTP, data=data) + return self._call_nk3(Command.OTP, data=data) def is_locked(self) -> bool: - response = self._call(Command.LOCKED, response_len=1) + response = self._call_nk3(Command.LOCKED, response_len=1) return response[0] == 1 - def _call( + def _call_nk3( self, command: Command, response_len: Optional[int] = None, data: bytes = b"" ) -> bytes: - response = self.device.call(command.value, data=data) - if response_len is not None and response_len != len(response): - raise ValueError( - f"The response for the CTAPHID {command.name} command has an unexpected length " - f"(expected: {response_len}, actual: {len(response)})" - ) - return response - - @staticmethod - def list() -> List["Nitrokey3Device"]: - devices = [] - for device in CtapHidDevice.list_devices(): - try: - devices.append(Nitrokey3Device(device)) - except ValueError: - # not a Nitrokey 3 device, skip - pass - return devices - - @staticmethod - def open(path: str) -> Optional["Nitrokey3Device"]: - try: - if platform.system() == "Windows": - device = open_device(bytes(path, "utf-8")) - else: - device = open_device(path) - except Exception: - logger.warn(f"No CTAPHID device at path {path}", exc_info=sys.exc_info()) - return None - try: - return Nitrokey3Device(device) - except ValueError: - logger.warn(f"No Nitrokey 3 device at path {path}", exc_info=sys.exc_info()) - return None + return super()._call(command.value, command.name, response_len, data) diff --git a/pynitrokey/nk3/provisioner_app.py b/pynitrokey/nk3/provisioner_app.py index ae512ab4..aed9a7f7 100644 --- a/pynitrokey/nk3/provisioner_app.py +++ b/pynitrokey/nk3/provisioner_app.py @@ -34,7 +34,7 @@ def _call( response_len: Optional[int] = None, data: bytes = b"", ) -> bytes: - return self.device._call( + return self.device._call_nk3( Command.PROVISIONER, response_len=response_len, data=command.value.to_bytes(1, "big") + data, diff --git a/pynitrokey/nk3/updates.py b/pynitrokey/nk3/updates.py index d345238e..27604022 100644 --- a/pynitrokey/nk3/updates.py +++ b/pynitrokey/nk3/updates.py @@ -21,16 +21,17 @@ import pynitrokey from pynitrokey.helpers import Retries -from pynitrokey.nk3.base import Nitrokey3Base -from pynitrokey.nk3.bootloader import ( +from pynitrokey.nk3.bootloader import Nitrokey3Bootloader +from pynitrokey.nk3.device import BootMode, Nitrokey3Device +from pynitrokey.nk3.exceptions import TimeoutException +from pynitrokey.trussed.base import NitrokeyTrussedBase +from pynitrokey.trussed.bootloader import ( + Device, FirmwareContainer, - Nitrokey3Bootloader, Variant, validate_firmware_image, ) -from pynitrokey.nk3.device import BootMode, Nitrokey3Device -from pynitrokey.nk3.exceptions import TimeoutException -from pynitrokey.nk3.utils import Version +from pynitrokey.trussed.utils import Version from pynitrokey.updates import Asset, Release, Repository logger = logging.getLogger(__name__) @@ -167,7 +168,7 @@ def __init__( def update( self, - device: Nitrokey3Base, + device: NitrokeyTrussedBase, image: Optional[str], update_version: Optional[str], ignore_pynitrokey_version: bool = False, @@ -235,7 +236,7 @@ def _prepare_update( ) -> FirmwareContainer: if image: try: - container = FirmwareContainer.parse(image) + container = FirmwareContainer.parse(image, Device.NITROKEY3) except Exception as e: raise self.ui.error("Failed to parse firmware container", e) self._validate_version(current_version, container.version) @@ -282,7 +283,7 @@ def _download_update(self, release: Release) -> FirmwareContainer: ) try: - container = FirmwareContainer.parse(BytesIO(data)) + container = FirmwareContainer.parse(BytesIO(data), Device.NITROKEY3) except Exception as e: raise self.ui.error( f"Failed to parse firmware container for {update.tag}", e @@ -315,7 +316,9 @@ def _validate_version( self.ui.confirm_update_same_version(same_version) @contextmanager - def _get_bootloader(self, device: Nitrokey3Base) -> Iterator[Nitrokey3Bootloader]: + def _get_bootloader( + self, device: NitrokeyTrussedBase + ) -> Iterator[Nitrokey3Bootloader]: if isinstance(device, Nitrokey3Device): self.ui.request_bootloader_confirmation() try: diff --git a/pynitrokey/nk3/utils.py b/pynitrokey/nk3/utils.py index acac5cb5..3d9913ff 100644 --- a/pynitrokey/nk3/utils.py +++ b/pynitrokey/nk3/utils.py @@ -7,229 +7,10 @@ # http://opensource.org/licenses/MIT>, at your option. This file may not be # copied, modified, or distributed except according to those terms. -import dataclasses -from dataclasses import dataclass, field -from functools import total_ordering +from dataclasses import dataclass from typing import Optional -from spsdk.sbfile.misc import BcdVersion3 - - -@dataclass(order=True, frozen=True) -class Uuid: - """UUID of a Nitrokey 3 device.""" - - value: int - - def __str__(self) -> str: - return f"{self.value:032X}" - - def __int__(self) -> int: - return self.value - - -@dataclass(eq=False, frozen=True) -@total_ordering -class Version: - """ - The version of a Nitrokey 3 device, following Semantic Versioning 2.0.0. - - Some sources for version information, namely the version returned by older - devices and the firmware binaries, do not contain the pre-release - component. These instances are marked with *complete=False*. This flag - affects comparison: The pre-release version is only taken into account if - both version instances are complete. - - >>> Version(1, 0, 0) - Version(major=1, minor=0, patch=0, pre=None, build=None) - >>> Version.from_str("1.0.0") - Version(major=1, minor=0, patch=0, pre=None, build=None) - >>> Version.from_v_str("v1.0.0") - Version(major=1, minor=0, patch=0, pre=None, build=None) - >>> Version(1, 0, 0, "rc.1") - Version(major=1, minor=0, patch=0, pre='rc.1', build=None) - >>> Version.from_str("1.0.0-rc.1") - Version(major=1, minor=0, patch=0, pre='rc.1', build=None) - >>> Version.from_v_str("v1.0.0-rc.1") - Version(major=1, minor=0, patch=0, pre='rc.1', build=None) - >>> Version.from_v_str("v1.0.0-rc.1+git") - Version(major=1, minor=0, patch=0, pre='rc.1', build='git') - """ - - major: int - minor: int - patch: int - pre: Optional[str] = None - build: Optional[str] = None - complete: bool = field(default=False, repr=False) - - def __str__(self) -> str: - """ - >>> str(Version(major=1, minor=0, patch=0)) - 'v1.0.0' - >>> str(Version(major=1, minor=0, patch=0, pre="rc.1")) - 'v1.0.0-rc.1' - >>> str(Version(major=1, minor=0, patch=0, pre="rc.1", build="git")) - 'v1.0.0-rc.1+git' - """ - - version = f"v{self.major}.{self.minor}.{self.patch}" - if self.pre: - version += f"-{self.pre}" - if self.build: - version += f"+{self.build}" - return version - - def __eq__(self, other: object) -> bool: - """ - >>> Version(1, 0, 0) == Version(1, 0, 0) - True - >>> Version(1, 0, 0) == Version(1, 0, 1) - False - >>> Version.from_str("1.0.0-rc.1") == Version.from_str("1.0.0-rc.1") - True - >>> Version.from_str("1.0.0") == Version.from_str("1.0.0-rc.1") - False - >>> Version.from_str("1.0.0") == Version.from_str("1.0.0+git") - True - >>> Version(1, 0, 0, complete=False) == Version.from_str("1.0.0-rc.1") - True - >>> Version(1, 0, 0, complete=False) == Version.from_str("1.0.1") - False - """ - if not isinstance(other, Version): - return NotImplemented - lhs = (self.major, self.minor, self.patch) - rhs = (other.major, other.minor, other.patch) - - if lhs != rhs: - return False - if self.complete and other.complete: - return self.pre == other.pre - return True - - def __lt__(self, other: object) -> bool: - """ - >>> def cmp(a, b): - ... return Version.from_str(a) < Version.from_str(b) - >>> cmp("1.0.0", "1.0.0") - False - >>> cmp("1.0.0", "1.0.1") - True - >>> cmp("1.1.0", "2.0.0") - True - >>> cmp("1.1.0", "1.0.3") - False - >>> cmp("1.0.0-rc.1", "1.0.0-rc.1") - False - >>> cmp("1.0.0-rc.1", "1.0.0") - True - >>> cmp("1.0.0", "1.0.0-rc.1") - False - >>> cmp("1.0.0-rc.1", "1.0.0-rc.2") - True - >>> cmp("1.0.0-rc.2", "1.0.0-rc.1") - False - >>> cmp("1.0.0-alpha.1", "1.0.0-rc.1") - True - >>> cmp("1.0.0-alpha.1", "1.0.0-rc.1.0") - True - >>> cmp("1.0.0-alpha.1", "1.0.0-alpha.1.0") - True - >>> cmp("1.0.0-rc.2", "1.0.0-rc.10") - True - >>> Version(1, 0, 0, "rc.1") < Version(1, 0, 0) - False - """ - - if not isinstance(other, Version): - return NotImplemented - - lhs = (self.major, self.minor, self.patch) - rhs = (other.major, other.minor, other.patch) - - if lhs == rhs and self.complete and other.complete: - # relevant rules: - # 1. pre-releases sort before regular releases - # 2. two pre-releases for the same core version are sorted by the pre-release component - # (split into subcomponents) - if self.pre == other.pre: - return False - elif self.pre is None: - # self is regular release, other is pre-release - return False - elif other.pre is None: - # self is pre-release, other is regular release - return True - else: - # both are pre-releases - def int_or_str(s: str) -> object: - if s.isdigit(): - return int(s) - else: - return s - - lhs_pre = [int_or_str(s) for s in self.pre.split(".")] - rhs_pre = [int_or_str(s) for s in other.pre.split(".")] - return lhs_pre < rhs_pre - else: - return lhs < rhs - - def core(self) -> "Version": - """ - Returns the core part of this version, i. e. the version without the - pre-release and build components. - - >>> Version(1, 0, 0).core() - Version(major=1, minor=0, patch=0, pre=None, build=None) - >>> Version(1, 0, 0, "rc.1").core() - Version(major=1, minor=0, patch=0, pre=None, build=None) - >>> Version(1, 0, 0, "rc.1", "git").core() - Version(major=1, minor=0, patch=0, pre=None, build=None) - """ - return dataclasses.replace(self, pre=None, build=None) - - @classmethod - def from_int(cls, version: int) -> "Version": - # This is the reverse of the calculation in runners/lpc55/build.rs (CARGO_PKG_VERSION): - # https://github.com/Nitrokey/nitrokey-3-firmware/blob/main/runners/lpc55/build.rs#L131 - major = version >> 22 - minor = (version >> 6) & ((1 << 16) - 1) - patch = version & ((1 << 6) - 1) - return cls(major=major, minor=minor, patch=patch) - - @classmethod - def from_str(cls, s: str) -> "Version": - version_parts = s.split("+", maxsplit=1) - s = version_parts[0] - build = version_parts[1] if len(version_parts) == 2 else None - - version_parts = s.split("-", maxsplit=1) - pre = version_parts[1] if len(version_parts) == 2 else None - - str_parts = version_parts[0].split(".") - if len(str_parts) != 3: - raise ValueError(f"Invalid firmware version: {s}") - - try: - int_parts = [int(part) for part in str_parts] - except ValueError: - raise ValueError(f"Invalid component in firmware version: {s}") - - [major, minor, patch] = int_parts - return cls( - major=major, minor=minor, patch=patch, pre=pre, build=build, complete=True - ) - - @classmethod - def from_v_str(cls, s: str) -> "Version": - if not s.startswith("v"): - raise ValueError(f"Missing v prefix for firmware version: {s}") - return Version.from_str(s[1:]) - - @classmethod - def from_bcd_version(cls, version: BcdVersion3) -> "Version": - return cls(major=version.major, minor=version.minor, patch=version.service) +from pynitrokey.trussed.utils import Version @dataclass diff --git a/pynitrokey/trussed/__init__.py b/pynitrokey/trussed/__init__.py new file mode 100644 index 00000000..cef81906 --- /dev/null +++ b/pynitrokey/trussed/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021-2024 Nitrokey Developers +# +# Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be +# copied, modified, or distributed except according to those terms. + +VID_NITROKEY = 0x20A0 diff --git a/pynitrokey/nk3/base.py b/pynitrokey/trussed/base.py similarity index 56% rename from pynitrokey/nk3/base.py rename to pynitrokey/trussed/base.py index 08fe93d7..3608ddf2 100644 --- a/pynitrokey/nk3/base.py +++ b/pynitrokey/trussed/base.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2021 Nitrokey Developers +# Copyright 2021-2024 Nitrokey Developers # # Licensed under the Apache License, Version 2.0, or the MIT license T: return self @@ -24,6 +28,22 @@ def __enter__(self: T) -> T: def __exit__(self, exc_type: None, exc_val: None, exc_tb: None) -> None: self.close() + def validate_vid_pid(self, vid: int, pid: int) -> None: + if (vid, pid) != (self.vid, self.pid): + raise ValueError( + f"Not a {self.name} device: expected VID:PID " + f"{self.vid:x}:{self.pid:x}, got {vid:x}:{pid:x}" + ) + + @property + def vid(self) -> int: + return VID_NITROKEY + + @property + @abstractmethod + def pid(self) -> int: + ... + @property @abstractmethod def path(self) -> str: diff --git a/pynitrokey/nk3/bootloader/__init__.py b/pynitrokey/trussed/bootloader/__init__.py similarity index 84% rename from pynitrokey/nk3/bootloader/__init__.py rename to pynitrokey/trussed/bootloader/__init__.py index b90e1f91..63019357 100644 --- a/pynitrokey/nk3/bootloader/__init__.py +++ b/pynitrokey/trussed/bootloader/__init__.py @@ -19,7 +19,7 @@ from typing import Callable, Dict, List, Optional, Tuple, Union from zipfile import ZipFile -from ..base import Nitrokey3Base +from ..base import NitrokeyTrussedBase from ..utils import Version logger = logging.getLogger(__name__) @@ -28,6 +28,17 @@ ProgressCallback = Callable[[int, int], None] +class Device(enum.Enum): + NITROKEY3 = "Nitrokey 3" + + @classmethod + def from_str(cls, s: str) -> "Device": + for device in cls: + if device.value == s: + return device + raise ValueError(f"Unknown device {s}") + + class Variant(enum.Enum): LPC55 = "lpc55" NRF52 = "nrf52" @@ -57,7 +68,7 @@ class FirmwareContainer: images: Dict[Variant, bytes] @classmethod - def parse(cls, path: Union[str, BytesIO]) -> "FirmwareContainer": + def parse(cls, path: Union[str, BytesIO], device: Device) -> "FirmwareContainer": with ZipFile(path) as z: checksum_lines = z.read("sha256sums").decode("utf-8").splitlines() checksum_pairs = [line.split(" ", maxsplit=1) for line in checksum_lines] @@ -66,9 +77,10 @@ def parse(cls, path: Union[str, BytesIO]) -> "FirmwareContainer": manifest_bytes = z.read("manifest.json") _validate_checksum(checksums, "manifest.json", manifest_bytes) manifest = json.loads(manifest_bytes) - if manifest["device"] != "Nitrokey 3": - raise Exception( - f"Unexpected device value in manifest: {manifest['device']}" + actual_device = Device.from_str(manifest["device"]) + if actual_device != device: + raise ValueError( + f"Expected firmware container for {device.value}, got {actual_device.value}" ) version = Version.from_v_str(manifest["version"]) pynitrokey = None @@ -95,7 +107,7 @@ class FirmwareMetadata: signed_by_nitrokey: bool = False -class Nitrokey3Bootloader(Nitrokey3Base): +class NitrokeyTrussedBootloader(NitrokeyTrussedBase): @abstractmethod def update( self, @@ -110,31 +122,6 @@ def variant(self) -> Variant: ... -def list() -> List[Nitrokey3Bootloader]: - from .lpc55 import Nitrokey3BootloaderLpc55 - from .nrf52 import Nitrokey3BootloaderNrf52 - - devices: List[Nitrokey3Bootloader] = [] - devices.extend(Nitrokey3BootloaderLpc55.list()) - devices.extend(Nitrokey3BootloaderNrf52.list()) - return devices - - -def open(path: str) -> Optional[Nitrokey3Bootloader]: - from .lpc55 import Nitrokey3BootloaderLpc55 - from .nrf52 import Nitrokey3BootloaderNrf52 - - lpc55 = Nitrokey3BootloaderLpc55.open(path) - if lpc55: - return lpc55 - - nrf52 = Nitrokey3BootloaderNrf52.open(path) - if nrf52: - return nrf52 - - return None - - def get_firmware_filename_pattern(variant: Variant) -> Pattern[str]: from .lpc55 import FILENAME_PATTERN as FILENAME_PATTERN_LPC55 from .nrf52 import FILENAME_PATTERN as FILENAME_PATTERN_NRF52 diff --git a/pynitrokey/nk3/bootloader/lpc55.py b/pynitrokey/trussed/bootloader/lpc55.py similarity index 79% rename from pynitrokey/nk3/bootloader/lpc55.py rename to pynitrokey/trussed/bootloader/lpc55.py index 22d3c682..6686fb28 100644 --- a/pynitrokey/nk3/bootloader/lpc55.py +++ b/pynitrokey/trussed/bootloader/lpc55.py @@ -11,7 +11,7 @@ import platform import re import sys -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, TypeVar from spsdk.mboot.error_codes import StatusCode from spsdk.mboot.interfaces.usb import MbootUSBInterface @@ -21,33 +21,29 @@ from spsdk.utils.interfaces.device.usb_device import UsbDevice from spsdk.utils.usbfilter import USBDeviceFilter -from ..utils import Uuid, Version -from . import FirmwareMetadata, Nitrokey3Bootloader, ProgressCallback, Variant +from pynitrokey.trussed.utils import Uuid, Version + +from . import FirmwareMetadata, NitrokeyTrussedBootloader, ProgressCallback, Variant RKTH = bytes.fromhex("050aad3e77791a81e59c5b2ba5a158937e9460ee325d8ccba09734b8fdebb171") KEK = bytes([0xAA] * 32) UUID_LEN = 4 FILENAME_PATTERN = re.compile("(firmware|alpha)-nk3..-lpc55-(?P.*)\\.sb2$") +T = TypeVar("T", bound="NitrokeyTrussedBootloaderLpc55") + logger = logging.getLogger(__name__) -class Nitrokey3BootloaderLpc55(Nitrokey3Bootloader): +class NitrokeyTrussedBootloaderLpc55(NitrokeyTrussedBootloader): """A Nitrokey 3 device running the LPC55 bootloader.""" def __init__(self, device: UsbDevice): - from .. import PID_NITROKEY3_LPC55_BOOTLOADER, VID_NITROKEY - - if (device.vid, device.pid) != (VID_NITROKEY, PID_NITROKEY3_LPC55_BOOTLOADER): - raise ValueError( - "Not a Nitrokey 3 device: expected VID:PID " - f"{VID_NITROKEY:x}:{PID_NITROKEY3_LPC55_BOOTLOADER:x}, " - f"got {device.vid:x}:{device.pid:x}" - ) + self.validate_vid_pid(device.vid, device.pid) self._path = device.path self.device = McuBoot(MbootUSBInterface(device)) - def __enter__(self) -> "Nitrokey3BootloaderLpc55": + def __enter__(self: T) -> T: self.device.open() return self @@ -61,10 +57,6 @@ def path(self) -> str: return self._path.decode("UTF-8") return self._path - @property - def name(self) -> str: - return "Nitrokey 3 Bootloader (LPC55)" - @property def status(self) -> Tuple[int, str]: code = self.device.status_code @@ -116,25 +108,21 @@ def update( f"Firmware update failed with status code {code}: {message}" ) - @staticmethod - def list() -> List["Nitrokey3BootloaderLpc55"]: - from .. import PID_NITROKEY3_LPC55_BOOTLOADER, VID_NITROKEY - - device_filter = USBDeviceFilter( - f"0x{VID_NITROKEY:x}:0x{PID_NITROKEY3_LPC55_BOOTLOADER:x}" - ) + @classmethod + def list_vid_pid(cls: type[T], vid: int, pid: int) -> list[T]: + device_filter = USBDeviceFilter(f"0x{vid:x}:0x{pid:x}") devices = [] for device in UsbDevice.enumerate(device_filter): try: - devices.append(Nitrokey3BootloaderLpc55(device)) + devices.append(cls(device)) except ValueError: logger.warn( f"Invalid Nitrokey 3 LPC55 bootloader returned by enumeration: {device}" ) return devices - @staticmethod - def open(path: str) -> Optional["Nitrokey3BootloaderLpc55"]: + @classmethod + def open(cls: type[T], path: str) -> Optional[T]: device_filter = USBDeviceFilter(path) devices = UsbDevice.enumerate(device_filter) if len(devices) == 0: @@ -145,7 +133,7 @@ def open(path: str) -> Optional["Nitrokey3BootloaderLpc55"]: return None try: - return Nitrokey3BootloaderLpc55(devices[0]) + return cls(devices[0]) except ValueError: logger.warn( f"No Nitrokey 3 bootloader at path {path}", exc_info=sys.exc_info() diff --git a/pynitrokey/nk3/bootloader/nrf52.py b/pynitrokey/trussed/bootloader/nrf52.py similarity index 89% rename from pynitrokey/nk3/bootloader/nrf52.py rename to pynitrokey/trussed/bootloader/nrf52.py index 14360b61..06b15cc0 100644 --- a/pynitrokey/nk3/bootloader/nrf52.py +++ b/pynitrokey/trussed/bootloader/nrf52.py @@ -13,15 +13,16 @@ import time from dataclasses import dataclass from io import BytesIO -from typing import Optional +from typing import Optional, TypeVar from zipfile import ZipFile import ecdsa import ecdsa.curves from ecdsa.keys import BadSignatureError -from ..utils import Uuid, Version -from . import FirmwareMetadata, Nitrokey3Bootloader, ProgressCallback, Variant +from pynitrokey.trussed.utils import Uuid, Version + +from . import FirmwareMetadata, NitrokeyTrussedBootloader, ProgressCallback, Variant from .nrf52_upload.dfu.dfu_transport import DfuEvent from .nrf52_upload.dfu.dfu_transport_serial import DfuTransportSerial from .nrf52_upload.dfu.init_packet_pb import InitPacketPB @@ -33,6 +34,8 @@ FILENAME_PATTERN = re.compile("(firmware|alpha)-nk3..-nrf52-(?P.*)\\.zip$") +T = TypeVar("T", bound="NitrokeyTrussedBootloaderNrf52") + @dataclass class SignatureKey: @@ -130,7 +133,7 @@ def parse(cls, data: bytes) -> "Image": return image -class Nitrokey3BootloaderNrf52(Nitrokey3Bootloader): +class NitrokeyTrussedBootloaderNrf52(NitrokeyTrussedBootloader): def __init__(self, path: str, uuid: int) -> None: self._path = path self._uuid = uuid @@ -143,10 +146,6 @@ def variant(self) -> Variant: def path(self) -> str: return self._path - @property - def name(self) -> str: - return "Nitrokey 3 Bootloader (NRF52)" - def close(self) -> None: pass @@ -180,17 +179,15 @@ def update(self, data: bytes, callback: Optional[ProgressCallback] = None) -> No dfu.send_firmware(image.firmware_bin) dfu.close() - @staticmethod - def list() -> list["Nitrokey3BootloaderNrf52"]: - return [ - Nitrokey3BootloaderNrf52(port, serial) for port, serial in _list_ports() - ] + @classmethod + def list_vid_pid(cls: type[T], vid: int, pid: int) -> list[T]: + return [cls(port, serial) for port, serial in _list_ports(vid, pid)] - @staticmethod - def open(path: str) -> Optional["Nitrokey3BootloaderNrf52"]: - for port, serial in _list_ports(): + @classmethod + def open_vid_pid(cls: type[T], vid: int, pid: int, path: str) -> Optional[T]: + for port, serial in _list_ports(vid, pid): if path == port: - return Nitrokey3BootloaderNrf52(path, serial) + return cls(path, serial) return None @@ -205,9 +202,7 @@ def __call__(self, progress: int) -> None: self.callback(self.n, self.total) -def _list_ports() -> list[tuple[str, int]]: - from .. import PID_NITROKEY3_NRF52_BOOTLOADER, VID_NITROKEY - +def _list_ports(vid: int, pid: int) -> list[tuple[str, int]]: ports = [] for device in DeviceLister().enumerate(): vendor_id = int(device.vendor_id, base=16) @@ -217,7 +212,7 @@ def _list_ports() -> list[tuple[str, int]]: logger.warn( f"Nitrokey 3 NRF52 bootloader has multiple com ports: {device.com_ports}" ) - if vendor_id == VID_NITROKEY and product_id == PID_NITROKEY3_NRF52_BOOTLOADER: + if vendor_id == vid and product_id == pid: port = device.com_ports[0] serial = int(device.serial_number, base=16) logger.debug(f"Found Nitrokey 3 NRF52 bootloader with port {port}") diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/README.md b/pynitrokey/trussed/bootloader/nrf52_upload/README.md similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/README.md rename to pynitrokey/trussed/bootloader/nrf52_upload/README.md diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/__init__.py b/pynitrokey/trussed/bootloader/nrf52_upload/__init__.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/__init__.py rename to pynitrokey/trussed/bootloader/nrf52_upload/__init__.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/dfu/__init__.py b/pynitrokey/trussed/bootloader/nrf52_upload/dfu/__init__.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/dfu/__init__.py rename to pynitrokey/trussed/bootloader/nrf52_upload/dfu/__init__.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/dfu/crc16.py b/pynitrokey/trussed/bootloader/nrf52_upload/dfu/crc16.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/dfu/crc16.py rename to pynitrokey/trussed/bootloader/nrf52_upload/dfu/crc16.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/dfu/dfu_cc_pb2.py b/pynitrokey/trussed/bootloader/nrf52_upload/dfu/dfu_cc_pb2.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/dfu/dfu_cc_pb2.py rename to pynitrokey/trussed/bootloader/nrf52_upload/dfu/dfu_cc_pb2.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/dfu/dfu_transport.py b/pynitrokey/trussed/bootloader/nrf52_upload/dfu/dfu_transport.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/dfu/dfu_transport.py rename to pynitrokey/trussed/bootloader/nrf52_upload/dfu/dfu_transport.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/dfu/dfu_transport_serial.py b/pynitrokey/trussed/bootloader/nrf52_upload/dfu/dfu_transport_serial.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/dfu/dfu_transport_serial.py rename to pynitrokey/trussed/bootloader/nrf52_upload/dfu/dfu_transport_serial.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/dfu/dfu_trigger.py b/pynitrokey/trussed/bootloader/nrf52_upload/dfu/dfu_trigger.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/dfu/dfu_trigger.py rename to pynitrokey/trussed/bootloader/nrf52_upload/dfu/dfu_trigger.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/dfu/init_packet_pb.py b/pynitrokey/trussed/bootloader/nrf52_upload/dfu/init_packet_pb.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/dfu/init_packet_pb.py rename to pynitrokey/trussed/bootloader/nrf52_upload/dfu/init_packet_pb.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/dfu/manifest.py b/pynitrokey/trussed/bootloader/nrf52_upload/dfu/manifest.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/dfu/manifest.py rename to pynitrokey/trussed/bootloader/nrf52_upload/dfu/manifest.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/dfu/model.py b/pynitrokey/trussed/bootloader/nrf52_upload/dfu/model.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/dfu/model.py rename to pynitrokey/trussed/bootloader/nrf52_upload/dfu/model.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/dfu/nrfhex.py b/pynitrokey/trussed/bootloader/nrf52_upload/dfu/nrfhex.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/dfu/nrfhex.py rename to pynitrokey/trussed/bootloader/nrf52_upload/dfu/nrfhex.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/dfu/package.py b/pynitrokey/trussed/bootloader/nrf52_upload/dfu/package.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/dfu/package.py rename to pynitrokey/trussed/bootloader/nrf52_upload/dfu/package.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/dfu/signing.py b/pynitrokey/trussed/bootloader/nrf52_upload/dfu/signing.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/dfu/signing.py rename to pynitrokey/trussed/bootloader/nrf52_upload/dfu/signing.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/exceptions.py b/pynitrokey/trussed/bootloader/nrf52_upload/exceptions.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/exceptions.py rename to pynitrokey/trussed/bootloader/nrf52_upload/exceptions.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/lister/__init__.py b/pynitrokey/trussed/bootloader/nrf52_upload/lister/__init__.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/lister/__init__.py rename to pynitrokey/trussed/bootloader/nrf52_upload/lister/__init__.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/lister/device_lister.py b/pynitrokey/trussed/bootloader/nrf52_upload/lister/device_lister.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/lister/device_lister.py rename to pynitrokey/trussed/bootloader/nrf52_upload/lister/device_lister.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/lister/enumerated_device.py b/pynitrokey/trussed/bootloader/nrf52_upload/lister/enumerated_device.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/lister/enumerated_device.py rename to pynitrokey/trussed/bootloader/nrf52_upload/lister/enumerated_device.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/lister/lister_backend.py b/pynitrokey/trussed/bootloader/nrf52_upload/lister/lister_backend.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/lister/lister_backend.py rename to pynitrokey/trussed/bootloader/nrf52_upload/lister/lister_backend.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/lister/unix/__init__.py b/pynitrokey/trussed/bootloader/nrf52_upload/lister/unix/__init__.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/lister/unix/__init__.py rename to pynitrokey/trussed/bootloader/nrf52_upload/lister/unix/__init__.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/lister/unix/unix_lister.py b/pynitrokey/trussed/bootloader/nrf52_upload/lister/unix/unix_lister.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/lister/unix/unix_lister.py rename to pynitrokey/trussed/bootloader/nrf52_upload/lister/unix/unix_lister.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/lister/windows/__init__.py b/pynitrokey/trussed/bootloader/nrf52_upload/lister/windows/__init__.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/lister/windows/__init__.py rename to pynitrokey/trussed/bootloader/nrf52_upload/lister/windows/__init__.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/lister/windows/constants.py b/pynitrokey/trussed/bootloader/nrf52_upload/lister/windows/constants.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/lister/windows/constants.py rename to pynitrokey/trussed/bootloader/nrf52_upload/lister/windows/constants.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/lister/windows/lister_win32.py b/pynitrokey/trussed/bootloader/nrf52_upload/lister/windows/lister_win32.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/lister/windows/lister_win32.py rename to pynitrokey/trussed/bootloader/nrf52_upload/lister/windows/lister_win32.py diff --git a/pynitrokey/nk3/bootloader/nrf52_upload/lister/windows/structures.py b/pynitrokey/trussed/bootloader/nrf52_upload/lister/windows/structures.py similarity index 100% rename from pynitrokey/nk3/bootloader/nrf52_upload/lister/windows/structures.py rename to pynitrokey/trussed/bootloader/nrf52_upload/lister/windows/structures.py diff --git a/pynitrokey/trussed/device.py b/pynitrokey/trussed/device.py new file mode 100644 index 00000000..6ea4780c --- /dev/null +++ b/pynitrokey/trussed/device.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021-2024 Nitrokey Developers +# +# Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be +# copied, modified, or distributed except according to those terms. + +import logging +import platform +import sys +from abc import abstractmethod +from typing import Optional, TypeVar + +from fido2.hid import CtapHidDevice, open_device + +from pynitrokey.fido2 import device_path_to_str + +from .base import NitrokeyTrussedBase + +T = TypeVar("T", bound="NitrokeyTrussedDevice") + +logger = logging.getLogger(__name__) + + +class NitrokeyTrussedDevice(NitrokeyTrussedBase): + def __init__(self, device: CtapHidDevice) -> None: + self.validate_vid_pid(device.descriptor.vid, device.descriptor.pid) + + self.device = device + self._path = device_path_to_str(device.descriptor.path) + + @property + def path(self) -> str: + return self._path + + def close(self) -> None: + self.device.close() + + def wink(self) -> None: + self.device.wink() + + def _call( + self, + command: int, + command_name: str, + response_len: Optional[int] = None, + data: bytes = b"", + ) -> bytes: + response = self.device.call(command, data=data) + if response_len is not None and response_len != len(response): + raise ValueError( + f"The response for the CTAPHID {command_name} command has an unexpected length " + f"(expected: {response_len}, actual: {len(response)})" + ) + return response + + @classmethod + def open(cls: type[T], path: str) -> Optional[T]: + try: + if platform.system() == "Windows": + device = open_device(bytes(path, "utf-8")) + else: + device = open_device(path) + except Exception: + logger.warn(f"No CTAPHID device at path {path}", exc_info=sys.exc_info()) + return None + try: + return cls(device) + except ValueError: + logger.warn(f"No Nitrokey device at path {path}", exc_info=sys.exc_info()) + return None + + @classmethod + def list(cls: type[T]) -> list[T]: + devices = [] + for device in CtapHidDevice.list_devices(): + try: + devices.append(cls(device)) + except ValueError: + # not the correct device type, skip + pass + return devices diff --git a/pynitrokey/trussed/utils.py b/pynitrokey/trussed/utils.py new file mode 100644 index 00000000..1ff4e5fd --- /dev/null +++ b/pynitrokey/trussed/utils.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021-2024 Nitrokey Developers +# +# Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be +# copied, modified, or distributed except according to those terms. + +import dataclasses +from dataclasses import dataclass, field +from functools import total_ordering +from typing import Optional + +from spsdk.sbfile.misc import BcdVersion3 + + +@dataclass(order=True, frozen=True) +class Uuid: + """UUID of a Nitrokey Trussed device.""" + + value: int + + def __str__(self) -> str: + return f"{self.value:032X}" + + def __int__(self) -> int: + return self.value + + +@dataclass(eq=False, frozen=True) +@total_ordering +class Version: + """ + The version of a Nitrokey Trussed device, following Semantic Versioning + 2.0.0. + + Some sources for version information, namely the version returned by older + devices and the firmware binaries, do not contain the pre-release + component. These instances are marked with *complete=False*. This flag + affects comparison: The pre-release version is only taken into account if + both version instances are complete. + + >>> Version(1, 0, 0) + Version(major=1, minor=0, patch=0, pre=None, build=None) + >>> Version.from_str("1.0.0") + Version(major=1, minor=0, patch=0, pre=None, build=None) + >>> Version.from_v_str("v1.0.0") + Version(major=1, minor=0, patch=0, pre=None, build=None) + >>> Version(1, 0, 0, "rc.1") + Version(major=1, minor=0, patch=0, pre='rc.1', build=None) + >>> Version.from_str("1.0.0-rc.1") + Version(major=1, minor=0, patch=0, pre='rc.1', build=None) + >>> Version.from_v_str("v1.0.0-rc.1") + Version(major=1, minor=0, patch=0, pre='rc.1', build=None) + >>> Version.from_v_str("v1.0.0-rc.1+git") + Version(major=1, minor=0, patch=0, pre='rc.1', build='git') + """ + + major: int + minor: int + patch: int + pre: Optional[str] = None + build: Optional[str] = None + complete: bool = field(default=False, repr=False) + + def __str__(self) -> str: + """ + >>> str(Version(major=1, minor=0, patch=0)) + 'v1.0.0' + >>> str(Version(major=1, minor=0, patch=0, pre="rc.1")) + 'v1.0.0-rc.1' + >>> str(Version(major=1, minor=0, patch=0, pre="rc.1", build="git")) + 'v1.0.0-rc.1+git' + """ + + version = f"v{self.major}.{self.minor}.{self.patch}" + if self.pre: + version += f"-{self.pre}" + if self.build: + version += f"+{self.build}" + return version + + def __eq__(self, other: object) -> bool: + """ + >>> Version(1, 0, 0) == Version(1, 0, 0) + True + >>> Version(1, 0, 0) == Version(1, 0, 1) + False + >>> Version.from_str("1.0.0-rc.1") == Version.from_str("1.0.0-rc.1") + True + >>> Version.from_str("1.0.0") == Version.from_str("1.0.0-rc.1") + False + >>> Version.from_str("1.0.0") == Version.from_str("1.0.0+git") + True + >>> Version(1, 0, 0, complete=False) == Version.from_str("1.0.0-rc.1") + True + >>> Version(1, 0, 0, complete=False) == Version.from_str("1.0.1") + False + """ + if not isinstance(other, Version): + return NotImplemented + lhs = (self.major, self.minor, self.patch) + rhs = (other.major, other.minor, other.patch) + + if lhs != rhs: + return False + if self.complete and other.complete: + return self.pre == other.pre + return True + + def __lt__(self, other: object) -> bool: + """ + >>> def cmp(a, b): + ... return Version.from_str(a) < Version.from_str(b) + >>> cmp("1.0.0", "1.0.0") + False + >>> cmp("1.0.0", "1.0.1") + True + >>> cmp("1.1.0", "2.0.0") + True + >>> cmp("1.1.0", "1.0.3") + False + >>> cmp("1.0.0-rc.1", "1.0.0-rc.1") + False + >>> cmp("1.0.0-rc.1", "1.0.0") + True + >>> cmp("1.0.0", "1.0.0-rc.1") + False + >>> cmp("1.0.0-rc.1", "1.0.0-rc.2") + True + >>> cmp("1.0.0-rc.2", "1.0.0-rc.1") + False + >>> cmp("1.0.0-alpha.1", "1.0.0-rc.1") + True + >>> cmp("1.0.0-alpha.1", "1.0.0-rc.1.0") + True + >>> cmp("1.0.0-alpha.1", "1.0.0-alpha.1.0") + True + >>> cmp("1.0.0-rc.2", "1.0.0-rc.10") + True + >>> Version(1, 0, 0, "rc.1") < Version(1, 0, 0) + False + """ + + if not isinstance(other, Version): + return NotImplemented + + lhs = (self.major, self.minor, self.patch) + rhs = (other.major, other.minor, other.patch) + + if lhs == rhs and self.complete and other.complete: + # relevant rules: + # 1. pre-releases sort before regular releases + # 2. two pre-releases for the same core version are sorted by the pre-release component + # (split into subcomponents) + if self.pre == other.pre: + return False + elif self.pre is None: + # self is regular release, other is pre-release + return False + elif other.pre is None: + # self is pre-release, other is regular release + return True + else: + # both are pre-releases + def int_or_str(s: str) -> object: + if s.isdigit(): + return int(s) + else: + return s + + lhs_pre = [int_or_str(s) for s in self.pre.split(".")] + rhs_pre = [int_or_str(s) for s in other.pre.split(".")] + return lhs_pre < rhs_pre + else: + return lhs < rhs + + def core(self) -> "Version": + """ + Returns the core part of this version, i. e. the version without the + pre-release and build components. + + >>> Version(1, 0, 0).core() + Version(major=1, minor=0, patch=0, pre=None, build=None) + >>> Version(1, 0, 0, "rc.1").core() + Version(major=1, minor=0, patch=0, pre=None, build=None) + >>> Version(1, 0, 0, "rc.1", "git").core() + Version(major=1, minor=0, patch=0, pre=None, build=None) + """ + return dataclasses.replace(self, pre=None, build=None) + + @classmethod + def from_int(cls, version: int) -> "Version": + # This is the reverse of the calculation in runners/lpc55/build.rs (CARGO_PKG_VERSION): + # https://github.com/Nitrokey/nitrokey-3-firmware/blob/main/runners/lpc55/build.rs#L131 + major = version >> 22 + minor = (version >> 6) & ((1 << 16) - 1) + patch = version & ((1 << 6) - 1) + return cls(major=major, minor=minor, patch=patch) + + @classmethod + def from_str(cls, s: str) -> "Version": + version_parts = s.split("+", maxsplit=1) + s = version_parts[0] + build = version_parts[1] if len(version_parts) == 2 else None + + version_parts = s.split("-", maxsplit=1) + pre = version_parts[1] if len(version_parts) == 2 else None + + str_parts = version_parts[0].split(".") + if len(str_parts) != 3: + raise ValueError(f"Invalid firmware version: {s}") + + try: + int_parts = [int(part) for part in str_parts] + except ValueError: + raise ValueError(f"Invalid component in firmware version: {s}") + + [major, minor, patch] = int_parts + return cls( + major=major, minor=minor, patch=patch, pre=pre, build=build, complete=True + ) + + @classmethod + def from_v_str(cls, s: str) -> "Version": + if not s.startswith("v"): + raise ValueError(f"Missing v prefix for firmware version: {s}") + return Version.from_str(s[1:]) + + @classmethod + def from_bcd_version(cls, version: BcdVersion3) -> "Version": + return cls(major=version.major, minor=version.minor, patch=version.service) diff --git a/pyproject.toml b/pyproject.toml index 6a747606..86ebebb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,12 +112,12 @@ warn_return_any = false # pynitrokey.nk3.bootloader.nrf52_upload is only temporary in this package [[tool.mypy.overrides]] -module = "pynitrokey.nk3.bootloader.nrf52_upload.*" +module = "pynitrokey.trussed.bootloader.nrf52_upload.*" ignore_errors = true # nrf52 has to use the untyped nrf52_upload module [[tool.mypy.overrides]] -module = "pynitrokey.nk3.bootloader.nrf52" +module = "pynitrokey.trussed.bootloader.nrf52" disallow_untyped_calls = false # libraries without annotations From 43b55eeb921fd3dc972f4fa02ddf013581e70d5f Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Tue, 23 Jan 2024 13:51:09 +0100 Subject: [PATCH 2/8] nk3: Move admin_app into trussed module --- pynitrokey/cli/nk3/__init__.py | 2 +- pynitrokey/cli/nk3/test.py | 2 +- pynitrokey/nk3/device.py | 10 ----- pynitrokey/{nk3 => trussed}/admin_app.py | 51 +++++++++++++++++++----- pynitrokey/trussed/device.py | 25 ++++++++++++ 5 files changed, 69 insertions(+), 21 deletions(-) rename pynitrokey/{nk3 => trussed}/admin_app.py (82%) diff --git a/pynitrokey/cli/nk3/__init__.py b/pynitrokey/cli/nk3/__init__.py index 28af4431..7d3c05e1 100644 --- a/pynitrokey/cli/nk3/__init__.py +++ b/pynitrokey/cli/nk3/__init__.py @@ -29,12 +29,12 @@ ) from pynitrokey.nk3 import list as list_nk3 from pynitrokey.nk3 import open as open_nk3 -from pynitrokey.nk3.admin_app import AdminApp from pynitrokey.nk3.bootloader import Nitrokey3Bootloader from pynitrokey.nk3.device import BootMode, Nitrokey3Device from pynitrokey.nk3.exceptions import TimeoutException from pynitrokey.nk3.provisioner_app import ProvisionerApp from pynitrokey.nk3.updates import REPOSITORY, get_firmware_update +from pynitrokey.trussed.admin_app import AdminApp from pynitrokey.trussed.base import NitrokeyTrussedBase from pynitrokey.trussed.bootloader import ( Device, diff --git a/pynitrokey/cli/nk3/test.py b/pynitrokey/cli/nk3/test.py index cdf150ab..e281bb35 100644 --- a/pynitrokey/cli/nk3/test.py +++ b/pynitrokey/cli/nk3/test.py @@ -25,9 +25,9 @@ from pynitrokey.fido2 import device_path_to_str from pynitrokey.fido2.client import NKFido2Client from pynitrokey.helpers import local_print -from pynitrokey.nk3.admin_app import AdminApp from pynitrokey.nk3.device import Nitrokey3Device from pynitrokey.nk3.utils import Fido2Certs +from pynitrokey.trussed.admin_app import AdminApp from pynitrokey.trussed.base import NitrokeyTrussedBase from pynitrokey.trussed.utils import Uuid, Version diff --git a/pynitrokey/nk3/device.py b/pynitrokey/nk3/device.py index b7616719..24611652 100644 --- a/pynitrokey/nk3/device.py +++ b/pynitrokey/nk3/device.py @@ -8,7 +8,6 @@ # copied, modified, or distributed except according to those terms. import enum -import logging from enum import Enum from typing import Optional @@ -24,8 +23,6 @@ UUID_LEN = 16 VERSION_LEN = 4 -logger = logging.getLogger(__name__) - @enum.unique class Command(Enum): @@ -54,13 +51,6 @@ class Nitrokey3Device(NitrokeyTrussedDevice): def __init__(self, device: CtapHidDevice) -> None: super().__init__(device) - from .admin_app import AdminApp - - self.logger = logger.getChild(self._path) - - self.admin = AdminApp(self) - self.admin.status() - @property def pid(self) -> int: from . import PID_NITROKEY3_DEVICE diff --git a/pynitrokey/nk3/admin_app.py b/pynitrokey/trussed/admin_app.py similarity index 82% rename from pynitrokey/nk3/admin_app.py rename to pynitrokey/trussed/admin_app.py index 5750f8c1..c2ec3337 100644 --- a/pynitrokey/nk3/admin_app.py +++ b/pynitrokey/trussed/admin_app.py @@ -8,14 +8,23 @@ from fido2.ctap import CtapError from pynitrokey.helpers import local_critical, local_print -from pynitrokey.nk3.device import Command, Nitrokey3Device +from pynitrokey.trussed.device import App, NitrokeyTrussedDevice from pynitrokey.trussed.utils import Version -from .device import VERSION_LEN +VERSION_LEN = 4 @enum.unique class AdminCommand(Enum): + # legacy commands -- can be called directly or using the admin namespace + UPDATE = 0x51 + REBOOT = 0x53 + RNG = 0x60 + VERSION = 0x61 + UUID = 0x62 + LOCKED = 0x63 + + # new commands -- can only be called using the admin namespace STATUS = 0x80 TEST_SE050 = 0x81 GET_CONFIG = 0x82 @@ -23,6 +32,21 @@ class AdminCommand(Enum): FACTORY_RESET = 0x84 FACTORY_RESET_APP = 0x85 + def is_legacy_command(self) -> bool: + if self == AdminCommand.UPDATE: + return True + if self == AdminCommand.REBOOT: + return True + if self == AdminCommand.RNG: + return True + if self == AdminCommand.VERSION: + return True + if self == AdminCommand.UUID: + return True + if self == AdminCommand.LOCKED: + return True + return False + @enum.unique class InitStatus(IntFlag): @@ -120,7 +144,7 @@ def check(cls, i: int, msg: str) -> None: class AdminApp: - def __init__(self, device: Nitrokey3Device) -> None: + def __init__(self, device: NitrokeyTrussedDevice) -> None: self.device = device def _call( @@ -130,11 +154,19 @@ def _call( data: bytes = b"", ) -> Optional[bytes]: try: - return self.device._call_nk3( - Command.ADMIN, - response_len=response_len, - data=command.value.to_bytes(1, "big") + data, - ) + if command.is_legacy_command(): + return self.device._call( + command.value, + command.name, + response_len=response_len, + data=data, + ) + else: + return self.device._call_app( + App.ADMIN, + response_len=response_len, + data=command.value.to_bytes(1, "big") + data, + ) except CtapError as e: if e.code == CtapError.ERR.INVALID_COMMAND: return None @@ -159,7 +191,8 @@ def status(self) -> Status: return status def version(self) -> Version: - reply = self.device._call_nk3(Command.VERSION, data=bytes([0x01])) + reply = self._call(AdminCommand.VERSION, data=bytes([0x01])) + assert reply is not None if len(reply) == VERSION_LEN: version = int.from_bytes(reply, "big") return Version.from_int(version) diff --git a/pynitrokey/trussed/device.py b/pynitrokey/trussed/device.py index 6ea4780c..4676e109 100644 --- a/pynitrokey/trussed/device.py +++ b/pynitrokey/trussed/device.py @@ -7,10 +7,12 @@ # http://opensource.org/licenses/MIT>, at your option. This file may not be # copied, modified, or distributed except according to those terms. +import enum import logging import platform import sys from abc import abstractmethod +from enum import Enum from typing import Optional, TypeVar from fido2.hid import CtapHidDevice, open_device @@ -24,12 +26,27 @@ logger = logging.getLogger(__name__) +@enum.unique +class App(Enum): + """Vendor-specific CTAPHID commands for Trussed apps.""" + + SECRETS = 0x70 + PROVISIONER = 0x71 + ADMIN = 0x72 + + class NitrokeyTrussedDevice(NitrokeyTrussedBase): def __init__(self, device: CtapHidDevice) -> None: self.validate_vid_pid(device.descriptor.vid, device.descriptor.pid) self.device = device self._path = device_path_to_str(device.descriptor.path) + self.logger = logger.getChild(self._path) + + from .admin_app import AdminApp + + self.admin = AdminApp(self) + self.admin.status() @property def path(self) -> str: @@ -56,6 +73,14 @@ def _call( ) return response + def _call_app( + self, + app: App, + response_len: Optional[int] = None, + data: bytes = b"", + ) -> bytes: + return self._call(app.value, app.name, response_len, data) + @classmethod def open(cls: type[T], path: str) -> Optional[T]: try: From f06c1e32105e9be37cda9de7be3f04f50da07dfa Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Tue, 23 Jan 2024 14:16:16 +0100 Subject: [PATCH 3/8] trussed, nk3: Move commands into apps --- pynitrokey/cli/nk3/__init__.py | 30 ++++---- pynitrokey/cli/nk3/test.py | 13 ++-- pynitrokey/nk3/device.py | 85 ----------------------- pynitrokey/nk3/provisioner_app.py | 7 +- pynitrokey/nk3/secrets_app.py | 5 +- pynitrokey/nk3/updates.py | 11 +-- pynitrokey/test_secrets_app.py | 5 +- pynitrokey/trussed/admin_app.py | 52 +++++++++++++- pynitrokey/trussed/device.py | 9 +++ pynitrokey/{nk3 => trussed}/exceptions.py | 6 +- 10 files changed, 97 insertions(+), 126 deletions(-) rename pynitrokey/{nk3 => trussed}/exceptions.py (76%) diff --git a/pynitrokey/cli/nk3/__init__.py b/pynitrokey/cli/nk3/__init__.py index 7d3c05e1..661aec9f 100644 --- a/pynitrokey/cli/nk3/__init__.py +++ b/pynitrokey/cli/nk3/__init__.py @@ -30,17 +30,17 @@ from pynitrokey.nk3 import list as list_nk3 from pynitrokey.nk3 import open as open_nk3 from pynitrokey.nk3.bootloader import Nitrokey3Bootloader -from pynitrokey.nk3.device import BootMode, Nitrokey3Device -from pynitrokey.nk3.exceptions import TimeoutException +from pynitrokey.nk3.device import Nitrokey3Device from pynitrokey.nk3.provisioner_app import ProvisionerApp from pynitrokey.nk3.updates import REPOSITORY, get_firmware_update -from pynitrokey.trussed.admin_app import AdminApp +from pynitrokey.trussed.admin_app import BootMode from pynitrokey.trussed.base import NitrokeyTrussedBase from pynitrokey.trussed.bootloader import ( Device, FirmwareContainer, parse_firmware_image, ) +from pynitrokey.trussed.exceptions import TimeoutException from pynitrokey.updates import OverwriteError T = TypeVar("T", bound=NitrokeyTrussedBase) @@ -188,7 +188,7 @@ def reboot_to_bootloader(device: Nitrokey3Device) -> bool: "Please press the touch button to reboot the device into bootloader mode ..." ) try: - return device.reboot(BootMode.BOOTROM) + return device.admin.reboot(BootMode.BOOTROM) except TimeoutException: raise CliException( "The reboot was not confirmed with the touch button.", @@ -209,7 +209,7 @@ def rng(ctx: Context, length: int) -> None: """Generate random data on the device.""" with ctx.connect_device() as device: while length > 0: - rng = device.rng() + rng = device.admin.rng() local_print(rng[:length].hex()) length -= len(rng) @@ -460,11 +460,10 @@ def status(ctx: Context) -> None: if uuid is not None: local_print(f"UUID: {uuid}") - admin = AdminApp(device) - version = admin.version() + version = device.admin.version() local_print(f"Firmware version: {version}") - status = admin.status() + status = device.admin.status() if status.init_status is not None: local_print(f"Init status: {status.init_status}") if status.ifs_blocks is not None: @@ -481,8 +480,7 @@ def status(ctx: Context) -> None: def get_config(ctx: Context, key: str) -> None: """Query a config value.""" with ctx.connect_device() as device: - admin = AdminApp(device) - value = admin.get_config(key) + value = device.admin.get_config(key) print(value) @@ -522,10 +520,8 @@ def set_config(ctx: Context, key: str, value: str, force: bool, dry_run: bool) - """ with ctx.connect_device() as device: - admin = AdminApp(device) - # before the confirmation prompt, check if the config value is supported - if not admin.has_config(key): + if not device.admin.has_config(key): raise CliException( f"The configuration option '{key}' is not supported by the device.", support_hint=False, @@ -582,7 +578,7 @@ def set_config(ctx: Context, key: str, value: str, force: bool, dry_run: bool) - file=sys.stderr, ) - admin.set_config(key, value) + device.admin.set_config(key, value) if requires_reboot: print("Rebooting device to apply config change.") @@ -596,7 +592,7 @@ def set_config(ctx: Context, key: str, value: str, force: bool, dry_run: bool) - def version(ctx: Context) -> None: """Query the firmware version of the device.""" with ctx.connect_device() as device: - version = device.version() + version = device.admin.version() local_print(version) @@ -613,7 +609,7 @@ def factory_reset(ctx: Context, experimental: bool) -> None: """Factory reset all functionality of the device""" check_experimental_flag(experimental) with ctx.connect_device() as device: - device.factory_reset() + device.admin.factory_reset() # We consciously do not allow resetting the admin app @@ -634,7 +630,7 @@ def factory_reset_app(ctx: Context, application: str, experimental: bool) -> Non """Factory reset all functionality of an application""" check_experimental_flag(experimental) with ctx.connect_device() as device: - device.factory_reset_app(application) + device.admin.factory_reset_app(application) @nk3.command() diff --git a/pynitrokey/cli/nk3/test.py b/pynitrokey/cli/nk3/test.py index e281bb35..3c443bf5 100644 --- a/pynitrokey/cli/nk3/test.py +++ b/pynitrokey/cli/nk3/test.py @@ -27,7 +27,6 @@ from pynitrokey.helpers import local_print from pynitrokey.nk3.device import Nitrokey3Device from pynitrokey.nk3.utils import Fido2Certs -from pynitrokey.trussed.admin_app import AdminApp from pynitrokey.trussed.base import NitrokeyTrussedBase from pynitrokey.trussed.utils import Uuid, Version @@ -146,7 +145,7 @@ def test_firmware_version_query( ) -> TestResult: if not isinstance(device, Nitrokey3Device): return TestResult(TestStatus.SKIPPED) - version = device.version() + version = device.admin.version() ctx.firmware_version = version return TestResult(TestStatus.SUCCESS, str(version)) @@ -155,13 +154,13 @@ def test_firmware_version_query( def test_device_status(ctx: TestContext, device: NitrokeyTrussedBase) -> TestResult: if not isinstance(device, Nitrokey3Device): return TestResult(TestStatus.SKIPPED) - firmware_version = ctx.firmware_version or device.version() + firmware_version = ctx.firmware_version or device.admin.version() if firmware_version.core() < Version(1, 3, 0): return TestResult(TestStatus.SKIPPED) errors = [] - status = AdminApp(device).status() + status = device.admin.status() logger.info(f"Device status: {status}") if status.init_status is None: @@ -189,7 +188,7 @@ def test_bootloader_configuration( ) -> TestResult: if not isinstance(device, Nitrokey3Device): return TestResult(TestStatus.SKIPPED) - if device.is_locked(): + if device.admin.is_locked(): return TestResult(TestStatus.SUCCESS) else: return TestResult(TestStatus.FAILURE, "bootloader not locked") @@ -369,7 +368,7 @@ def test_se050(ctx: TestContext, device: NitrokeyTrussedBase) -> TestResult: def internal_se050_run( q: Queue[Optional[bytes]], ) -> None: - q.put(AdminApp(device).se050_tests()) + q.put(device.admin.se050_tests()) t = Thread(target=internal_se050_run, args=[que]) t.start() @@ -500,7 +499,7 @@ def request_uv(self, permissions: Any, rd_id: Any) -> bool: cert = make_credential_result.attestation_object.att_stmt["x5c"] cert_hash = sha256(cert[0]).digest().hex() - firmware_version = ctx.firmware_version or device.version() + firmware_version = ctx.firmware_version or device.admin.version() if firmware_version: expected_certs = Fido2Certs.get(firmware_version) if expected_certs and cert_hash not in expected_certs.hashes: diff --git a/pynitrokey/nk3/device.py b/pynitrokey/nk3/device.py index 24611652..df255188 100644 --- a/pynitrokey/nk3/device.py +++ b/pynitrokey/nk3/device.py @@ -7,42 +7,9 @@ # http://opensource.org/licenses/MIT>, at your option. This file may not be # copied, modified, or distributed except according to those terms. -import enum -from enum import Enum -from typing import Optional - -from fido2.ctap import CtapError from fido2.hid import CtapHidDevice from pynitrokey.trussed.device import NitrokeyTrussedDevice -from pynitrokey.trussed.utils import Uuid, Version - -from .exceptions import TimeoutException - -RNG_LEN = 57 -UUID_LEN = 16 -VERSION_LEN = 4 - - -@enum.unique -class Command(Enum): - """Vendor-specific CTAPHID commands for the Nitrokey 3.""" - - UPDATE = 0x51 - REBOOT = 0x53 - RNG = 0x60 - VERSION = 0x61 - UUID = 0x62 - LOCKED = 0x63 - OTP = 0x70 - PROVISIONER = 0x71 - ADMIN = 0x72 - - -@enum.unique -class BootMode(Enum): - FIRMWARE = enum.auto() - BOOTROM = enum.auto() class Nitrokey3Device(NitrokeyTrussedDevice): @@ -60,55 +27,3 @@ def pid(self) -> int: @property def name(self) -> str: return "Nitrokey 3" - - def reboot(self, mode: BootMode = BootMode.FIRMWARE) -> bool: - try: - if mode == BootMode.FIRMWARE: - self._call_nk3(Command.REBOOT) - elif mode == BootMode.BOOTROM: - try: - self._call_nk3(Command.UPDATE) - except CtapError as e: - # The admin app returns an Invalid Length error if the user confirmation - # request times out - if e.code == CtapError.ERR.INVALID_LENGTH: - raise TimeoutException() - else: - raise e - except OSError as e: - # OS error is expected as the device does not respond during the reboot - self.logger.debug("ignoring OSError after reboot", exc_info=e) - return True - - def uuid(self) -> Optional[Uuid]: - uuid = self._call_nk3(Command.UUID) - if len(uuid) == 0: - # Firmware version 1.0.0 does not support querying the UUID - return None - if len(uuid) != UUID_LEN: - raise ValueError(f"UUID response has invalid length {len(uuid)}") - return Uuid(int.from_bytes(uuid, byteorder="big")) - - def version(self) -> Version: - return self.admin.version() - - def factory_reset(self) -> None: - self.admin.factory_reset() - - def factory_reset_app(self, app: str) -> None: - self.admin.factory_reset_app(app) - - def rng(self) -> bytes: - return self._call_nk3(Command.RNG, response_len=RNG_LEN) - - def otp(self, data: bytes = b"") -> bytes: - return self._call_nk3(Command.OTP, data=data) - - def is_locked(self) -> bool: - response = self._call_nk3(Command.LOCKED, response_len=1) - return response[0] == 1 - - def _call_nk3( - self, command: Command, response_len: Optional[int] = None, data: bytes = b"" - ) -> bytes: - return super()._call(command.value, command.name, response_len, data) diff --git a/pynitrokey/nk3/provisioner_app.py b/pynitrokey/nk3/provisioner_app.py index aed9a7f7..c305ed3d 100644 --- a/pynitrokey/nk3/provisioner_app.py +++ b/pynitrokey/nk3/provisioner_app.py @@ -2,7 +2,8 @@ from enum import Enum from typing import Optional -from pynitrokey.nk3.device import Command, Nitrokey3Device +from pynitrokey.nk3.device import Nitrokey3Device +from pynitrokey.trussed.device import App @enum.unique @@ -34,8 +35,8 @@ def _call( response_len: Optional[int] = None, data: bytes = b"", ) -> bytes: - return self.device._call_nk3( - Command.PROVISIONER, + return self.device._call_app( + App.PROVISIONER, response_len=response_len, data=command.value.to_bytes(1, "big") + data, ) diff --git a/pynitrokey/nk3/secrets_app.py b/pynitrokey/nk3/secrets_app.py index 8c98d285..a2e1e193 100644 --- a/pynitrokey/nk3/secrets_app.py +++ b/pynitrokey/nk3/secrets_app.py @@ -19,6 +19,7 @@ from pynitrokey.nk3.device import Nitrokey3Device from pynitrokey.start.gnuk_token import iso7816_compose +from pynitrokey.trussed.device import App LogFn = Callable[[str], Any] WriteCorpusFn = Callable[[typing.Union["Instruction", "CCIDInstruction"], bytes], Any] @@ -350,7 +351,7 @@ def _send_receive_inner(self, data: bytes, log_info: str = "") -> bytes: self.logfn(f"Sending {log_info if log_info else ''} (data: {len(data)} bytes)") try: - result = self.dev.otp(data=data) + result = self.dev._call_app(App.SECRETS, data=data) except Exception as e: self.logfn(f"Got exception: {e}") raise @@ -368,7 +369,7 @@ def _send_receive_inner(self, data: bytes, log_info: str = "") -> bytes: ins_b, p1, p2 = self._encode_command(Instruction.SendRemaining) bytes_data = iso7816_compose(ins_b, p1, p2) try: - result = self.dev.otp(data=bytes_data) + result = self.dev._call_app(App.SECRETS, data=bytes_data) except Exception as e: self.logfn(f"Got exception: {e}") raise diff --git a/pynitrokey/nk3/updates.py b/pynitrokey/nk3/updates.py index 27604022..9738ada2 100644 --- a/pynitrokey/nk3/updates.py +++ b/pynitrokey/nk3/updates.py @@ -22,8 +22,8 @@ import pynitrokey from pynitrokey.helpers import Retries from pynitrokey.nk3.bootloader import Nitrokey3Bootloader -from pynitrokey.nk3.device import BootMode, Nitrokey3Device -from pynitrokey.nk3.exceptions import TimeoutException +from pynitrokey.nk3.device import Nitrokey3Device +from pynitrokey.trussed.admin_app import BootMode from pynitrokey.trussed.base import NitrokeyTrussedBase from pynitrokey.trussed.bootloader import ( Device, @@ -31,6 +31,7 @@ Variant, validate_firmware_image, ) +from pynitrokey.trussed.exceptions import TimeoutException from pynitrokey.trussed.utils import Version from pynitrokey.updates import Asset, Release, Repository @@ -174,7 +175,7 @@ def update( ignore_pynitrokey_version: bool = False, ) -> Version: current_version = ( - device.version() if isinstance(device, Nitrokey3Device) else None + device.admin.version() if isinstance(device, Nitrokey3Device) else None ) logger.info(f"Firmware version before update: {current_version or ''}") container = self._prepare_update(image, update_version, current_version) @@ -219,7 +220,7 @@ def update( wait_retries = get_finalization_wait_retries(update_path) with self.ui.finalization_progress_bar() as callback: with self.await_device(wait_retries, callback) as device: - version = device.version() + version = device.admin.version() if version != container.version: raise self.ui.error( f"The firmware update to {container.version} was successful, but the " @@ -322,7 +323,7 @@ def _get_bootloader( if isinstance(device, Nitrokey3Device): self.ui.request_bootloader_confirmation() try: - device.reboot(BootMode.BOOTROM) + device.admin.reboot(BootMode.BOOTROM) except TimeoutException: raise self.ui.abort( "The reboot was not confirmed with the touch button" diff --git a/pynitrokey/test_secrets_app.py b/pynitrokey/test_secrets_app.py index b47d2767..6423657e 100644 --- a/pynitrokey/test_secrets_app.py +++ b/pynitrokey/test_secrets_app.py @@ -47,6 +47,7 @@ SecretsAppException, Tag, ) +from pynitrokey.trussed.device import App CREDENTIAL_LABEL_MAX_SIZE = 127 @@ -1223,7 +1224,7 @@ def _trunc(s: str, l: int = 100) -> str: data_to_send = app._custom_encode(structure) if structure is not None else data_raw data = iso7816_compose(ins_b, p1, p2, data_to_send, le=le) app.logfn(f">> {_trunc(data.hex())}") - res = app.dev.otp(data=data) + res = app.dev._call_app(App.SECRETS, data=data) app.logfn(f"<< {_trunc(res.hex())}") status_bytes, result = res[:2], res[2:] @@ -1390,7 +1391,7 @@ def test_select_applet(secretsAppRaw): data = "00 a4 04 00 07 a0 00 00 05 27 21 01" data = data.replace(" ", "") data = binascii.a2b_hex(data) - res = secretsAppRaw.dev.otp(data=data) + res = secretsAppRaw.dev._call_app(App.SECRETS, data=data) assert res.hex() != "6a82" assert res.hex().startswith("9000") # 90007903040a00710869f72b4b3712f627 print(res.hex()) diff --git a/pynitrokey/trussed/admin_app.py b/pynitrokey/trussed/admin_app.py index c2ec3337..d11621ef 100644 --- a/pynitrokey/trussed/admin_app.py +++ b/pynitrokey/trussed/admin_app.py @@ -8,9 +8,13 @@ from fido2.ctap import CtapError from pynitrokey.helpers import local_critical, local_print -from pynitrokey.trussed.device import App, NitrokeyTrussedDevice -from pynitrokey.trussed.utils import Version +from .device import App, NitrokeyTrussedDevice +from .exceptions import TimeoutException +from .utils import Uuid, Version + +RNG_LEN = 57 +UUID_LEN = 16 VERSION_LEN = 4 @@ -48,6 +52,12 @@ def is_legacy_command(self) -> bool: return False +@enum.unique +class BootMode(Enum): + FIRMWARE = enum.auto() + BOOTROM = enum.auto() + + @enum.unique class InitStatus(IntFlag): NFC_ERROR = 0b0001 @@ -173,6 +183,35 @@ def _call( else: raise + def is_locked(self) -> bool: + response = self._call(AdminCommand.LOCKED, response_len=1) + assert response is not None + return response[0] == 1 + + def reboot(self, mode: BootMode = BootMode.FIRMWARE) -> bool: + try: + if mode == BootMode.FIRMWARE: + self._call(AdminCommand.REBOOT) + elif mode == BootMode.BOOTROM: + try: + self._call(AdminCommand.UPDATE) + except CtapError as e: + # The admin app returns an Invalid Length error if the user confirmation + # request times out + if e.code == CtapError.ERR.INVALID_LENGTH: + raise TimeoutException() + else: + raise e + except OSError as e: + # OS error is expected as the device does not respond during the reboot + self.device.logger.debug("ignoring OSError after reboot", exc_info=e) + return True + + def rng(self) -> bytes: + data = self._call(AdminCommand.RNG, response_len=RNG_LEN) + assert data is not None + return data + def status(self) -> Status: status = Status() reply = self._call(AdminCommand.STATUS) @@ -190,6 +229,15 @@ def status(self) -> Status: pass return status + def uuid(self) -> Optional[Uuid]: + uuid = self._call(AdminCommand.UUID) + if uuid is None or len(uuid) == 0: + # Firmware version 1.0.0 does not support querying the UUID + return None + if len(uuid) != UUID_LEN: + raise ValueError(f"UUID response has invalid length {len(uuid)}") + return Uuid(int.from_bytes(uuid, byteorder="big")) + def version(self) -> Version: reply = self._call(AdminCommand.VERSION, data=bytes([0x01])) assert reply is not None diff --git a/pynitrokey/trussed/device.py b/pynitrokey/trussed/device.py index 4676e109..377713ac 100644 --- a/pynitrokey/trussed/device.py +++ b/pynitrokey/trussed/device.py @@ -20,6 +20,7 @@ from pynitrokey.fido2 import device_path_to_str from .base import NitrokeyTrussedBase +from .utils import Uuid, Version T = TypeVar("T", bound="NitrokeyTrussedDevice") @@ -55,6 +56,14 @@ def path(self) -> str: def close(self) -> None: self.device.close() + def reboot(self) -> bool: + from .admin_app import BootMode + + return self.admin.reboot(BootMode.FIRMWARE) + + def uuid(self) -> Optional[Uuid]: + return self.admin.uuid() + def wink(self) -> None: self.device.wink() diff --git a/pynitrokey/nk3/exceptions.py b/pynitrokey/trussed/exceptions.py similarity index 76% rename from pynitrokey/nk3/exceptions.py rename to pynitrokey/trussed/exceptions.py index c3abbe55..b5e05925 100644 --- a/pynitrokey/nk3/exceptions.py +++ b/pynitrokey/trussed/exceptions.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2022 Nitrokey Developers +# Copyright 2022-2024 Nitrokey Developers # # Licensed under the Apache License, Version 2.0, or the MIT license None: super().__init__("The user confirmation request timed out") From 433ef969ad1713e2a820c42ee3c6d39f6de4706d Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Tue, 23 Jan 2024 22:27:33 +0100 Subject: [PATCH 4/8] nk3: Move some commands into trussed module --- pynitrokey/cli/nk3/__init__.py | 218 +++------------------------- pynitrokey/cli/trussed.py | 252 +++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+), 199 deletions(-) create mode 100644 pynitrokey/cli/trussed.py diff --git a/pynitrokey/cli/nk3/__init__.py b/pynitrokey/cli/nk3/__init__.py index 661aec9f..84629765 100644 --- a/pynitrokey/cli/nk3/__init__.py +++ b/pynitrokey/cli/nk3/__init__.py @@ -7,11 +7,10 @@ # http://opensource.org/licenses/MIT>, at your option. This file may not be # copied, modified, or distributed except according to those terms. -import logging import os.path import sys from hashlib import sha256 -from typing import BinaryIO, Callable, List, Optional, Type, TypeVar +from typing import BinaryIO, List, Optional import click from cryptography import x509 @@ -19,111 +18,39 @@ from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey from ecdsa import NIST256p, SigningKey +from pynitrokey.cli import trussed from pynitrokey.cli.exceptions import CliException -from pynitrokey.helpers import ( - DownloadProgressBar, - Retries, - check_experimental_flag, - local_print, - require_windows_admin, -) -from pynitrokey.nk3 import list as list_nk3 -from pynitrokey.nk3 import open as open_nk3 +from pynitrokey.helpers import DownloadProgressBar, check_experimental_flag, local_print from pynitrokey.nk3.bootloader import Nitrokey3Bootloader from pynitrokey.nk3.device import Nitrokey3Device from pynitrokey.nk3.provisioner_app import ProvisionerApp from pynitrokey.nk3.updates import REPOSITORY, get_firmware_update -from pynitrokey.trussed.admin_app import BootMode from pynitrokey.trussed.base import NitrokeyTrussedBase from pynitrokey.trussed.bootloader import ( Device, FirmwareContainer, parse_firmware_image, ) -from pynitrokey.trussed.exceptions import TimeoutException from pynitrokey.updates import OverwriteError -T = TypeVar("T", bound=NitrokeyTrussedBase) - -logger = logging.getLogger(__name__) - -class Context: +class Context(trussed.Context[Nitrokey3Bootloader, Nitrokey3Device]): def __init__(self, path: Optional[str]) -> None: - self.path = path - - def list(self) -> List[NitrokeyTrussedBase]: - if self.path: - device = open_nk3(self.path) - if device: - return [device] - else: - return [] - else: - return list_nk3() + super().__init__(path, Nitrokey3Bootloader, Nitrokey3Device) # type: ignore[type-abstract] - def _select_unique(self, name: str, devices: List[T]) -> T: - if len(devices) == 0: - msg = f"No {name} device found" - if self.path: - msg += f" at path {self.path}" - raise CliException(msg) + @property + def device_name(self) -> str: + return "Nitrokey 3" - if len(devices) > 1: - raise CliException( - f"Multiple {name} devices found -- use the --path option to select one" - ) + def open(self, path: str) -> Optional[NitrokeyTrussedBase]: + from pynitrokey.nk3 import open - return devices[0] + return open(path) - def connect(self) -> NitrokeyTrussedBase: - return self._select_unique("Nitrokey 3", self.list()) + def list_all(self) -> List[NitrokeyTrussedBase]: + from pynitrokey.nk3 import list - def connect_device(self) -> Nitrokey3Device: - devices = [ - device for device in self.list() if isinstance(device, Nitrokey3Device) - ] - return self._select_unique("Nitrokey 3", devices) - - def _await( - self, - name: str, - ty: Type[T], - retries: int, - callback: Optional[Callable[[int, int], None]] = None, - ) -> T: - for t in Retries(retries): - logger.debug(f"Searching {name} device ({t})") - devices = [device for device in self.list() if isinstance(device, ty)] - if len(devices) == 0: - if callback: - callback(int((t.i / retries) * 100), 100) - logger.debug(f"No {name} device found, continuing") - continue - if len(devices) > 1: - raise CliException(f"Multiple {name} devices found") - if callback: - callback(100, 100) - return devices[0] - - raise CliException(f"No {name} device found") - - def await_device( - self, - retries: Optional[int] = 30, - callback: Optional[Callable[[int, int], None]] = None, - ) -> Nitrokey3Device: - assert isinstance(retries, int) - return self._await("Nitrokey 3", Nitrokey3Device, retries, callback) - - def await_bootloader( - self, - retries: Optional[int] = 30, - callback: Optional[Callable[[int, int], None]] = None, - ) -> Nitrokey3Bootloader: - assert isinstance(retries, int) - # mypy does not allow abstract types here, but this is still valid - return self._await("Nitrokey 3 bootloader", Nitrokey3Bootloader, retries, callback) # type: ignore + return list() @click.group() @@ -132,86 +59,11 @@ def await_bootloader( def nk3(ctx: click.Context, path: Optional[str]) -> None: """Interact with Nitrokey 3 devices, see subcommands.""" ctx.obj = Context(path) - require_windows_admin() + trussed.prepare_group() -@nk3.command() -def list() -> None: - """List all Nitrokey 3 devices.""" - local_print(":: 'Nitrokey 3' keys") - for device in list_nk3(): - with device as device: - uuid = device.uuid() - if uuid: - local_print(f"{device.path}: {device.name} {uuid}") - else: - local_print(f"{device.path}: {device.name}") - - -@nk3.command() -@click.option( - "--bootloader", - is_flag=True, - help="Reboot a Nitrokey 3 device into bootloader mode", -) -@click.pass_obj -def reboot(ctx: Context, bootloader: bool) -> None: - """ - Reboot the key. - - Per default, the key will reboot into regular firmware mode. If the --bootloader option - is set, a key can boot from firmware mode to bootloader mode. Booting into - bootloader mode has to be confirmed by pressing the touch button. - """ - with ctx.connect() as device: - if bootloader: - if isinstance(device, Nitrokey3Device): - success = reboot_to_bootloader(device) - else: - raise CliException( - "A Nitrokey 3 device in bootloader mode can only reboot into firmware mode.", - support_hint=False, - ) - else: - success = device.reboot() - - if not success: - raise CliException( - "The connected device cannot be rebooted automatically. Remove and reinsert the " - "device to reboot it.", - support_hint=False, - ) - - -def reboot_to_bootloader(device: Nitrokey3Device) -> bool: - local_print( - "Please press the touch button to reboot the device into bootloader mode ..." - ) - try: - return device.admin.reboot(BootMode.BOOTROM) - except TimeoutException: - raise CliException( - "The reboot was not confirmed with the touch button.", - support_hint=False, - ) - - -@nk3.command() -@click.option( - "-l", - "--length", - "length", - default=57, - help="The length of the generated data (default: 57)", -) -@click.pass_obj -def rng(ctx: Context, length: int) -> None: - """Generate random data on the device.""" - with ctx.connect_device() as device: - while length > 0: - rng = device.admin.rng() - local_print(rng[:length].hex()) - length -= len(rng) +# shared Trussed commands +trussed.add_commands(nk3) @nk3.command() @@ -396,8 +248,8 @@ def validate_update(image: str) -> None: if container.version != metadata.version: raise CliException( - f"The firmware image for the {variant} variant and the release {version} has an " - f"unexpected product version ({metadata.version})." + f"The firmware image for the {variant} variant and the release " + f"{container.version} has an unexpected product version ({metadata.version})." ) @@ -451,29 +303,6 @@ def update( exec_update(ctx, image, version, ignore_pynitrokey_version) -@nk3.command() -@click.pass_obj -def status(ctx: Context) -> None: - """Query the device status.""" - with ctx.connect_device() as device: - uuid = device.uuid() - if uuid is not None: - local_print(f"UUID: {uuid}") - - version = device.admin.version() - local_print(f"Firmware version: {version}") - - status = device.admin.status() - if status.init_status is not None: - local_print(f"Init status: {status.init_status}") - if status.ifs_blocks is not None: - local_print(f"Free blocks (int): {status.ifs_blocks}") - if status.efs_blocks is not None: - local_print(f"Free blocks (ext): {status.efs_blocks}") - if status.variant is not None: - local_print(f"Variant: {status.variant.name}") - - @nk3.command() @click.pass_obj @click.argument("key") @@ -587,15 +416,6 @@ def set_config(ctx: Context, key: str, value: str, force: bool, dry_run: bool) - print(f"Updated configuration {key}.") -@nk3.command() -@click.pass_obj -def version(ctx: Context) -> None: - """Query the firmware version of the device.""" - with ctx.connect_device() as device: - version = device.admin.version() - local_print(version) - - @nk3.command() @click.pass_obj @click.option( diff --git a/pynitrokey/cli/trussed.py b/pynitrokey/cli/trussed.py new file mode 100644 index 00000000..f50a4795 --- /dev/null +++ b/pynitrokey/cli/trussed.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021-2024 Nitrokey Developers +# +# Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be +# copied, modified, or distributed except according to those terms. + +import logging +from abc import ABC, abstractmethod +from typing import Callable, Generic, Optional, Sequence, TypeVar + +import click + +from pynitrokey.cli.exceptions import CliException +from pynitrokey.helpers import Retries, local_print, require_windows_admin +from pynitrokey.trussed.admin_app import BootMode +from pynitrokey.trussed.base import NitrokeyTrussedBase +from pynitrokey.trussed.bootloader import NitrokeyTrussedBootloader +from pynitrokey.trussed.device import NitrokeyTrussedDevice +from pynitrokey.trussed.exceptions import TimeoutException + +T = TypeVar("T", bound=NitrokeyTrussedBase) +Bootloader = TypeVar("Bootloader", bound=NitrokeyTrussedBootloader) +Device = TypeVar("Device", bound=NitrokeyTrussedDevice) + +logger = logging.getLogger(__name__) + + +class Context(ABC, Generic[Bootloader, Device]): + def __init__( + self, + path: Optional[str], + bootloader_type: type[Bootloader], + device_type: type[Device], + ) -> None: + self.path = path + self.bootloader_type = bootloader_type + self.device_type = device_type + + @property + @abstractmethod + def device_name(self) -> str: + ... + + @abstractmethod + def open(self, path: str) -> Optional[NitrokeyTrussedBase]: + ... + + @abstractmethod + def list_all(self) -> Sequence[NitrokeyTrussedBase]: + ... + + def list(self) -> Sequence[NitrokeyTrussedBase]: + if self.path: + device = self.open(self.path) + if device: + return [device] + else: + return [] + else: + return self.list_all() + + def connect(self) -> NitrokeyTrussedBase: + return self._select_unique(self.device_name, self.list()) + + def connect_device(self) -> Device: + devices = [ + device for device in self.list() if isinstance(device, self.device_type) + ] + return self._select_unique(self.device_name, devices) + + def await_device( + self, + retries: Optional[int] = None, + callback: Optional[Callable[[int, int], None]] = None, + ) -> Device: + return self._await(self.device_name, self.device_type, retries, callback) + + def await_bootloader( + self, + retries: Optional[int] = None, + callback: Optional[Callable[[int, int], None]] = None, + ) -> Bootloader: + # mypy does not allow abstract types here, but this is still valid + return self._await( + f"{self.device_name} bootloader", self.bootloader_type, retries, callback + ) + + def _select_unique(self, name: str, devices: Sequence[T]) -> T: + if len(devices) == 0: + msg = f"No {name} device found" + if self.path: + msg += f" at path {self.path}" + raise CliException(msg) + + if len(devices) > 1: + raise CliException( + f"Multiple {name} devices found -- use the --path option to select one" + ) + + return devices[0] + + def _await( + self, + name: str, + ty: type[T], + retries: Optional[int], + callback: Optional[Callable[[int, int], None]] = None, + ) -> T: + if retries is None: + retries = 30 + for t in Retries(retries): + logger.debug(f"Searching {name} device ({t})") + devices = [device for device in self.list() if isinstance(device, ty)] + if len(devices) == 0: + if callback: + callback(int((t.i / retries) * 100), 100) + logger.debug(f"No {name} device found, continuing") + continue + if len(devices) > 1: + raise CliException(f"Multiple {name} devices found") + if callback: + callback(100, 100) + return devices[0] + + raise CliException(f"No {name} device found") + + +def prepare_group() -> None: + require_windows_admin() + + +def add_commands(group: click.Group) -> None: + group.add_command(list) + group.add_command(reboot) + group.add_command(rng) + group.add_command(status) + group.add_command(version) + + +@click.command() +@click.pass_obj +def list(ctx: Context[Bootloader, Device]) -> None: + """List all devices.""" + local_print(f":: '{ctx.device_name}' keys") + for device in ctx.list_all(): + with device as device: + uuid = device.uuid() + if uuid: + local_print(f"{device.path}: {device.name} {uuid}") + else: + local_print(f"{device.path}: {device.name}") + + +@click.command() +@click.option( + "--bootloader", + is_flag=True, + help="Reboot the device into bootloader mode", +) +@click.pass_obj +def reboot(ctx: Context[Bootloader, Device], bootloader: bool) -> None: + """ + Reboot the key. + + Per default, the key will reboot into regular firmware mode. If the --bootloader option + is set, a key can boot from firmware mode to bootloader mode. Booting into + bootloader mode has to be confirmed by pressing the touch button. + """ + with ctx.connect() as device: + if bootloader: + if isinstance(device, NitrokeyTrussedDevice): + success = reboot_to_bootloader(device) + else: + raise CliException( + "A device in bootloader mode can only reboot into firmware mode.", + support_hint=False, + ) + else: + success = device.reboot() + + if not success: + raise CliException( + "The connected device cannot be rebooted automatically. Remove and reinsert the " + "device to reboot it.", + support_hint=False, + ) + + +def reboot_to_bootloader(device: NitrokeyTrussedDevice) -> bool: + local_print( + "Please press the touch button to reboot the device into bootloader mode ..." + ) + try: + return device.admin.reboot(BootMode.BOOTROM) + except TimeoutException: + raise CliException( + "The reboot was not confirmed with the touch button.", + support_hint=False, + ) + + +@click.command() +@click.option( + "-l", + "--length", + "length", + default=57, + help="The length of the generated data (default: 57)", +) +@click.pass_obj +def rng(ctx: Context[Bootloader, Device], length: int) -> None: + """Generate random data on the device.""" + with ctx.connect_device() as device: + while length > 0: + rng = device.admin.rng() + local_print(rng[:length].hex()) + length -= len(rng) + + +@click.command() +@click.pass_obj +def status(ctx: Context[Bootloader, Device]) -> None: + """Query the device status.""" + with ctx.connect_device() as device: + uuid = device.uuid() + if uuid is not None: + local_print(f"UUID: {uuid}") + + version = device.admin.version() + local_print(f"Firmware version: {version}") + + status = device.admin.status() + if status.init_status is not None: + local_print(f"Init status: {status.init_status}") + if status.ifs_blocks is not None: + local_print(f"Free blocks (int): {status.ifs_blocks}") + if status.efs_blocks is not None: + local_print(f"Free blocks (ext): {status.efs_blocks}") + if status.variant is not None: + local_print(f"Variant: {status.variant.name}") + + +@click.command() +@click.pass_obj +def version(ctx: Context[Bootloader, Device]) -> None: + """Query the firmware version of the device.""" + with ctx.connect_device() as device: + version = device.admin.version() + local_print(version) From 2ec83cd3a634a50af4583ddfe6392666e5df69ca Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Wed, 24 Jan 2024 19:05:50 +0100 Subject: [PATCH 5/8] nk3: Move test into trussed module --- pynitrokey/cli/nk3/__init__.py | 116 ++--------- .../cli/{trussed.py => trussed/__init__.py} | 116 +++++++++++ pynitrokey/cli/trussed/test.py | 180 ++++++++++++++++++ .../cli/{nk3/test.py => trussed/tests.py} | 175 +---------------- 4 files changed, 319 insertions(+), 268 deletions(-) rename pynitrokey/cli/{trussed.py => trussed/__init__.py} (74%) create mode 100644 pynitrokey/cli/trussed/test.py rename pynitrokey/cli/{nk3/test.py => trussed/tests.py} (71%) diff --git a/pynitrokey/cli/nk3/__init__.py b/pynitrokey/cli/nk3/__init__.py index 84629765..cc0b7e7b 100644 --- a/pynitrokey/cli/nk3/__init__.py +++ b/pynitrokey/cli/nk3/__init__.py @@ -20,6 +20,7 @@ from pynitrokey.cli import trussed from pynitrokey.cli.exceptions import CliException +from pynitrokey.cli.trussed.test import TestCase from pynitrokey.helpers import DownloadProgressBar, check_experimental_flag, local_print from pynitrokey.nk3.bootloader import Nitrokey3Bootloader from pynitrokey.nk3.device import Nitrokey3Device @@ -42,6 +43,20 @@ def __init__(self, path: Optional[str]) -> None: def device_name(self) -> str: return "Nitrokey 3" + @property + def test_cases(self) -> list[TestCase]: + from pynitrokey.cli.trussed import tests + + return [ + tests.test_uuid_query, + tests.test_firmware_version_query, + tests.test_device_status, + tests.test_bootloader_configuration, + tests.test_firmware_mode, + tests.test_se050, + tests.test_fido2, + ] + def open(self, path: str) -> Optional[NitrokeyTrussedBase]: from pynitrokey.nk3 import open @@ -66,107 +81,6 @@ def nk3(ctx: click.Context, path: Optional[str]) -> None: trussed.add_commands(nk3) -@nk3.command() -@click.option( - "--pin", - "pin", - help="The FIDO2 PIN of the device (if enabled)", -) -@click.option( - "--only", - "only", - help="Run only the specified tests (may not be used with --all, --include or --exclude)", -) -@click.option( - "--all", - "all", - is_flag=True, - default=False, - help="Run all tests (except those specified with --exclude)", -) -@click.option( - "--include", - "include", - help="Also run the specified tests", -) -@click.option( - "--exclude", - "exclude", - help="Do not run the specified tests", -) -@click.option( - "--list", - "list_", - is_flag=True, - default=False, - help="List the selected tests instead of running them", -) -@click.pass_obj -def test( - ctx: Context, - pin: Optional[str], - only: Optional[str], - all: bool, - include: Optional[str], - exclude: Optional[str], - list_: bool, -) -> None: - """Run some tests on all connected Nitrokey 3 devices.""" - from .test import ( - TestContext, - TestSelector, - list_tests, - log_devices, - log_system, - run_tests, - ) - - test_selector = TestSelector(all=all) - if only: - if all or include or exclude: - raise CliException( - "--only may not be used together with --all, --include or --exclude.", - support_hint=False, - ) - test_selector.only = only.split(",") - if include: - test_selector.include = include.split(",") - if exclude: - test_selector.exclude = exclude.split(",") - - if list_: - list_tests(test_selector) - return - - log_system() - devices = ctx.list() - - if len(devices) == 0: - log_devices() - raise CliException("No connected Nitrokey 3 devices found") - - local_print(f"Found {len(devices)} Nitrokey 3 device(s):") - for device in devices: - local_print(f"- {device.name} at {device.path}") - - results = [] - test_ctx = TestContext(pin=pin) - for device in devices: - results.append(run_tests(test_ctx, device, test_selector)) - - n = len(devices) - success = sum(results) - failure = n - success - local_print("") - local_print( - f"Summary: {n} device(s) tested, {success} successful, {failure} failed" - ) - - if failure > 0: - local_print("") - raise CliException(f"Test failed for {failure} device(s)") - - @nk3.command() @click.argument("path", default=".") @click.option( diff --git a/pynitrokey/cli/trussed.py b/pynitrokey/cli/trussed/__init__.py similarity index 74% rename from pynitrokey/cli/trussed.py rename to pynitrokey/cli/trussed/__init__.py index f50a4795..7d3e9d8a 100644 --- a/pynitrokey/cli/trussed.py +++ b/pynitrokey/cli/trussed/__init__.py @@ -21,6 +21,8 @@ from pynitrokey.trussed.device import NitrokeyTrussedDevice from pynitrokey.trussed.exceptions import TimeoutException +from .test import TestCase + T = TypeVar("T", bound=NitrokeyTrussedBase) Bootloader = TypeVar("Bootloader", bound=NitrokeyTrussedBootloader) Device = TypeVar("Device", bound=NitrokeyTrussedDevice) @@ -44,6 +46,11 @@ def __init__( def device_name(self) -> str: ... + @property + @abstractmethod + def test_cases(self) -> Sequence[TestCase]: + ... + @abstractmethod def open(self, path: str) -> Optional[NitrokeyTrussedBase]: ... @@ -137,6 +144,7 @@ def add_commands(group: click.Group) -> None: group.add_command(reboot) group.add_command(rng) group.add_command(status) + group.add_command(test) group.add_command(version) @@ -243,6 +251,114 @@ def status(ctx: Context[Bootloader, Device]) -> None: local_print(f"Variant: {status.variant.name}") +@click.command() +@click.option( + "--pin", + "pin", + help="The FIDO2 PIN of the device (if enabled)", +) +@click.option( + "--only", + "only", + help="Run only the specified tests (may not be used with --all, --include or --exclude)", +) +@click.option( + "--all", + "all", + is_flag=True, + default=False, + help="Run all tests (except those specified with --exclude)", +) +@click.option( + "--include", + "include", + help="Also run the specified tests", +) +@click.option( + "--exclude", + "exclude", + help="Do not run the specified tests", +) +@click.option( + "--list", + "list_", + is_flag=True, + default=False, + help="List the selected tests instead of running them", +) +@click.pass_obj +def test( + ctx: Context[Bootloader, Device], + pin: Optional[str], + only: Optional[str], + all: bool, + include: Optional[str], + exclude: Optional[str], + list_: bool, +) -> None: + """Run some tests on all connected devices.""" + from pynitrokey.cli.trussed.test import ( + TestContext, + TestSelector, + list_tests, + log_devices, + log_system, + run_tests, + ) + + test_selector = TestSelector(all=all) + if only: + if all or include or exclude: + raise CliException( + "--only may not be used together with --all, --include or --exclude.", + support_hint=False, + ) + test_selector.only = only.split(",") + if include: + test_selector.include = include.split(",") + if exclude: + test_selector.exclude = exclude.split(",") + + if list_: + list_tests(test_selector, ctx.test_cases) + return + + log_system() + devices = ctx.list() + + if len(devices) == 0: + log_devices() + raise CliException(f"No connected {ctx.device_name} devices found") + + local_print(f"Found {len(devices)} {ctx.device_name} device(s):") + for device in devices: + local_print(f"- {device.name} at {device.path}") + + results = [] + test_ctx = TestContext(pin=pin) + for device in devices: + results.append( + run_tests( + test_ctx, + device, + test_selector, + ctx.test_cases, + ) + ) + + n = len(devices) + success = sum(results) + failure = n - success + local_print("") + local_print( + f"Summary: {n} device(s) tested, {success} successful, {failure} failed" + ) + + if failure > 0: + local_print("") + raise CliException(f"Test failed for {failure} device(s)") + + @click.command() @click.pass_obj def version(ctx: Context[Bootloader, Device]) -> None: diff --git a/pynitrokey/cli/trussed/test.py b/pynitrokey/cli/trussed/test.py new file mode 100644 index 00000000..2f9f4317 --- /dev/null +++ b/pynitrokey/cli/trussed/test.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021-2024 Nitrokey Developers +# +# Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be +# copied, modified, or distributed except according to those terms. + +import logging +import platform +import sys +from dataclasses import dataclass +from enum import Enum, auto, unique +from types import TracebackType +from typing import Any, Callable, Iterable, Optional, Sequence, Tuple, Type, Union + +from pynitrokey.cli.exceptions import CliException +from pynitrokey.fido2 import device_path_to_str +from pynitrokey.helpers import local_print +from pynitrokey.trussed.base import NitrokeyTrussedBase +from pynitrokey.trussed.utils import Uuid, Version + +logger = logging.getLogger(__name__) + +DEFAULT_EXCLUDES = ["bootloader", "provisioner"] + + +ExcInfo = Tuple[Type[BaseException], BaseException, TracebackType] + + +class TestContext: + def __init__(self, pin: Optional[str]) -> None: + self.pin = pin + self.firmware_version: Optional[Version] = None + + +@unique +class TestStatus(Enum): + SKIPPED = auto() + SUCCESS = auto() + FAILURE = auto() + + +class TestResult: + def __init__( + self, + status: TestStatus, + data: Optional[str] = None, + exc_info: Union[ExcInfo, Tuple[None, None, None]] = (None, None, None), + ) -> None: + self.status = status + self.data = data + self.exc_info = exc_info + + +TestCaseFn = Callable[[TestContext, NitrokeyTrussedBase], TestResult] + + +class TestCase: + def __init__(self, name: str, description: str, fn: TestCaseFn) -> None: + self.name = name + self.description = description + self.fn = fn + + +def test_case(name: str, description: str) -> Callable[[TestCaseFn], TestCase]: + def decorator(func: TestCaseFn) -> TestCase: + return TestCase(name, description, func) + + return decorator + + +def filter_test_cases( + test_cases: Sequence[TestCase], names: Iterable[str] +) -> Iterable[TestCase]: + for test_case in test_cases: + if test_case.name in names: + yield test_case + + +@dataclass +class TestSelector: + only: Iterable[str] = () + all: bool = False + include: Iterable[str] = () + exclude: Iterable[str] = () + + def select(self, test_cases: Sequence[TestCase]) -> list[TestCase]: + if self.only: + return list(filter_test_cases(test_cases, self.only)) + + selected = [] + for test_case in test_cases: + if test_case.name in self.include: + selected.append(test_case) + elif test_case.name not in self.exclude: + if self.all or test_case.name not in DEFAULT_EXCLUDES: + selected.append(test_case) + return selected + + +def log_devices() -> None: + from fido2.hid import CtapHidDevice + + ctap_devices = [device for device in CtapHidDevice.list_devices()] + logger.info(f"Found {len(ctap_devices)} CTAPHID devices:") + for device in ctap_devices: + descriptor = device.descriptor + path = device_path_to_str(descriptor.path) + logger.info(f"- {path} ({descriptor.vid:x}:{descriptor.pid:x})") + + +def log_system() -> None: + logger.info(f"platform: {platform.platform()}") + logger.info(f"uname: {platform.uname()}") + + +def list_tests( + selector: TestSelector, + test_cases: Sequence[TestCase], +) -> None: + test_cases = selector.select(test_cases) + print(f"{len(test_cases)} test case(s) selected") + for test_case in test_cases: + print(f"- {test_case.name}: {test_case.description}") + + +def run_tests( + ctx: TestContext, + device: NitrokeyTrussedBase, + selector: TestSelector, + test_cases: Sequence[TestCase], +) -> bool: + test_cases = selector.select(test_cases) + if not test_cases: + raise CliException("No test cases selected", support_hint=False) + + results = [] + + local_print("") + local_print(f"Running tests for {device.name} at {device.path}") + local_print("") + + n = len(test_cases) + idx_len = len(str(n)) + name_len = max([len(test_case.name) for test_case in test_cases]) + 2 + description_len = max([len(test_case.description) for test_case in test_cases]) + 2 + status_len = max([len(status.name) for status in TestStatus]) + 2 + + for (i, test_case) in enumerate(test_cases): + try: + result = test_case.fn(ctx, device) + except Exception: + result = TestResult(TestStatus.FAILURE, exc_info=sys.exc_info()) + results.append(result) + + idx = str(i + 1).rjust(idx_len) + name = test_case.name.ljust(name_len) + description = test_case.description.ljust(description_len) + status = result.status.name.ljust(status_len) + msg = "" + if result.data: + msg = str(result.data) + elif result.exc_info[1]: + logger.error( + f"An exception occured during the execution of the test {test_case.name}:", + exc_info=result.exc_info, + ) + msg = str(result.exc_info[1]) + + local_print(f"[{idx}/{n}]\t{name}\t{description}\t{status}\t{msg}") + + success = len([result for result in results if result.status == TestStatus.SUCCESS]) + skipped = len([result for result in results if result.status == TestStatus.SKIPPED]) + failed = len([result for result in results if result.status == TestStatus.FAILURE]) + local_print("") + local_print(f"{n} tests, {success} successful, {skipped} skipped, {failed} failed") + + return all([result.status != TestStatus.FAILURE for result in results]) diff --git a/pynitrokey/cli/nk3/test.py b/pynitrokey/cli/trussed/tests.py similarity index 71% rename from pynitrokey/cli/nk3/test.py rename to pynitrokey/cli/trussed/tests.py index 3c443bf5..2c8d2af4 100644 --- a/pynitrokey/cli/nk3/test.py +++ b/pynitrokey/cli/trussed/tests.py @@ -9,128 +9,27 @@ # copied, modified, or distributed except according to those terms. import logging -import platform -import sys -from dataclasses import dataclass -from enum import Enum, auto, unique from hashlib import sha256 from struct import unpack from threading import Thread -from types import TracebackType -from typing import Any, Callable, Iterable, Optional, Tuple, Type, Union +from typing import Any, Optional from tqdm import tqdm -from pynitrokey.cli.exceptions import CliException -from pynitrokey.fido2 import device_path_to_str +from pynitrokey.cli.trussed.test import TestContext, TestResult, TestStatus, test_case from pynitrokey.fido2.client import NKFido2Client from pynitrokey.helpers import local_print -from pynitrokey.nk3.device import Nitrokey3Device from pynitrokey.nk3.utils import Fido2Certs from pynitrokey.trussed.base import NitrokeyTrussedBase +from pynitrokey.trussed.device import NitrokeyTrussedDevice from pynitrokey.trussed.utils import Uuid, Version logger = logging.getLogger(__name__) -TEST_CASES = [] - AID_ADMIN = [0xA0, 0x00, 0x00, 0x08, 0x47, 0x00, 0x00, 0x00, 0x01] AID_PROVISIONER = [0xA0, 0x00, 0x00, 0x08, 0x47, 0x01, 0x00, 0x00, 0x01] -DEFAULT_EXCLUDES = ["bootloader", "provisioner"] - - -ExcInfo = Tuple[Type[BaseException], BaseException, TracebackType] - - -class TestContext: - def __init__(self, pin: Optional[str]) -> None: - self.pin = pin - self.firmware_version: Optional[Version] = None - - -@unique -class TestStatus(Enum): - SKIPPED = auto() - SUCCESS = auto() - FAILURE = auto() - - -class TestResult: - def __init__( - self, - status: TestStatus, - data: Optional[str] = None, - exc_info: Union[ExcInfo, Tuple[None, None, None]] = (None, None, None), - ) -> None: - self.status = status - self.data = data - self.exc_info = exc_info - - -TestCaseFn = Callable[[TestContext, NitrokeyTrussedBase], TestResult] - - -class TestCase: - def __init__(self, name: str, description: str, fn: TestCaseFn) -> None: - self.name = name - self.description = description - self.fn = fn - - -def test_case(name: str, description: str) -> Callable[[TestCaseFn], TestCaseFn]: - def decorator(func: TestCaseFn) -> TestCaseFn: - TEST_CASES.append(TestCase(name, description, func)) - return func - - return decorator - - -def filter_test_cases( - test_cases: list[TestCase], names: Iterable[str] -) -> Iterable[TestCase]: - for test_case in test_cases: - if test_case.name in names: - yield test_case - - -@dataclass -class TestSelector: - only: Iterable[str] = () - all: bool = False - include: Iterable[str] = () - exclude: Iterable[str] = () - - def select(self) -> list[TestCase]: - if self.only: - return list(filter_test_cases(TEST_CASES, self.only)) - - selected = [] - for test_case in TEST_CASES: - if test_case.name in self.include: - selected.append(test_case) - elif test_case.name not in self.exclude: - if self.all or test_case.name not in DEFAULT_EXCLUDES: - selected.append(test_case) - return selected - - -def log_devices() -> None: - from fido2.hid import CtapHidDevice - - ctap_devices = [device for device in CtapHidDevice.list_devices()] - logger.info(f"Found {len(ctap_devices)} CTAPHID devices:") - for device in ctap_devices: - descriptor = device.descriptor - path = device_path_to_str(descriptor.path) - logger.info(f"- {path} ({descriptor.vid:x}:{descriptor.pid:x})") - - -def log_system() -> None: - logger.info(f"platform: {platform.platform()}") - logger.info(f"uname: {platform.uname()}") - @test_case("uuid", "UUID query") def test_uuid_query(ctx: TestContext, device: NitrokeyTrussedBase) -> TestResult: @@ -143,7 +42,7 @@ def test_uuid_query(ctx: TestContext, device: NitrokeyTrussedBase) -> TestResult def test_firmware_version_query( ctx: TestContext, device: NitrokeyTrussedBase ) -> TestResult: - if not isinstance(device, Nitrokey3Device): + if not isinstance(device, NitrokeyTrussedDevice): return TestResult(TestStatus.SKIPPED) version = device.admin.version() ctx.firmware_version = version @@ -152,7 +51,7 @@ def test_firmware_version_query( @test_case("status", "Device status") def test_device_status(ctx: TestContext, device: NitrokeyTrussedBase) -> TestResult: - if not isinstance(device, Nitrokey3Device): + if not isinstance(device, NitrokeyTrussedDevice): return TestResult(TestStatus.SKIPPED) firmware_version = ctx.firmware_version or device.admin.version() if firmware_version.core() < Version(1, 3, 0): @@ -186,7 +85,7 @@ def test_device_status(ctx: TestContext, device: NitrokeyTrussedBase) -> TestRes def test_bootloader_configuration( ctx: TestContext, device: NitrokeyTrussedBase ) -> TestResult: - if not isinstance(device, Nitrokey3Device): + if not isinstance(device, NitrokeyTrussedDevice): return TestResult(TestStatus.SKIPPED) if device.admin.is_locked(): return TestResult(TestStatus.SUCCESS) @@ -360,7 +259,7 @@ def select(conn: CardConnection, aid: list[int]) -> bool: def test_se050(ctx: TestContext, device: NitrokeyTrussedBase) -> TestResult: from queue import Queue - if not isinstance(device, Nitrokey3Device): + if not isinstance(device, NitrokeyTrussedDevice): return TestResult(TestStatus.SKIPPED) que: Queue[Optional[bytes]] = Queue() @@ -431,7 +330,7 @@ def internal_se050_run( @test_case("fido2", "FIDO2") def test_fido2(ctx: TestContext, device: NitrokeyTrussedBase) -> TestResult: - if not isinstance(device, Nitrokey3Device): + if not isinstance(device, NitrokeyTrussedDevice): return TestResult(TestStatus.SKIPPED) # drop out early, if pin is needed, but not provided @@ -539,61 +438,3 @@ def request_uv(self, permissions: Any, rd_id: Any) -> bool: ) return TestResult(TestStatus.SUCCESS) - - -def list_tests(selector: TestSelector) -> None: - test_cases = selector.select() - print(f"{len(test_cases)} test case(s) selected") - for test_case in test_cases: - print(f"- {test_case.name}: {test_case.description}") - - -def run_tests( - ctx: TestContext, device: NitrokeyTrussedBase, selector: TestSelector -) -> bool: - test_cases = selector.select() - if not test_cases: - raise CliException("No test cases selected", support_hint=False) - - results = [] - - local_print("") - local_print(f"Running tests for {device.name} at {device.path}") - local_print("") - - n = len(test_cases) - idx_len = len(str(n)) - name_len = max([len(test_case.name) for test_case in test_cases]) + 2 - description_len = max([len(test_case.description) for test_case in test_cases]) + 2 - status_len = max([len(status.name) for status in TestStatus]) + 2 - - for (i, test_case) in enumerate(test_cases): - try: - result = test_case.fn(ctx, device) - except Exception: - result = TestResult(TestStatus.FAILURE, exc_info=sys.exc_info()) - results.append(result) - - idx = str(i + 1).rjust(idx_len) - name = test_case.name.ljust(name_len) - description = test_case.description.ljust(description_len) - status = result.status.name.ljust(status_len) - msg = "" - if result.data: - msg = str(result.data) - elif result.exc_info[1]: - logger.error( - f"An exception occured during the execution of the test {test_case.name}:", - exc_info=result.exc_info, - ) - msg = str(result.exc_info[1]) - - local_print(f"[{idx}/{n}]\t{name}\t{description}\t{status}\t{msg}") - - success = len([result for result in results if result.status == TestStatus.SUCCESS]) - skipped = len([result for result in results if result.status == TestStatus.SKIPPED]) - failed = len([result for result in results if result.status == TestStatus.FAILURE]) - local_print("") - local_print(f"{n} tests, {success} successful, {skipped} skipped, {failed} failed") - - return all([result.status != TestStatus.FAILURE for result in results]) From b2255f121e698035bfbd963f6768551137ea42b3 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Fri, 26 Jan 2024 14:54:52 +0100 Subject: [PATCH 6/8] nk3: Move provisioner into trussed module --- pynitrokey/cli/nk3/__init__.py | 99 +---------------- pynitrokey/cli/trussed/__init__.py | 102 +++++++++++++++++- .../{nk3 => trussed}/provisioner_app.py | 5 +- 3 files changed, 104 insertions(+), 102 deletions(-) rename pynitrokey/{nk3 => trussed}/provisioner_app.py (90%) diff --git a/pynitrokey/cli/nk3/__init__.py b/pynitrokey/cli/nk3/__init__.py index cc0b7e7b..05b410d6 100644 --- a/pynitrokey/cli/nk3/__init__.py +++ b/pynitrokey/cli/nk3/__init__.py @@ -9,14 +9,9 @@ import os.path import sys -from hashlib import sha256 -from typing import BinaryIO, List, Optional +from typing import List, Optional import click -from cryptography import x509 -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey -from ecdsa import NIST256p, SigningKey from pynitrokey.cli import trussed from pynitrokey.cli.exceptions import CliException @@ -24,7 +19,6 @@ from pynitrokey.helpers import DownloadProgressBar, check_experimental_flag, local_print from pynitrokey.nk3.bootloader import Nitrokey3Bootloader from pynitrokey.nk3.device import Nitrokey3Device -from pynitrokey.nk3.provisioner_app import ProvisionerApp from pynitrokey.nk3.updates import REPOSITORY, get_firmware_update from pynitrokey.trussed.base import NitrokeyTrussedBase from pynitrokey.trussed.bootloader import ( @@ -375,97 +369,6 @@ def wink(ctx: Context) -> None: device.wink() -@nk3.group(hidden=True) -def provision() -> None: - """ - Provision the device. This command is only used during the production - process and not available for regular devices. - """ - pass - - -@provision.command("fido2") -@click.pass_obj -@click.option( - "--key", - "key_file", - required=True, - type=click.File("rb"), - help="The path of the FIDO2 attestation key", -) -@click.option( - "--cert", - "cert_file", - required=True, - type=click.File("rb"), - help="The path of the FIDO2 attestation certificate", -) -def provision_fido2(ctx: Context, key_file: BinaryIO, cert_file: BinaryIO) -> None: - """Provision the FIDO2 attestation key and certificate.""" - key = key_file.read() - cert = cert_file.read() - - if len(key) != 36: - raise CliException(f"Invalid key length {len(key)} (expected 36)") - ecdsa_key = SigningKey.from_string(key[4:], curve=NIST256p) - pem_pubkey = serialization.load_pem_public_key( - ecdsa_key.get_verifying_key().to_pem() - ) - - x509_cert = x509.load_der_x509_certificate(cert) - cert_pubkey = x509_cert.public_key() - - if not isinstance(pem_pubkey, EllipticCurvePublicKey): - raise CliException("The FIDO2 attestation key is not an EC key") - if not isinstance(cert_pubkey, EllipticCurvePublicKey): - raise CliException( - "The FIDO2 attestation certificate does not contain an EC key" - ) - if pem_pubkey.public_numbers() != cert_pubkey.public_numbers(): - raise CliException( - "The FIDO2 attestation certificate does not match the public key" - ) - - # See https://www.w3.org/TR/webauthn-2/#sctn-packed-attestation-cert-requirements - if x509_cert.version != x509.Version.v3: - raise CliException( - f"Unexpected certificate version {x509_cert.version} (expected v3)" - ) - - subject_attrs = { - attr.rfc4514_attribute_name: attr.value for attr in x509_cert.subject - } - for name in ["C", "CN", "O", "OU"]: - if name not in subject_attrs: - raise CliException(f"Missing subject {name} in certificate") - if subject_attrs["OU"] != "Authenticator Attestation": - raise CliException( - f"Unexpected certificate subject OU {subject_attrs['OU']!r} (expected " - "Authenticator Attestation)" - ) - - found_aaguid = False - for extension in x509_cert.extensions: - if extension.oid.dotted_string == "1.3.6.1.4.1.45724.1.1.4": - found_aaguid = True - if not found_aaguid: - raise CliException("Missing AAGUID extension in certificate") - - basic_constraints = x509_cert.extensions.get_extension_for_class( - x509.BasicConstraints - ) - if basic_constraints.value.ca: - raise CliException("CA must be set to false in the basic constraints") - - cert_hash = sha256(cert).digest().hex() - print(f"FIDO2 certificate hash: {cert_hash}") - - with ctx.connect_device() as device: - provisioner = ProvisionerApp(device) - provisioner.write_file(b"fido/x5c/00", cert) - provisioner.write_file(b"fido/sec/00", key) - - # This import has to be added here to avoid circular dependency # Import "secrets" subcommand from the secrets module from . import secrets # noqa: F401,E402 diff --git a/pynitrokey/cli/trussed/__init__.py b/pynitrokey/cli/trussed/__init__.py index 7d3e9d8a..a1919045 100644 --- a/pynitrokey/cli/trussed/__init__.py +++ b/pynitrokey/cli/trussed/__init__.py @@ -9,9 +9,14 @@ import logging from abc import ABC, abstractmethod -from typing import Callable, Generic, Optional, Sequence, TypeVar +from hashlib import sha256 +from typing import BinaryIO, Callable, Generic, Optional, Sequence, TypeVar import click +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey +from ecdsa import NIST256p, SigningKey from pynitrokey.cli.exceptions import CliException from pynitrokey.helpers import Retries, local_print, require_windows_admin @@ -20,6 +25,7 @@ from pynitrokey.trussed.bootloader import NitrokeyTrussedBootloader from pynitrokey.trussed.device import NitrokeyTrussedDevice from pynitrokey.trussed.exceptions import TimeoutException +from pynitrokey.trussed.provisioner_app import ProvisionerApp from .test import TestCase @@ -141,6 +147,7 @@ def prepare_group() -> None: def add_commands(group: click.Group) -> None: group.add_command(list) + group.add_command(provision) group.add_command(reboot) group.add_command(rng) group.add_command(status) @@ -162,6 +169,99 @@ def list(ctx: Context[Bootloader, Device]) -> None: local_print(f"{device.path}: {device.name}") +@click.group(hidden=True) +def provision() -> None: + """ + Provision the device. This command is only used during the production + process and not available for regular devices. + """ + pass + + +@provision.command("fido2") +@click.pass_obj +@click.option( + "--key", + "key_file", + required=True, + type=click.File("rb"), + help="The path of the FIDO2 attestation key", +) +@click.option( + "--cert", + "cert_file", + required=True, + type=click.File("rb"), + help="The path of the FIDO2 attestation certificate", +) +def provision_fido2( + ctx: Context[Bootloader, Device], key_file: BinaryIO, cert_file: BinaryIO +) -> None: + """Provision the FIDO2 attestation key and certificate.""" + key = key_file.read() + cert = cert_file.read() + + if len(key) != 36: + raise CliException(f"Invalid key length {len(key)} (expected 36)") + ecdsa_key = SigningKey.from_string(key[4:], curve=NIST256p) + pem_pubkey = serialization.load_pem_public_key( + ecdsa_key.get_verifying_key().to_pem() + ) + + x509_cert = x509.load_der_x509_certificate(cert) + cert_pubkey = x509_cert.public_key() + + if not isinstance(pem_pubkey, EllipticCurvePublicKey): + raise CliException("The FIDO2 attestation key is not an EC key") + if not isinstance(cert_pubkey, EllipticCurvePublicKey): + raise CliException( + "The FIDO2 attestation certificate does not contain an EC key" + ) + if pem_pubkey.public_numbers() != cert_pubkey.public_numbers(): + raise CliException( + "The FIDO2 attestation certificate does not match the public key" + ) + + # See https://www.w3.org/TR/webauthn-2/#sctn-packed-attestation-cert-requirements + if x509_cert.version != x509.Version.v3: + raise CliException( + f"Unexpected certificate version {x509_cert.version} (expected v3)" + ) + + subject_attrs = { + attr.rfc4514_attribute_name: attr.value for attr in x509_cert.subject + } + for name in ["C", "CN", "O", "OU"]: + if name not in subject_attrs: + raise CliException(f"Missing subject {name} in certificate") + if subject_attrs["OU"] != "Authenticator Attestation": + raise CliException( + f"Unexpected certificate subject OU {subject_attrs['OU']!r} (expected " + "Authenticator Attestation)" + ) + + found_aaguid = False + for extension in x509_cert.extensions: + if extension.oid.dotted_string == "1.3.6.1.4.1.45724.1.1.4": + found_aaguid = True + if not found_aaguid: + raise CliException("Missing AAGUID extension in certificate") + + basic_constraints = x509_cert.extensions.get_extension_for_class( + x509.BasicConstraints + ) + if basic_constraints.value.ca: + raise CliException("CA must be set to false in the basic constraints") + + cert_hash = sha256(cert).digest().hex() + print(f"FIDO2 certificate hash: {cert_hash}") + + with ctx.connect_device() as device: + provisioner = ProvisionerApp(device) + provisioner.write_file(b"fido/x5c/00", cert) + provisioner.write_file(b"fido/sec/00", key) + + @click.command() @click.option( "--bootloader", diff --git a/pynitrokey/nk3/provisioner_app.py b/pynitrokey/trussed/provisioner_app.py similarity index 90% rename from pynitrokey/nk3/provisioner_app.py rename to pynitrokey/trussed/provisioner_app.py index c305ed3d..62feaca8 100644 --- a/pynitrokey/nk3/provisioner_app.py +++ b/pynitrokey/trussed/provisioner_app.py @@ -2,8 +2,7 @@ from enum import Enum from typing import Optional -from pynitrokey.nk3.device import Nitrokey3Device -from pynitrokey.trussed.device import App +from pynitrokey.trussed.device import App, NitrokeyTrussedDevice @enum.unique @@ -21,7 +20,7 @@ class ProvisionerCommand(Enum): class ProvisionerApp: - def __init__(self, device: Nitrokey3Device) -> None: + def __init__(self, device: NitrokeyTrussedDevice) -> None: self.device = device try: From 7b99e2b666bcdb6c424fee8634829405b2b68de8 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Tue, 23 Jan 2024 17:00:15 +0100 Subject: [PATCH 7/8] Add nkpk commands --- pynitrokey/cli/__init__.py | 3 ++ pynitrokey/cli/nkpk.py | 65 +++++++++++++++++++++++++++++++++++ pynitrokey/nkpk.py | 70 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 pynitrokey/cli/nkpk.py create mode 100644 pynitrokey/nkpk.py diff --git a/pynitrokey/cli/__init__.py b/pynitrokey/cli/__init__.py index 55285ff5..a5cc42ec 100644 --- a/pynitrokey/cli/__init__.py +++ b/pynitrokey/cli/__init__.py @@ -24,6 +24,7 @@ from pynitrokey.cli.fido2 import fido2 from pynitrokey.cli.nethsm import nethsm from pynitrokey.cli.nk3 import nk3 +from pynitrokey.cli.nkpk import nkpk from pynitrokey.cli.pro import pro from pynitrokey.cli.start import start from pynitrokey.cli.storage import storage @@ -87,6 +88,7 @@ def nitropy(): nitropy.add_command(fido2) nitropy.add_command(nethsm) nitropy.add_command(nk3) +nitropy.add_command(nkpk) nitropy.add_command(start) nitropy.add_command(storage) nitropy.add_command(pro) @@ -105,6 +107,7 @@ def _list(): fido2.commands["list"].callback() start.commands["list"].callback() nk3.commands["list"].callback() + nkpk.commands["list"].callback() # TODO add other handled models diff --git a/pynitrokey/cli/nkpk.py b/pynitrokey/cli/nkpk.py new file mode 100644 index 00000000..ac6c04ae --- /dev/null +++ b/pynitrokey/cli/nkpk.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 Nitrokey Developers +# +# Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be +# copied, modified, or distributed except according to those terms. + +from typing import Optional, Sequence + +import click + +from pynitrokey.cli.trussed.test import TestCase +from pynitrokey.helpers import local_print +from pynitrokey.nkpk import NitrokeyPasskeyBootloader, NitrokeyPasskeyDevice +from pynitrokey.trussed.base import NitrokeyTrussedBase +from pynitrokey.trussed.device import NitrokeyTrussedDevice + +from . import trussed + + +class Context(trussed.Context[NitrokeyPasskeyBootloader, NitrokeyPasskeyDevice]): + def __init__(self, path: Optional[str]) -> None: + super().__init__(path, NitrokeyPasskeyBootloader, NitrokeyPasskeyDevice) + + @property + def test_cases(self) -> list[TestCase]: + from pynitrokey.cli.trussed import tests + + return [ + tests.test_uuid_query, + tests.test_firmware_version_query, + tests.test_device_status, + tests.test_bootloader_configuration, + tests.test_firmware_mode, + tests.test_fido2, + ] + + @property + def device_name(self) -> str: + return "Nitrokey Passkey" + + def open(self, path: str) -> Optional[NitrokeyTrussedBase]: + from pynitrokey.nkpk import open + + return open(path) + + def list_all(self) -> list[NitrokeyTrussedBase]: + from pynitrokey.nkpk import list + + return list() + + +@click.group() +@click.option("-p", "--path", "path", help="The path of the Nitrokey 3 device") +@click.pass_context +def nkpk(ctx: click.Context, path: Optional[str]) -> None: + """Interact with Nitrokey Passkey devices, see subcommands.""" + ctx.obj = Context(path) + trussed.prepare_group() + + +# shared Trussed commands +trussed.add_commands(nkpk) diff --git a/pynitrokey/nkpk.py b/pynitrokey/nkpk.py new file mode 100644 index 00000000..3a145807 --- /dev/null +++ b/pynitrokey/nkpk.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 Nitrokey Developers +# +# Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be +# copied, modified, or distributed except according to those terms. + +from typing import List, Optional + +from fido2.hid import CtapHidDevice + +from pynitrokey.trussed import VID_NITROKEY +from pynitrokey.trussed.base import NitrokeyTrussedBase +from pynitrokey.trussed.bootloader.nrf52 import NitrokeyTrussedBootloaderNrf52 +from pynitrokey.trussed.device import NitrokeyTrussedDevice + +PID_NITROKEY_PASSKEY_DEVICE = 0x42F3 +PID_NITROKEY_PASSKEY_BOOTLOADER = 0x42F4 + + +class NitrokeyPasskeyDevice(NitrokeyTrussedDevice): + def __init__(self, device: CtapHidDevice) -> None: + super().__init__(device) + + @property + def pid(self) -> int: + return PID_NITROKEY_PASSKEY_DEVICE + + @property + def name(self) -> str: + return "Nitrokey Passkey" + + +class NitrokeyPasskeyBootloader(NitrokeyTrussedBootloaderNrf52): + @property + def name(self) -> str: + return "Nitrokey Passkey Bootloader" + + @property + def pid(self) -> int: + return PID_NITROKEY_PASSKEY_BOOTLOADER + + @classmethod + def list(cls) -> List["NitrokeyPasskeyBootloader"]: + return cls.list_vid_pid(VID_NITROKEY, PID_NITROKEY_PASSKEY_BOOTLOADER) + + @classmethod + def open(cls, path: str) -> Optional["NitrokeyPasskeyBootloader"]: + return cls.open_vid_pid(VID_NITROKEY, PID_NITROKEY_PASSKEY_BOOTLOADER, path) + + +def list() -> List[NitrokeyTrussedBase]: + devices: List[NitrokeyTrussedBase] = [] + devices.extend(NitrokeyPasskeyBootloader.list()) + devices.extend(NitrokeyPasskeyDevice.list()) + return devices + + +def open(path: str) -> Optional[NitrokeyTrussedBase]: + device = NitrokeyPasskeyDevice.open(path) + bootloader_device = NitrokeyPasskeyBootloader.open(path) + if device and bootloader_device: + raise Exception(f"Found multiple devices at path {path}") + if device: + return device + if bootloader_device: + return bootloader_device + return None From e3c0f7c0577d3ef6ece4aa0723ce7887855ebcfc Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Wed, 31 Jan 2024 10:40:38 +0100 Subject: [PATCH 8/8] nk3/nkpk: Fix device listing --- pynitrokey/cli/__init__.py | 7 +++++-- pynitrokey/cli/nk3/__init__.py | 4 ++++ pynitrokey/cli/nkpk.py | 4 ++++ pynitrokey/cli/trussed/__init__.py | 4 ++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/pynitrokey/cli/__init__.py b/pynitrokey/cli/__init__.py index a5cc42ec..5c9cbb08 100644 --- a/pynitrokey/cli/__init__.py +++ b/pynitrokey/cli/__init__.py @@ -104,10 +104,13 @@ def version(): def _list(): + from .nk3 import _list as list_nk3 + from .nkpk import _list as list_nkpk + fido2.commands["list"].callback() start.commands["list"].callback() - nk3.commands["list"].callback() - nkpk.commands["list"].callback() + list_nk3() + list_nkpk() # TODO add other handled models diff --git a/pynitrokey/cli/nk3/__init__.py b/pynitrokey/cli/nk3/__init__.py index 05b410d6..8f4b2621 100644 --- a/pynitrokey/cli/nk3/__init__.py +++ b/pynitrokey/cli/nk3/__init__.py @@ -75,6 +75,10 @@ def nk3(ctx: click.Context, path: Optional[str]) -> None: trussed.add_commands(nk3) +def _list() -> None: + trussed._list(Context(None)) + + @nk3.command() @click.argument("path", default=".") @click.option( diff --git a/pynitrokey/cli/nkpk.py b/pynitrokey/cli/nkpk.py index ac6c04ae..d64bd14e 100644 --- a/pynitrokey/cli/nkpk.py +++ b/pynitrokey/cli/nkpk.py @@ -63,3 +63,7 @@ def nkpk(ctx: click.Context, path: Optional[str]) -> None: # shared Trussed commands trussed.add_commands(nkpk) + + +def _list() -> None: + trussed._list(Context(None)) diff --git a/pynitrokey/cli/trussed/__init__.py b/pynitrokey/cli/trussed/__init__.py index a1919045..8409ac77 100644 --- a/pynitrokey/cli/trussed/__init__.py +++ b/pynitrokey/cli/trussed/__init__.py @@ -159,6 +159,10 @@ def add_commands(group: click.Group) -> None: @click.pass_obj def list(ctx: Context[Bootloader, Device]) -> None: """List all devices.""" + return _list(ctx) + + +def _list(ctx: Context[Bootloader, Device]) -> None: local_print(f":: '{ctx.device_name}' keys") for device in ctx.list_all(): with device as device: