Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-nitrokey for openSUSE:Factory checked in at 2025-08-07 16:48:44 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-nitrokey (Old) and /work/SRC/openSUSE:Factory/.python-nitrokey.new.1085 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-nitrokey" Thu Aug 7 16:48:44 2025 rev:5 rq:1297995 version:0.4.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-nitrokey/python-nitrokey.changes 2025-07-09 17:29:56.863403012 +0200 +++ /work/SRC/openSUSE:Factory/.python-nitrokey.new.1085/python-nitrokey.changes 2025-08-07 16:49:57.539181616 +0200 @@ -1,0 +2,22 @@ +Mon Aug 4 09:21:43 UTC 2025 - Johannes Kastl <opensuse_buildserv...@ojkastl.de> + +- update to 0.4.0: + * nitrokey.trussed.admin_app.InitStatus: add support for + EXT_FLASH_NEED_REFORMAT + * Use poetry-core v2 as build backend. + * Bump minimum Python version to 3.10. + * nitrokey.trussed.Model: Remove firmware_repository and + firmware_pattern properties. + * nitrokey.nk3.updates: + - Move to nitrokey.trussed.updates and prepare adding NKPK + support. + - Return device status after an update. + - Add model argument to get_firmware_update. + - Add get_firmware_repository method. + - Replace connection callbacks in Updater with DeviceHandler + class. +- add version constraints for dependencies +- relax constraint on protobuf, see + https://github.com/Nitrokey/nitrokey-sdk-py/issues/84#issuecomment-3158310783 + +------------------------------------------------------------------- Old: ---- nitrokey-0.3.2.tar.gz New: ---- nitrokey-0.4.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-nitrokey.spec ++++++ --- /var/tmp/diff_new_pack.OeryBv/_old 2025-08-07 16:49:59.367258511 +0200 +++ /var/tmp/diff_new_pack.OeryBv/_new 2025-08-07 16:49:59.407260193 +0200 @@ -1,7 +1,7 @@ # # spec file for package python-nitrokey # -# Copyright (c) 2025 SUSE LLC +# Copyright (c) 2025 SUSE LLC and contributors # # All modifications and additions to the file contributed by third parties # remain the property of their copyright owners, unless otherwise agreed @@ -18,29 +18,39 @@ %{?sle15_python_module_pythons} Name: python-nitrokey -Version: 0.3.2 +Version: 0.4.0 Release: 0 Summary: Nitrokey Python SDK License: Apache-2.0 URL: https://github.com/Nitrokey/nitrokey-sdk-py Source0: https://files.pythonhosted.org/packages/source/n/nitrokey/nitrokey-%{version}.tar.gz Source99: python-nitrokey.rpmlintrc -BuildRequires: %{python_module base >= 3.9.2} -BuildRequires: %{python_module fido2 >= 1.1.2 with %python-fido2 < 3} +BuildRequires: %{python_module base >= 3.10} BuildRequires: %{python_module pip} BuildRequires: %{python_module poetry-core >= 1} BuildRequires: %{python_module wheel} +# Runtime dependencies +BuildRequires: %{python_module cryptography >= 41} +BuildRequires: %{python_module crcmod >= 1.7 with %python-crcmod < 2} +BuildRequires: %{python_module fido2 >= 1.1.2 with %python-fido2 < 3} +BuildRequires: %{python_module hidapi >= 0.14 with %python-hidapi < 0.15} +BuildRequires: %{python_module protobuf >= 5.26 with %python-protobuf < 7} +BuildRequires: %{python_module pyserial >= 3.5 with %python-pyserial < 4} +BuildRequires: %{python_module requests >= 2 with %python-requests < 3} +BuildRequires: %{python_module semver >= 3 with %python-semver < 4} +BuildRequires: %{python_module tlv8 >= 0.10 with %python-tlv8 < 0.11} +# BuildRequires: fdupes BuildRequires: python-rpm-macros -Requires: python-crcmod -Requires: python-cryptography -Requires: python-hidapi -Requires: python-protobuf -Requires: python-pyserial -Requires: python-requests -Requires: python-semver -Requires: python-tlv8 +Requires: python-cryptography >= 41 +Requires: (python-crcmod >= 1.7 with python-crcmod < 2) Requires: (python-fido2 >= 1.1.2 with python-fido2 < 3) +Requires: (python-hidapi >= 0.14 with python-hidapi < 0.15) +Requires: (python-protobuf >= 5.26 with python-protobuf < 7) +Requires: (python-pyserial >= 3.5 with python-pyserial < 4) +Requires: (python-requests >= 2 with python-requests < 3) +Requires: (python-semver >= 3 with python-semver < 4) +Requires: (python-tlv8 >= 0.10 with python-tlv8 < 0.11) BuildArch: noarch %python_subpackages ++++++ nitrokey-0.3.2.tar.gz -> nitrokey-0.4.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nitrokey-0.3.2/PKG-INFO new/nitrokey-0.4.0/PKG-INFO --- old/nitrokey-0.3.2/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 +++ new/nitrokey-0.4.0/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 @@ -1,12 +1,11 @@ -Metadata-Version: 2.1 +Metadata-Version: 2.3 Name: nitrokey -Version: 0.3.2 +Version: 0.4.0 Summary: Nitrokey Python SDK -Home-page: https://github.com/Nitrokey/nitrokey-sdk-py License: Apache-2.0 or MIT Author: Nitrokey Author-email: p...@nitrokey.com -Requires-Python: >=3.9.2,<4.0.0 +Requires-Python: >=3.10, <4 Classifier: Intended Audience :: Developers Classifier: License :: Other/Proprietary License Classifier: Programming Language :: Python :: 3 @@ -14,13 +13,13 @@ Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 -Requires-Dist: crcmod (>=1.7,<2.0) +Requires-Dist: crcmod (>=1.7,<2) Requires-Dist: cryptography (>=41) Requires-Dist: fido2 (>=1.1.2,<3) Requires-Dist: hidapi (>=0.14,<0.15) -Requires-Dist: protobuf (>=5.26,<6.0) -Requires-Dist: pyserial (>=3.5,<4.0) -Requires-Dist: requests (>=2,<3) +Requires-Dist: protobuf (>=5.26,<6) +Requires-Dist: pyserial (>=3.5,<4) +Requires-Dist: requests (>=2.16,<3) Requires-Dist: semver (>=3,<4) Requires-Dist: tlv8 (>=0.10,<0.11) Project-URL: Repository, https://github.com/Nitrokey/nitrokey-sdk-py @@ -76,7 +75,7 @@ ## Compatibility -The Nitrokey Python SDK currently requires Python 3.9.2 or later. +The Nitrokey Python SDK currently requires Python 3.10 or later. Support for old Python versions may be dropped in minor releases. ## Related Projects @@ -92,7 +91,7 @@ The following software is required for the development of the SDK: -- Python 3.9 or newer +- Python 3.10 or newer - [poetry](https://python-poetry.org/) - GNU Make - git diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nitrokey-0.3.2/README.md new/nitrokey-0.4.0/README.md --- old/nitrokey-0.3.2/README.md 2025-07-08 12:41:08.000000000 +0200 +++ new/nitrokey-0.4.0/README.md 1970-01-01 01:00:00.000000000 +0100 @@ -48,7 +48,7 @@ ## Compatibility -The Nitrokey Python SDK currently requires Python 3.9.2 or later. +The Nitrokey Python SDK currently requires Python 3.10 or later. Support for old Python versions may be dropped in minor releases. ## Related Projects @@ -64,7 +64,7 @@ The following software is required for the development of the SDK: -- Python 3.9 or newer +- Python 3.10 or newer - [poetry](https://python-poetry.org/) - GNU Make - git diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nitrokey-0.3.2/pyproject.toml new/nitrokey-0.4.0/pyproject.toml --- old/nitrokey-0.3.2/pyproject.toml 2025-07-08 12:41:08.000000000 +0200 +++ new/nitrokey-0.4.0/pyproject.toml 1970-01-01 01:00:00.000000000 +0100 @@ -1,37 +1,45 @@ [build-system] -requires = ["poetry-core >=1,<3"] +requires = ["poetry-core >=2, <3"] build-backend = "poetry.core.masonry.api" -[tool.poetry] +[project] name = "nitrokey" -version = "0.3.2" +version = "0.4.0" description = "Nitrokey Python SDK" -authors = ["Nitrokey <p...@nitrokey.com>"] +authors = [ + { name = "Nitrokey", email = "p...@nitrokey.com" }, +] license = "Apache-2.0 or MIT" readme = "README.md" -repository = "https://github.com/Nitrokey/nitrokey-sdk-py" -classifiers = [ - "Intended Audience :: Developers", -] packages = [ { include = "nitrokey", from = "src" }, ] +requires-python = ">=3.10, <4" +dynamic = ["classifiers"] + +dependencies = [ + "cryptography >=41", + "fido2 >=1.1.2, <3", + "requests >=2.16, <3", + "semver >=3, <4", + "tlv8 >=0.10, <0.11", + + # lpc55 + "crcmod >=1.7, <2", + "hidapi >=0.14, <0.15", + + # nrf52 + "protobuf >=5.26, <6", + "pyserial >=3.5, <4", +] -[tool.poetry.dependencies] -cryptography = ">=41" -fido2 = ">=1.1.2, <3" -python = "^3.9.2" -requests = "^2" -semver = "^3" -tlv8 = "^0.10" - -# lpc55 -crcmod = "^1.7" -hidapi = "^0.14" - -# nrf52 -protobuf = "^5.26" -pyserial = "^3.5" +[project.urls] +repository = "https://github.com/Nitrokey/nitrokey-sdk-py" + +[tool.poetry] +classifiers = [ + "Intended Audience :: Developers", +] [tool.poetry.group.dev] optional = true @@ -45,20 +53,35 @@ rstcheck = { version = "^6", extras = ["sphinx"] } sphinx = "^7" types-protobuf = "^5.26" -types-requests = "^2.32" +types-requests = "^2.16" typing-extensions = "^4.1" +[tool.uv] +dev-dependencies = [ + # These dependencies are required for running the type checks. + "fake-winreg >=1.6, <2", + "mypy >=1.4, <2", + "types-protobuf >=5.26, <6", + "types-requests >=2.16, <3", + + # These additional lower bounds are required for --resolution lowest. + # As these are transitive dependencies, we don’t include them in our dependency list. + "cffi >=1.14.1", + "typing-extensions >=4.1", + "urllib3 >= 2", +] + [tool.black] -target-version = ["py39"] +target-version = ["py310"] [tool.isort] -py_version = "39" +py_version = "310" profile = "black" [tool.mypy] mypy_path = "stubs" show_error_codes = true -python_version = "3.9" +python_version = "3.10" strict = true [tool.rstcheck] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/nk3/_bootloader.py new/nitrokey-0.4.0/src/nitrokey/nk3/_bootloader.py --- old/nitrokey-0.3.2/src/nitrokey/nk3/_bootloader.py 2025-07-08 12:41:08.000000000 +0200 +++ new/nitrokey-0.4.0/src/nitrokey/nk3/_bootloader.py 1970-01-01 01:00:00.000000000 +0100 @@ -8,12 +8,17 @@ from typing import List, Optional, Sequence from nitrokey import _VID_NITROKEY +from nitrokey.trussed._base import Model from nitrokey.trussed._bootloader import TrussedBootloader from nitrokey.trussed._bootloader.lpc55 import TrussedBootloaderLpc55 from nitrokey.trussed._bootloader.nrf52 import SignatureKey, TrussedBootloaderNrf52 class NK3Bootloader(TrussedBootloader): + @property + def model(self) -> Model: + return Model.NK3 + @staticmethod def list() -> List["NK3Bootloader"]: devices: List[NK3Bootloader] = [] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/nk3/_device.py new/nitrokey-0.4.0/src/nitrokey/nk3/_device.py --- old/nitrokey-0.3.2/src/nitrokey/nk3/_device.py 2025-07-08 12:41:08.000000000 +0200 +++ new/nitrokey-0.4.0/src/nitrokey/nk3/_device.py 1970-01-01 01:00:00.000000000 +0100 @@ -10,7 +10,7 @@ from fido2.hid import CtapHidDevice, list_descriptors, open_device from nitrokey import _VID_NITROKEY -from nitrokey.trussed import Fido2Certs, TrussedDevice, Version +from nitrokey.trussed import Fido2Certs, Model, TrussedDevice, Version FIDO2_CERTS = [ Fido2Certs( @@ -37,6 +37,10 @@ super().__init__(device, FIDO2_CERTS) @property + def model(self) -> Model: + return Model.NK3 + + @property def pid(self) -> int: from . import _PID_NK3_DEVICE diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/nk3/updates.py new/nitrokey-0.4.0/src/nitrokey/nk3/updates.py --- old/nitrokey-0.3.2/src/nitrokey/nk3/updates.py 2025-07-08 12:41:08.000000000 +0200 +++ new/nitrokey-0.4.0/src/nitrokey/nk3/updates.py 1970-01-01 01:00:00.000000000 +0100 @@ -1,527 +0,0 @@ -# Copyright 2022 Nitrokey Developers -# -# Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or -# http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or -# 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 importlib.metadata -import logging -import platform -import time -from abc import ABC, abstractmethod -from collections.abc import Set -from contextlib import contextmanager -from importlib.metadata import PackageNotFoundError -from io import BytesIO -from typing import TYPE_CHECKING, Any, Callable, Iterator, List, Optional, Union - -from nitrokey._helpers import Retries -from nitrokey.nk3 import NK3, NK3Bootloader -from nitrokey.trussed import TimeoutException, TrussedBase, Version -from nitrokey.trussed._bootloader import ( - FirmwareContainer, - Model, - Variant, - validate_firmware_image, -) -from nitrokey.trussed._bootloader.lpc55_upload.mboot.exceptions import ( - McuBootConnectionError, -) -from nitrokey.trussed.admin_app import BootMode, Status -from nitrokey.trussed.admin_app import Variant as AdminAppVariant -from nitrokey.updates import Asset, Release - -if TYPE_CHECKING: - import typing_extensions - -logger = logging.getLogger(__name__) - - -@enum.unique -class Warning(enum.Enum): - """ - A warning that can occur during a firmware update. - - By default, these warnings abort the firmware update. This enum can be used to select types - of warnings that should be ignored and not cause the firmware update to fail. - """ - - IFS_MIGRATION_V2 = "ifs-migration-v2" - MISSING_STATUS = "missing-status" - SDK_VERSION = "sdk-version" - UPDATE_FROM_BOOTLOADER = "update-from-bootloader" - - @property - def message(self) -> str: - if self == Warning.IFS_MIGRATION_V2: - return ( - "Not enough space on the internal filesystem to perform the firmware" - " update. See the release notes for more information:" - " https://github.com/Nitrokey/nitrokey-3-firmware/releases/tag/v1.8.2-test.20250312" - ) - if self == Warning.MISSING_STATUS: - return ( - "Could not determine the device state as the current firmware is too old." - " Please update to firmware version v1.3.1 first." - ) - if self == Warning.SDK_VERSION: - return ( - "Your Nitrokey SDK version is outdated. Please update this program to the latest" - " version and try again." - ) - if self == Warning.UPDATE_FROM_BOOTLOADER: - return ( - "The current state of the device cannot be checked as it is already in bootloader" - " mode. Please review the release notes at:" - " https://github.com/Nitrokey/nitrokey-3-firmware/releases" - ) - - if TYPE_CHECKING: - typing_extensions.assert_never(self) - - return self.value - - @classmethod - def from_str(cls, s: str) -> "Warning": - for w in cls: - if w.value == s: - return w - raise ValueError(f"Unexpected update warning id: {s}") - - -@enum.unique -class _Migration(enum.Enum): - # IFS migration to use journaling on the NRF52 introduced in v1.3.0 - NRF_IFS_MIGRATION = enum.auto() - # IFS migration to filesystem layout 2 in v1.8.2 (FIDO2 RK migration) - IFS_MIGRATION_V2 = enum.auto() - - @classmethod - def get( - cls, - variant: Union[Variant, AdminAppVariant], - current: Optional[Version], - new: Version, - ) -> frozenset["_Migration"]: - if isinstance(variant, AdminAppVariant): - if variant == AdminAppVariant.USBIP: - raise ValueError("Cannot perform firmware update for USBIP runner") - elif variant == AdminAppVariant.LPC55: - variant = Variant.LPC55 - elif variant == AdminAppVariant.NRF52: - variant = Variant.NRF52 - else: - if TYPE_CHECKING: - typing_extensions.assert_never(variant) - - raise ValueError(f"Unsupported device variant: {variant}") - - migrations = set() - - if variant == Variant.NRF52: - if ( - current is None - or current <= Version(1, 2, 2) - and new >= Version(1, 3, 0) - ): - migrations.add(cls.NRF_IFS_MIGRATION) - - ifs_migration_v2 = Version(1, 8, 2) - if ( - current is not None - and current < ifs_migration_v2 - and new >= ifs_migration_v2 - ): - migrations.add(cls.IFS_MIGRATION_V2) - - return frozenset(migrations) - - -def get_firmware_update(release: Release) -> Asset: - return release.require_asset(Model.NK3.firmware_pattern) - - -def _get_extra_information(migrations: Set[_Migration]) -> List[str]: - """Return additional information for the device after update based on update-path""" - - out = [] - if _Migration.NRF_IFS_MIGRATION in migrations: - out += [ - "", - "During this update process the internal filesystem will be migrated!", - "- Migration will only work, if your internal filesystem does not contain more than 45 Resident Keys. If you have more please remove some.", - "- After the update it might take up to 3 minutes for the first boot.", - "Never unplug the device while the LED is active!", - ] - return out - - -def _get_finalization_wait_retries(migrations: Set[_Migration]) -> int: - """Return number of retries to wait for the device after update based on update-path""" - - out = 60 - if _Migration.NRF_IFS_MIGRATION in migrations: - # max time 150secs == 300 retries - out = 500 - return out - - -class UpdateUi(ABC): - @abstractmethod - def error(self, *msgs: Any) -> Exception: - pass - - @abstractmethod - def show_warning(self, warning: Warning) -> None: - pass - - @abstractmethod - def raise_warning(self, warning: Warning) -> Exception: - pass - - @abstractmethod - def abort(self, *msgs: Any) -> Exception: - pass - - @abstractmethod - def abort_downgrade(self, current: Version, image: Version) -> Exception: - pass - - @abstractmethod - def abort_pynitrokey_version( - self, current: Version, required: Version - ) -> Exception: - pass - - @abstractmethod - def confirm_download(self, current: Optional[Version], new: Version) -> None: - pass - - @abstractmethod - def confirm_update(self, current: Optional[Version], new: Version) -> None: - pass - - @abstractmethod - def confirm_pynitrokey_version(self, current: Version, required: Version) -> None: - pass - - @abstractmethod - def confirm_extra_information(self, extra_info: List[str]) -> None: - pass - - @abstractmethod - def confirm_update_same_version(self, version: Version) -> None: - pass - - @abstractmethod - def pre_bootloader_hint(self) -> None: - pass - - @abstractmethod - def request_bootloader_confirmation(self) -> None: - pass - - @abstractmethod - @contextmanager - def download_progress_bar(self, desc: str) -> Iterator[Callable[[int, int], None]]: - pass - - @abstractmethod - @contextmanager - def update_progress_bar(self) -> Iterator[Callable[[int, int], None]]: - pass - - @abstractmethod - @contextmanager - def finalization_progress_bar(self) -> Iterator[Callable[[int, int], None]]: - pass - - -class Updater: - def __init__( - self, - ui: UpdateUi, - await_bootloader: Callable[[], NK3Bootloader], - await_device: Callable[ - [Optional[int], Optional[Callable[[int, int], None]]], NK3 - ], - ignore_warnings: Set[Warning] = frozenset(), - ) -> None: - self.ui = ui - self.await_bootloader = await_bootloader - self.await_device = await_device - self.ignore_warnings = ignore_warnings - - def _trigger_warning(self, warning: Warning) -> None: - if warning in self.ignore_warnings: - self.ui.show_warning(warning) - else: - raise self.ui.raise_warning(warning) - - def update( - self, - device: TrussedBase, - image: Optional[str], - update_version: Optional[str], - ignore_pynitrokey_version: bool = False, - ) -> Version: - update_from_bootloader = False - current_version = None - status = None - if isinstance(device, NK3Bootloader): - update_from_bootloader = True - self._trigger_warning(Warning.UPDATE_FROM_BOOTLOADER) - elif isinstance(device, NK3): - current_version = device.admin.version() - status = device.admin.status() - else: - raise self.ui.error(f"Unexpected Trussed device: {device}") - - logger.info(f"Firmware version before update: {current_version or ''}") - container = self._prepare_update(image, update_version, current_version) - - if not update_from_bootloader: - if status is None and container.version > Version.from_str("1.3.1"): - self._trigger_warning(Warning.MISSING_STATUS) - - self._check_minimum_version(container, ignore_pynitrokey_version) - - self.ui.confirm_update(current_version, container.version) - - migrations = None - if status is not None and status.variant is not None: - migrations = self._check_migrations( - status.variant, current_version, container.version, status - ) - elif isinstance(device, NK3Bootloader): - migrations = self._check_migrations( - device.variant, current_version, container.version, status - ) - - with self._get_bootloader(device) as bootloader: - if bootloader.variant not in container.images: - raise self.ui.error( - "The firmware release does not contain an image for the " - f"{bootloader.variant.value} hardware variant" - ) - try: - validate_firmware_image( - bootloader.variant, - container.images[bootloader.variant], - container.version, - Model.NK3, - ) - except Exception as e: - raise self.ui.error("Failed to validate firmware image", e) - - if migrations is None: - migrations = self._check_migrations( - bootloader.variant, current_version, container.version, status - ) - - self._perform_update(bootloader, container) - - wait_retries = _get_finalization_wait_retries(migrations) - with self.ui.finalization_progress_bar() as callback: - with self.await_device(wait_retries, callback) as device: - version = device.admin.version() - if version != container.version: - raise self.ui.error( - f"The firmware update to {container.version} was successful, but the " - f"firmware is still reporting version {version}." - ) - - return container.version - - def _prepare_update( - self, - image: Optional[str], - version: Optional[str], - current_version: Optional[Version], - ) -> FirmwareContainer: - if image: - try: - container = FirmwareContainer.parse(image, Model.NK3) - except Exception as e: - raise self.ui.error("Failed to parse firmware container", e) - self._validate_version(current_version, container.version) - return container - else: - repository = Model.NK3.firmware_repository - if version: - try: - logger.info(f"Downloading firmare version {version}") - release = repository.get_release(version) - except Exception as e: - raise self.ui.error(f"Failed to get firmware release {version}", e) - else: - try: - release = repository.get_latest_release() - logger.info(f"Latest firmware version: {release}") - except Exception as e: - raise self.ui.error("Failed to find latest firmware release", e) - - try: - release_version = Version.from_v_str(release.tag) - except ValueError as e: - raise self.ui.error("Failed to parse version from release tag", e) - self._validate_version(current_version, release_version) - self.ui.confirm_download(current_version, release_version) - return self._download_update(release) - - def _download_update(self, release: Release) -> FirmwareContainer: - try: - update = get_firmware_update(release) - except Exception as e: - raise self.ui.error( - f"Failed to find firmware image for release {release}", - e, - ) - - try: - logger.info(f"Trying to download firmware update from URL: {update.url}") - - with self.ui.download_progress_bar(update.tag) as callback: - data = update.read(callback=callback) - except Exception as e: - raise self.ui.error( - f"Failed to download latest firmware update {update.tag}", e - ) - - try: - container = FirmwareContainer.parse(BytesIO(data), Model.NK3) - except Exception as e: - raise self.ui.error( - f"Failed to parse firmware container for {update.tag}", e - ) - - release_version = Version.from_v_str(release.tag) - if release_version != container.version: - raise self.ui.error( - f"The firmware container for {update.tag} has the version {container.version}" - ) - - return container - - def _check_minimum_version( - self, container: FirmwareContainer, ignore_pynitrokey_version: bool - ) -> None: - if container.sdk: - try: - sdk_version = Version.from_str(importlib.metadata.version("nitrokey")) - except PackageNotFoundError: - raise self.ui.error("Failed to determine the Nitrokey SDK version") - - if container.sdk > sdk_version: - logger.warning( - f"Minimum SDK version required for update is {container.sdk} (current version: {sdk_version})" - ) - self._trigger_warning(Warning.SDK_VERSION) - elif container.pynitrokey: - # The minimum pynitrokey version has been replaced by the minimum SDK version, so we - # only check it if there is no minimum SDK version set. - - # this is the version of pynitrokey when we moved to the SDK - pynitrokey_version = Version.from_str("0.4.49") - if container.pynitrokey > pynitrokey_version: - if ignore_pynitrokey_version: - self.ui.confirm_pynitrokey_version( - current=pynitrokey_version, required=container.pynitrokey - ) - else: - raise self.ui.abort_pynitrokey_version( - current=pynitrokey_version, required=container.pynitrokey - ) - - def _validate_version( - self, - current_version: Optional[Version], - new_version: Version, - ) -> None: - logger.info(f"Current firmware version: {current_version}") - logger.info(f"Updated firmware version: {new_version}") - - if current_version: - if current_version.core() > new_version.core(): - raise self.ui.abort_downgrade(current_version, new_version) - elif current_version == new_version: - if current_version.complete and new_version.complete: - same_version = current_version - else: - same_version = current_version.core() - self.ui.confirm_update_same_version(same_version) - - @contextmanager - def _get_bootloader(self, device: TrussedBase) -> Iterator[NK3Bootloader]: - if isinstance(device, NK3): - self.ui.request_bootloader_confirmation() - try: - device.admin.reboot(BootMode.BOOTROM) - except TimeoutException: - raise self.ui.abort( - "The reboot was not confirmed with the touch button" - ) - - # needed for udev to properly handle new device - time.sleep(1) - - self.ui.pre_bootloader_hint() - - exc = None - for t in Retries(3): - logger.debug(f"Trying to connect to bootloader ({t})") - try: - with self.await_bootloader() as bootloader: - # noop to test communication - bootloader.uuid - yield bootloader - break - except McuBootConnectionError as e: - logger.debug("Received connection error", exc_info=True) - exc = e - else: - msgs = ["Failed to connect to Nitrokey 3 bootloader"] - if platform.system() == "Linux": - msgs += ["Are the Nitrokey udev rules installed and active?"] - raise self.ui.error(*msgs, exc) - elif isinstance(device, NK3Bootloader): - yield device - else: - raise self.ui.error(f"Unexpected Nitrokey 3 device: {device}") - - def _check_migrations( - self, - variant: Union[Variant, AdminAppVariant], - current_version: Optional[Version], - new_version: Version, - status: Optional[Status], - ) -> frozenset["_Migration"]: - try: - migrations = _Migration.get( - variant=variant, current=current_version, new=new_version - ) - except ValueError as e: - raise self.ui.error(str(e)) - - txt = _get_extra_information(migrations) - self.ui.confirm_extra_information(txt) - - if _Migration.IFS_MIGRATION_V2 in migrations: - if status and status.ifs_blocks is not None and status.ifs_blocks < 5: - self._trigger_warning(Warning.IFS_MIGRATION_V2) - - return migrations - - def _perform_update( - self, device: NK3Bootloader, container: FirmwareContainer - ) -> None: - logger.debug("Starting firmware update") - image = container.images[device.variant] - with self.ui.update_progress_bar() as callback: - try: - device.update(image, callback=callback) - except Exception as e: - raise self.ui.error("Failed to perform firmware update", e) - logger.debug("Firmware update finished successfully") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/nkpk.py new/nitrokey-0.4.0/src/nitrokey/nkpk.py --- old/nitrokey-0.3.2/src/nitrokey/nkpk.py 2025-07-08 12:41:08.000000000 +0200 +++ new/nitrokey-0.4.0/src/nitrokey/nkpk.py 1970-01-01 01:00:00.000000000 +0100 @@ -11,6 +11,7 @@ from nitrokey import _VID_NITROKEY from nitrokey.trussed import Fido2Certs, TrussedDevice, Version +from nitrokey.trussed._base import Model from nitrokey.trussed._bootloader import ModelData from nitrokey.trussed._bootloader.nrf52 import SignatureKey, TrussedBootloaderNrf52 @@ -49,6 +50,10 @@ super().__init__(device, _FIDO2_CERTS) @property + def model(self) -> Model: + return Model.NKPK + + @property def pid(self) -> int: return _PID_NKPK_DEVICE @@ -78,6 +83,10 @@ class NKPKBootloader(TrussedBootloaderNrf52): @property + def model(self) -> Model: + return Model.NKPK + + @property def name(self) -> str: return "Nitrokey Passkey Bootloader" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/trussed/__init__.py new/nitrokey-0.4.0/src/nitrokey/trussed/__init__.py --- old/nitrokey-0.3.2/src/nitrokey/trussed/__init__.py 2025-07-08 12:41:08.000000000 +0200 +++ new/nitrokey-0.4.0/src/nitrokey/trussed/__init__.py 1970-01-01 01:00:00.000000000 +0100 @@ -5,10 +5,10 @@ # http://opensource.org/licenses/MIT>, at your option. This file may not be # copied, modified, or distributed except according to those terms. +from ._base import Model as Model # noqa: F401 from ._base import TrussedBase as TrussedBase # noqa: F401 from ._bootloader import FirmwareContainer as FirmwareContainer # noqa: F401 from ._bootloader import FirmwareMetadata as FirmwareMetadata # noqa: F401 -from ._bootloader import Model as Model # noqa: F401 from ._bootloader import TrussedBootloader as TrussedBootloader # noqa: F401 from ._bootloader import Variant as Variant # noqa: F401 from ._bootloader import parse_firmware_image as parse_firmware_image # noqa: F401 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/trussed/_base.py new/nitrokey-0.4.0/src/nitrokey/trussed/_base.py --- old/nitrokey-0.3.2/src/nitrokey/trussed/_base.py 2025-07-08 12:41:08.000000000 +0200 +++ new/nitrokey-0.4.0/src/nitrokey/trussed/_base.py 1970-01-01 01:00:00.000000000 +0100 @@ -6,6 +6,7 @@ # copied, modified, or distributed except according to those terms. from abc import ABC, abstractmethod +from enum import Enum from typing import Optional, TypeVar from nitrokey import _VID_NITROKEY @@ -15,6 +16,21 @@ T = TypeVar("T", bound="TrussedBase") +class Model(Enum): + NK3 = "Nitrokey 3" + NKPK = "Nitrokey Passkey" + + def __str__(self) -> str: + return self.value + + @classmethod + def from_str(cls, s: str) -> "Model": + for model in cls: + if model.value == s: + return model + raise ValueError(f"Unknown model {s}") + + class TrussedBase(ABC): """ Base class for Nitrokey devices using the Trussed framework and running @@ -35,6 +51,10 @@ ) @property + @abstractmethod + def model(self) -> Model: ... + + @property def vid(self) -> int: return _VID_NITROKEY diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/trussed/_bootloader/__init__.py new/nitrokey-0.4.0/src/nitrokey/trussed/_bootloader/__init__.py --- old/nitrokey-0.3.2/src/nitrokey/trussed/_bootloader/__init__.py 2025-07-08 12:41:08.000000000 +0200 +++ new/nitrokey-0.4.0/src/nitrokey/trussed/_bootloader/__init__.py 1970-01-01 01:00:00.000000000 +0100 @@ -9,7 +9,6 @@ import hashlib import json import logging -import re import sys from abc import abstractmethod from dataclasses import dataclass @@ -18,9 +17,7 @@ from typing import TYPE_CHECKING, Callable, Dict, Optional, Tuple, Union from zipfile import ZipFile -from nitrokey.updates import Repository - -from .._base import TrussedBase +from .._base import Model, TrussedBase from .._utils import Version if TYPE_CHECKING: @@ -39,39 +36,16 @@ nrf52_signature_keys: list["SignatureKey"] -class Model(enum.Enum): - NK3 = "Nitrokey 3" - NKPK = "Nitrokey Passkey" - - def __str__(self) -> str: - return self.value - - @property - def firmware_repository(self) -> Repository: - return Repository(owner="Nitrokey", name=self._data.firmware_repository_name) +def get_model_data(model: Model) -> ModelData: + if model == Model.NK3: + from nitrokey.nk3 import _NK3_DATA + + return _NK3_DATA + if model == Model.NKPK: + from nitrokey.nkpk import _NKPK_DATA - @property - def firmware_pattern(self) -> Pattern[str]: - return re.compile(self._data.firmware_pattern_string) - - @property - def _data(self) -> "ModelData": - if self == Model.NK3: - from nitrokey.nk3 import _NK3_DATA - - return _NK3_DATA - if self == Model.NKPK: - from nitrokey.nkpk import _NKPK_DATA - - return _NKPK_DATA - raise ValueError(f"Unknown model {self}") - - @classmethod - def from_str(cls, s: str) -> "Model": - for model in cls: - if model.value == s: - return model - raise ValueError(f"Unknown model {s}") + return _NKPK_DATA + raise ValueError(f"Unknown model {model}") class Variant(enum.Enum): @@ -221,6 +195,8 @@ if variant == Variant.LPC55: return parse_firmware_image_lpc55(data) elif variant == Variant.NRF52: - return parse_firmware_image_nrf52(data, model._data.nrf52_signature_keys) + return parse_firmware_image_nrf52( + data, get_model_data(model).nrf52_signature_keys + ) else: raise ValueError(f"Unexpected variant {variant}") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/trussed/_bootloader/nrf52_upload/dfu/dfu_transport_serial.py new/nitrokey-0.4.0/src/nitrokey/trussed/_bootloader/nrf52_upload/dfu/dfu_transport_serial.py --- old/nitrokey-0.3.2/src/nitrokey/trussed/_bootloader/nrf52_upload/dfu/dfu_transport_serial.py 2025-07-08 12:41:08.000000000 +0200 +++ new/nitrokey-0.4.0/src/nitrokey/trussed/_bootloader/nrf52_upload/dfu/dfu_transport_serial.py 1970-01-01 01:00:00.000000000 +0100 @@ -146,9 +146,9 @@ while finished is False: byte = self.serial_port.read(1) if byte: - (byte) = struct.unpack("B", byte)[0] + (unpacked_byte) = struct.unpack("B", byte)[0] (finished, current_state, decoded_data) = Slip.decode_add_byte( - byte, decoded_data, current_state + unpacked_byte, decoded_data, current_state ) else: current_state = Slip.SLIP_STATE_CLEARING_INVALID_PACKET diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/trussed/admin_app.py new/nitrokey-0.4.0/src/nitrokey/trussed/admin_app.py --- old/nitrokey-0.3.2/src/nitrokey/trussed/admin_app.py 2025-07-08 12:41:08.000000000 +0200 +++ new/nitrokey-0.4.0/src/nitrokey/trussed/admin_app.py 1970-01-01 01:00:00.000000000 +0100 @@ -63,6 +63,7 @@ SE050_ERROR = 0b00010000 CONFIG_ERROR = 0b00100000 RNG_ERROR = 0b01000000 + EXT_FLASH_NEED_REFORMAT = 0b10000000 def is_error(self) -> bool: return self.value != 0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/trussed/updates.py new/nitrokey-0.4.0/src/nitrokey/trussed/updates.py --- old/nitrokey-0.3.2/src/nitrokey/trussed/updates.py 1970-01-01 01:00:00.000000000 +0100 +++ new/nitrokey-0.4.0/src/nitrokey/trussed/updates.py 1970-01-01 01:00:00.000000000 +0100 @@ -0,0 +1,568 @@ +# Copyright 2022 Nitrokey Developers +# +# Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or +# http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or +# 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 importlib.metadata +import logging +import platform +import re +import time +from abc import ABC, abstractmethod +from collections.abc import Set +from contextlib import contextmanager +from importlib.metadata import PackageNotFoundError +from io import BytesIO +from typing import TYPE_CHECKING, Any, Callable, Iterator, List, Optional, Tuple, Union + +from nitrokey._helpers import Retries +from nitrokey.trussed import TimeoutException, TrussedBase, Version +from nitrokey.trussed._base import Model +from nitrokey.trussed._bootloader import ( + FirmwareContainer, + TrussedBootloader, + Variant, + get_model_data, + validate_firmware_image, +) +from nitrokey.trussed._bootloader.lpc55_upload.mboot.exceptions import ( + McuBootConnectionError, +) +from nitrokey.trussed._device import TrussedDevice +from nitrokey.trussed.admin_app import BootMode, Status +from nitrokey.trussed.admin_app import Variant as AdminAppVariant +from nitrokey.updates import Asset, Release, Repository + +if TYPE_CHECKING: + import typing_extensions + +logger = logging.getLogger(__name__) + + +@enum.unique +class Warning(enum.Enum): + """ + A warning that can occur during a firmware update. + + By default, these warnings abort the firmware update. This enum can be used to select types + of warnings that should be ignored and not cause the firmware update to fail. + """ + + IFS_MIGRATION_V2 = "ifs-migration-v2" + MISSING_STATUS = "missing-status" + SDK_VERSION = "sdk-version" + UPDATE_FROM_BOOTLOADER = "update-from-bootloader" + + @property + def message(self) -> str: + if self == Warning.IFS_MIGRATION_V2: + return ( + "Not enough space on the internal filesystem to perform the firmware" + " update. See the release notes for more information:" + " https://github.com/Nitrokey/nitrokey-3-firmware/releases/tag/v1.8.2-test.20250312" + ) + if self == Warning.MISSING_STATUS: + return ( + "Could not determine the device state as the current firmware is too old." + " Please update to firmware version v1.3.1 first." + ) + if self == Warning.SDK_VERSION: + return ( + "Your Nitrokey SDK version is outdated. Please update this program to the latest" + " version and try again." + ) + if self == Warning.UPDATE_FROM_BOOTLOADER: + return ( + "The current state of the device cannot be checked as it is already in bootloader" + " mode. Please review the release notes at:" + " https://github.com/Nitrokey/nitrokey-3-firmware/releases" + ) + + if TYPE_CHECKING: + typing_extensions.assert_never(self) + + return self.value + + @classmethod + def from_str(cls, s: str) -> "Warning": + for w in cls: + if w.value == s: + return w + raise ValueError(f"Unexpected update warning id: {s}") + + +@enum.unique +class _Migration(enum.Enum): + # IFS migration to use journaling on the NRF52 introduced in v1.3.0 (NK3) + NRF_IFS_MIGRATION = enum.auto() + # IFS migration to filesystem layout 2 (FIDO2 RK migration) in v1.8.2 (NK3) + IFS_MIGRATION_V2 = enum.auto() + + @classmethod + def get( + cls, + model: Model, + variant: Union[Variant, AdminAppVariant], + current: Optional[Version], + new: Version, + ) -> frozenset["_Migration"]: + if model != Model.NK3: + return frozenset() + + if isinstance(variant, AdminAppVariant): + if variant == AdminAppVariant.USBIP: + raise ValueError("Cannot perform firmware update for USBIP runner") + elif variant == AdminAppVariant.LPC55: + variant = Variant.LPC55 + elif variant == AdminAppVariant.NRF52: + variant = Variant.NRF52 + else: + if TYPE_CHECKING: + typing_extensions.assert_never(variant) + + raise ValueError(f"Unsupported device variant: {variant}") + + migrations = set() + + if variant == Variant.NRF52: + if ( + current is None + or current <= Version(1, 2, 2) + and new >= Version(1, 3, 0) + ): + migrations.add(cls.NRF_IFS_MIGRATION) + + ifs_migration_v2 = Version(1, 8, 2) + if ( + current is not None + and current < ifs_migration_v2 + and new >= ifs_migration_v2 + ): + migrations.add(cls.IFS_MIGRATION_V2) + + return frozenset(migrations) + + +def get_firmware_repository(model: Model) -> Repository: + data = get_model_data(model) + return Repository(owner="Nitrokey", name=data.firmware_repository_name) + + +def get_firmware_update(model: Model, release: Release) -> Asset: + data = get_model_data(model) + return release.require_asset(re.compile(data.firmware_pattern_string)) + + +def _get_extra_information(migrations: Set[_Migration]) -> List[str]: + """Return additional information for the device after update based on update-path""" + + out = [] + if _Migration.NRF_IFS_MIGRATION in migrations: + out += [ + "", + "During this update process the internal filesystem will be migrated!", + "- Migration will only work, if your internal filesystem does not contain more than 45 Resident Keys. If you have more please remove some.", + "- After the update it might take up to 3 minutes for the first boot.", + "Never unplug the device while the LED is active!", + ] + return out + + +def _get_finalization_wait_retries(migrations: Set[_Migration]) -> int: + """Return number of retries to wait for the device after update based on update-path""" + + out = 60 + if _Migration.NRF_IFS_MIGRATION in migrations: + # max time 150secs == 300 retries + out = 500 + return out + + +class UpdateUi(ABC): + @abstractmethod + def error(self, *msgs: Any) -> Exception: + pass + + @abstractmethod + def show_warning(self, warning: Warning) -> None: + pass + + @abstractmethod + def raise_warning(self, warning: Warning) -> Exception: + pass + + @abstractmethod + def abort(self, *msgs: Any) -> Exception: + pass + + @abstractmethod + def abort_downgrade(self, current: Version, image: Version) -> Exception: + pass + + @abstractmethod + def abort_pynitrokey_version( + self, current: Version, required: Version + ) -> Exception: + pass + + @abstractmethod + def confirm_download(self, current: Optional[Version], new: Version) -> None: + pass + + @abstractmethod + def confirm_update(self, current: Optional[Version], new: Version) -> None: + pass + + @abstractmethod + def confirm_pynitrokey_version(self, current: Version, required: Version) -> None: + pass + + @abstractmethod + def confirm_extra_information(self, extra_info: List[str]) -> None: + pass + + @abstractmethod + def confirm_update_same_version(self, version: Version) -> None: + pass + + @abstractmethod + def pre_bootloader_hint(self) -> None: + pass + + @abstractmethod + def request_bootloader_confirmation(self) -> None: + pass + + @abstractmethod + @contextmanager + def download_progress_bar(self, desc: str) -> Iterator[Callable[[int, int], None]]: + pass + + @abstractmethod + @contextmanager + def update_progress_bar(self) -> Iterator[Callable[[int, int], None]]: + pass + + @abstractmethod + @contextmanager + def finalization_progress_bar(self) -> Iterator[Callable[[int, int], None]]: + pass + + +class DeviceHandler(ABC): + @abstractmethod + def await_bootloader(self, model: Model) -> TrussedBootloader: ... + + @abstractmethod + def await_device( + self, + model: Model, + wait_retries: Optional[int], + callback: Optional[Callable[[int, int], None]], + ) -> TrussedDevice: ... + + +class Updater: + def __init__( + self, + ui: UpdateUi, + device_handler: DeviceHandler, + ignore_warnings: Set[Warning] = frozenset(), + ) -> None: + self.ui = ui + self.device_handler = device_handler + self.ignore_warnings = ignore_warnings + + def _trigger_warning(self, warning: Warning) -> None: + if warning in self.ignore_warnings: + self.ui.show_warning(warning) + else: + raise self.ui.raise_warning(warning) + + def update( + self, + device: TrussedBase, + image: Optional[str], + update_version: Optional[str], + ignore_pynitrokey_version: bool = False, + ) -> Tuple[Version, Status]: + model = device.model + + update_from_bootloader = False + current_version = None + status = None + if isinstance(device, TrussedBootloader): + update_from_bootloader = True + self._trigger_warning(Warning.UPDATE_FROM_BOOTLOADER) + elif isinstance(device, TrussedDevice): + current_version = device.admin.version() + status = device.admin.status() + else: + raise self.ui.error(f"Unexpected Trussed device: {device}") + + logger.info(f"Firmware version before update: {current_version or ''}") + container = self._prepare_update(model, image, update_version, current_version) + + if not update_from_bootloader: + if status is None: + if model == Model.NK3: + if container.version > Version.from_str("1.3.1"): + self._trigger_warning(Warning.MISSING_STATUS) + else: + self.ui.error(f"Missing status for {model} device") + + self._check_minimum_version(container, ignore_pynitrokey_version) + + self.ui.confirm_update(current_version, container.version) + + migrations = None + if status is not None and status.variant is not None: + migrations = self._check_migrations( + model, status.variant, current_version, container.version, status + ) + elif isinstance(device, TrussedBootloader): + migrations = self._check_migrations( + model, device.variant, current_version, container.version, status + ) + + with self._get_bootloader(device) as bootloader: + if bootloader.variant not in container.images: + raise self.ui.error( + "The firmware release does not contain an image for the " + f"{bootloader.variant.value} hardware variant" + ) + try: + validate_firmware_image( + bootloader.variant, + container.images[bootloader.variant], + container.version, + model, + ) + except Exception as e: + raise self.ui.error("Failed to validate firmware image", e) + + if migrations is None: + migrations = self._check_migrations( + model, + bootloader.variant, + current_version, + container.version, + status, + ) + + self._perform_update(bootloader, container) + + wait_retries = _get_finalization_wait_retries(migrations) + with self.ui.finalization_progress_bar() as callback: + with self.device_handler.await_device( + model, wait_retries, callback + ) as device: + version = device.admin.version() + if version != container.version: + raise self.ui.error( + f"The firmware update to {container.version} was successful, but the " + f"firmware is still reporting version {version}." + ) + status = device.admin.status() + + return container.version, status + + def _prepare_update( + self, + model: Model, + image: Optional[str], + version: Optional[str], + current_version: Optional[Version], + ) -> FirmwareContainer: + if image: + try: + container = FirmwareContainer.parse(image, model) + except Exception as e: + raise self.ui.error("Failed to parse firmware container", e) + self._validate_version(current_version, container.version) + return container + else: + repository = get_firmware_repository(model) + if version: + try: + logger.info(f"Downloading firmare version {version}") + release = repository.get_release(version) + except Exception as e: + raise self.ui.error(f"Failed to get firmware release {version}", e) + else: + try: + release = repository.get_latest_release() + logger.info(f"Latest firmware version: {release}") + except Exception as e: + raise self.ui.error("Failed to find latest firmware release", e) + + try: + release_version = Version.from_v_str(release.tag) + except ValueError as e: + raise self.ui.error("Failed to parse version from release tag", e) + self._validate_version(current_version, release_version) + self.ui.confirm_download(current_version, release_version) + return self._download_update(model, release) + + def _download_update(self, model: Model, release: Release) -> FirmwareContainer: + try: + update = get_firmware_update(model, release) + except Exception as e: + raise self.ui.error( + f"Failed to find firmware image for release {release}", + e, + ) + + try: + logger.info(f"Trying to download firmware update from URL: {update.url}") + + with self.ui.download_progress_bar(update.tag) as callback: + data = update.read(callback=callback) + except Exception as e: + raise self.ui.error( + f"Failed to download latest firmware update {update.tag}", e + ) + + try: + container = FirmwareContainer.parse(BytesIO(data), model) + except Exception as e: + raise self.ui.error( + f"Failed to parse firmware container for {update.tag}", e + ) + + release_version = Version.from_v_str(release.tag) + if release_version != container.version: + raise self.ui.error( + f"The firmware container for {update.tag} has the version {container.version}" + ) + + return container + + def _check_minimum_version( + self, container: FirmwareContainer, ignore_pynitrokey_version: bool + ) -> None: + if container.sdk: + try: + sdk_version = Version.from_str(importlib.metadata.version("nitrokey")) + except PackageNotFoundError: + raise self.ui.error("Failed to determine the Nitrokey SDK version") + + if container.sdk > sdk_version: + logger.warning( + f"Minimum SDK version required for update is {container.sdk} (current version: {sdk_version})" + ) + self._trigger_warning(Warning.SDK_VERSION) + elif container.pynitrokey: + # The minimum pynitrokey version has been replaced by the minimum SDK version, so we + # only check it if there is no minimum SDK version set. + + # this is the version of pynitrokey when we moved to the SDK + pynitrokey_version = Version.from_str("0.4.49") + if container.pynitrokey > pynitrokey_version: + if ignore_pynitrokey_version: + self.ui.confirm_pynitrokey_version( + current=pynitrokey_version, required=container.pynitrokey + ) + else: + raise self.ui.abort_pynitrokey_version( + current=pynitrokey_version, required=container.pynitrokey + ) + + def _validate_version( + self, + current_version: Optional[Version], + new_version: Version, + ) -> None: + logger.info(f"Current firmware version: {current_version}") + logger.info(f"Updated firmware version: {new_version}") + + if current_version: + if current_version.core() > new_version.core(): + raise self.ui.abort_downgrade(current_version, new_version) + elif current_version == new_version: + if current_version.complete and new_version.complete: + same_version = current_version + else: + same_version = current_version.core() + self.ui.confirm_update_same_version(same_version) + + @contextmanager + def _get_bootloader(self, device: TrussedBase) -> Iterator[TrussedBootloader]: + model = device.model + if isinstance(device, TrussedDevice): + self.ui.request_bootloader_confirmation() + try: + device.admin.reboot(BootMode.BOOTROM) + except TimeoutException: + raise self.ui.abort( + "The reboot was not confirmed with the touch button" + ) + + # needed for udev to properly handle new device + time.sleep(1) + + self.ui.pre_bootloader_hint() + + exc = None + for t in Retries(3): + logger.debug(f"Trying to connect to bootloader ({t})") + try: + with self.device_handler.await_bootloader(model) as bootloader: + # noop to test communication + bootloader.uuid + yield bootloader + break + except McuBootConnectionError as e: + logger.debug("Received connection error", exc_info=True) + exc = e + else: + msgs = [f"Failed to connect to {model} bootloader"] + if platform.system() == "Linux": + msgs += ["Are the Nitrokey udev rules installed and active?"] + raise self.ui.error(*msgs, exc) + elif isinstance(device, TrussedBootloader): + yield device + else: + raise self.ui.error(f"Unexpected {model} device: {device}") + + def _check_migrations( + self, + model: Model, + variant: Union[Variant, AdminAppVariant], + current_version: Optional[Version], + new_version: Version, + status: Optional[Status], + ) -> frozenset["_Migration"]: + try: + migrations = _Migration.get( + model=model, + variant=variant, + current=current_version, + new=new_version, + ) + except ValueError as e: + raise self.ui.error(str(e)) + + txt = _get_extra_information(migrations) + self.ui.confirm_extra_information(txt) + + if _Migration.IFS_MIGRATION_V2 in migrations: + if status and status.ifs_blocks is not None and status.ifs_blocks < 5: + self._trigger_warning(Warning.IFS_MIGRATION_V2) + + return migrations + + def _perform_update( + self, device: TrussedBootloader, container: FirmwareContainer + ) -> None: + logger.debug("Starting firmware update") + image = container.images[device.variant] + with self.ui.update_progress_bar() as callback: + try: + device.update(image, callback=callback) + except Exception as e: + raise self.ui.error("Failed to perform firmware update", e) + logger.debug("Firmware update finished successfully")