Merge remote-tracking branch 'agent/benma-agent/improve-firmware-loader-ux'
What changed, and why it matters
This commit improves the Python firmware-flashing tool for BitBox hardware wallets. It adds clearer warnings, requires user confirmation before flashing, detects firmware type automatically instead of relying on filename or a debug flag, and exposes more detailed bootloader error messages. The changes are primarily usability and safety improvements for a developer/testing tool, not a fix for an exploitable device vulnerability.
No urgent action required. This is a defensive hardening and UX improvement to a developer tooling path. Reviewers may want to verify that the new --yes automation in CI/Makefile does not bypass important checks, and that the broad exception handling around flash_sigdata on dev devices does not mask unexpected failures in production workflows.
Security signals we found
Replaces filename-based and flag-based firmware-type decisions with header parsing, reducing the chance of flashing the wrong firmware type
Adds interactive confirmation before flashing and a --yes bypass for automation
Adds explicit warnings when unsigned firmware is flashed to production devices or when edition mismatches occur
Adds structured bootloader error codes and human-readable messages
Adds input-size validation and rejects empty/truncated firmware
Adds a 30-second bootloader-entry timeout
Adds comprehensive unit tests for the flashing matrix and error handling
Evidence from the diff
The patch refactors py/load_firmware.py and py/bitbox02/bitbox02/bootloader.py. Key changes: (1) automatic detection of signed vs unsigned firmware by parsing the signed-firmware magic header rather than trusting the filename or –debug flag; (2) a new –yes/-y flag to skip interactive confirmation, with –debug kept as a deprecated no-op; (3) structured BootloaderError exception carrying the bootloader status code and human-readable error mapping; (4) explicit handling of the four device/firmware combinations (production/dev × signed/unsigned) with warnings for expected failures; (5) extraction of flash_sigdata() so signature data can be attempted separately, with dev devices warning on sigdata rejection but still rebooting; (6) addition of is_devdevice() based on version prerelease/build metadata containing “dev”; (7) input validation for empty, truncated, and oversized firmware; (8) a 30-second timeout when waiting for the device to enter bootloader mode; (9) extensive unit tests covering the matrix and error paths. The Makefile’s flash-dev-firmware target switches from –debug to –yes.
Changed components
py/load_firmware.pypy/bitbox02/bitbox02/bootloader.pypy/bitbox02/bitbox02/__init__.pypy/bitbox02/bitbox02/__init__.py (compat re-export)Makefilepy/README.mdtest/scripts/test_load_firmware.pyInspect captured patch +775 / −57
### Makefile
@@ -171,7 +171,7 @@ run-valgrind-on-unit-tests:
$(MAKE) unit-test
bash -ec 'for exe in build-build/bin/test_*; do valgrind --leak-check=yes --track-origins=yes --error-exitcode=1 --exit-on-first-error=yes $$exe; done'
flash-dev-firmware:
- ./py/load_firmware.py build/bin/firmware.bin --debug
+ ./py/load_firmware.py build/bin/firmware.bin --yes
# Per-product development stage0/stage1 J-Link wrappers flash already-built images.
jlink-flash-bootloader-stage0-bitbox02-btconly-development: | build
### py/README.md
@@ -75,25 +75,36 @@ This is a one-time action.
## Flash the firmware.bin
Use the following script to flash the firmware.bin onto the BitBox.
-The script will prompt to enter the bootloader on the device before flashing.
-
-Production devices only accept `./firmware.signed.bin` signed by BitBox.
+The script prompts to enter the bootloader when necessary and confirms the detected firmware and
+device types before flashing.
```bash
python ./load_firmware.py ./firmware.signed.bin
```
-Please note:
-On production devices the bootloader only accepts newer signed
-firmware versions and
-[prevents downgrades](https://bitbox.swiss/bitbox02/security-features/#secure-bootloader).
+Signed firmware is detected by its header; all other input is treated as raw unsigned firmware.
+The file name is not used. A recognized but malformed signed-firmware container is rejected.
+
+The supported combinations are:
+
+| Firmware input | Production device | Development device |
+| --- | --- | --- |
+| Signed | Flash firmware and signature data; all errors are fatal | Flash firmware, attempt signature data, warn if the signature data is rejected, and reboot anyway |
+| Raw unsigned | Warn that firmware verification will fail, then flash without signature data | Flash firmware without signature data |
-On dev-devices use the `--debug` flag to flash unsigned `./firmware.bin`.
+On production devices the bootloader only accepts newer signed firmware versions and
+[prevents downgrades](https://bitbox.swiss/bitbox02/security-features/#secure-bootloader). On a
+production device, unsigned firmware cannot boot. A signed firmware for a different product or
+edition is allowed after a warning, but installing its signature data is expected to fail.
+
+Every flash requires confirmation. Use `-y` or `--yes` to skip the prompt for non-interactive use:
```bash
-python ./load_firmware.py --debug ./firmware.bin
+python ./load_firmware.py --yes ./firmware.bin
```
+The deprecated `--debug` option is accepted for backwards compatibility but has no effect.
+
Contributors that don't have a dev-devices please refer to the
[simulator](https://github.com/BitBoxSwiss/bitbox02-firmware?tab=readme-ov-file#simulator).
### py/bitbox02/bitbox02/__init__.py
@@ -31,4 +31,5 @@
eth,
system,
Bootloader,
+ BootloaderError,
)
### py/bitbox02/bitbox02/bitbox02/__init__.py
@@ -49,4 +49,4 @@
eth,
system,
)
-from .bootloader import Bootloader
+from .bootloader import Bootloader, BootloaderError
### py/bitbox02/bitbox02/bitbox02/bootloader.py
@@ -38,6 +38,12 @@
SIGDATA_MAGIC_BITBOX02_BTCONLY = struct.pack(">I", 0x11233B0B)
SIGDATA_MAGIC_BITBOX02PLUS_MULTI = struct.pack(">I", 0x5B648CEB)
SIGDATA_MAGIC_BITBOX02PLUS_BTCONLY = struct.pack(">I", 0x48714774)
+SIGDATA_MAGICS = (
+ SIGDATA_MAGIC_BITBOX02_MULTI,
+ SIGDATA_MAGIC_BITBOX02_BTCONLY,
+ SIGDATA_MAGIC_BITBOX02PLUS_MULTI,
+ SIGDATA_MAGIC_BITBOX02PLUS_BTCONLY,
+)
PRODUCT_ID_BITBOX02_MULTI = 1
PRODUCT_ID_BITBOX02_BTCONLY = 2
@@ -65,23 +71,32 @@ class Hardware(TypedDict):
secure_chip_model: SecureChipModel
+class BootloaderError(Exception):
+ """A nonzero status returned by the bootloader API."""
+
+ def __init__(self, code: int) -> None:
+ self.code = code
+ super().__init__(f"bootloader API error: code={code}")
+
+
+class InvalidFirmwareMagic(ValueError):
+ """The input does not start with a signed-firmware magic."""
+
+
def parse_signed_firmware(firmware: bytes) -> typing.Tuple[bytes, bytes, bytes]:
- """
- Split raw firmware bytes into magic, sigdata and firmware
- """
+ """Parse and validate signed firmware into magic, signature data, and payload."""
- if len(firmware) < MAGIC_LEN + SIGDATA_LEN:
- raise ValueError("firmware too small")
+ if len(firmware) < MAGIC_LEN:
+ raise InvalidFirmwareMagic("invalid magic")
magic, firmware = firmware[:MAGIC_LEN], firmware[MAGIC_LEN:]
- if magic not in (
- SIGDATA_MAGIC_BITBOX02_MULTI,
- SIGDATA_MAGIC_BITBOX02_BTCONLY,
- SIGDATA_MAGIC_BITBOX02PLUS_MULTI,
- SIGDATA_MAGIC_BITBOX02PLUS_BTCONLY,
- ):
- raise ValueError("invalid magic")
+ if magic not in SIGDATA_MAGICS:
+ raise InvalidFirmwareMagic("invalid magic")
+ if len(firmware) <= SIGDATA_LEN:
+ raise ValueError("firmware too small")
sigdata, firmware = firmware[:SIGDATA_LEN], firmware[SIGDATA_LEN:]
+ if len(firmware) > MAX_FIRMWARE_SIZE:
+ raise ValueError("firmware too big")
return magic, sigdata, firmware
@@ -104,20 +119,31 @@ def __init__(self, transport: TransportLayer, device_info: DeviceInfo):
BITBOX02PLUS_MULTI_BOOTLOADER: PRODUCT_ID_BITBOX02PLUS_MULTI,
BITBOX02PLUS_BTC_BOOTLOADER: PRODUCT_ID_BITBOX02PLUS_BTCONLY,
}.get(device_info["product_string"])
- self.version = parse_device_version(device_info["serial_number"])
+ version = parse_device_version(device_info["serial_number"])
+ version_identifiers = [
+ identifier
+ for metadata in (version.prerelease, version.build)
+ if metadata is not None
+ for identifier in metadata.split(".")
+ ]
+ self._is_devdevice = "dev" in version_identifiers
# Delete the prelease part, as it messes with the comparison (e.g. 3.0.0-pre < 3.0.0 is
# True, but the 3.0.0-pre has already the same API breaking changes like 3.0.0...).
- self.version = self.version.replace(prerelease=None)
+ self.version = version.replace(prerelease=None)
assert self.expected_magic
assert self.product_id
+ def is_devdevice(self) -> bool:
+ """Returns whether this is a development-device bootloader."""
+ return self._is_devdevice
+
def _query(self, msg: bytes) -> bytes:
cid = self._transport.generate_cid()
response = self._transport.query(msg, BOOTLOADER_CMD, cid)
if response[0] != msg[0]:
raise Exception("bootloader api error, expected {}, got {}".format(msg[0], response[0]))
if response[1] != 0:
- raise Exception("bootloader api error: code={}".format(response[1]))
+ raise BootloaderError(response[1])
return response[2:]
def versions(self) -> typing.Tuple[int, int]:
@@ -222,6 +248,12 @@ def flash_signed_firmware(
if magic != self.expected_magic:
raise ValueError("wrong firmware edition")
self.flash_unsigned_firmware(firmware, progress_callback=progress_callback)
+ self.flash_sigdata(sigdata)
+
+ def flash_sigdata(self, sigdata: bytes) -> None:
+ """Flashes firmware signature data."""
+ if len(sigdata) != SIGDATA_LEN:
+ raise ValueError(f"signature data must be {SIGDATA_LEN} bytes")
self._query(b"s" + sigdata)
def erase(self) -> None:
### py/load_firmware.py
@@ -1,24 +1,96 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
-"""TODO: document"""
+"""Flash signed or unsigned firmware onto a BitBox device.
-import argparse
-import sys
+The input type is detected from the signed-firmware header. The supported combinations are:
+
+* Production device, signed firmware: flash the firmware and signature data.
+* Production device, unsigned firmware: warn that it will not boot, then flash the firmware.
+* Development device, signed firmware: flash the firmware, attempt to flash the signature data,
+ warn if the signature data is rejected, and reboot into the firmware anyway.
+* Development device, unsigned firmware: flash the firmware without signature data.
+Every flash requires confirmation unless ``-y``/``--yes`` is used. Edition mismatches and
+combinations that are expected to fail are called out before the confirmation.
+"""
+
+import argparse
import pprint
-from typing import Callable, Any, Tuple
-from time import sleep
+import sys
+import time
+from pathlib import Path
+from typing import Any, Callable, Dict, NamedTuple, Optional, Sequence, Tuple
import hid
from bitbox02.communication import devices, TransportLayer, u2fhid, bitbox_api_protocol
from bitbox02.communication.devices import TooManyFoundException, NoneFoundException
-from bitbox02.bitbox02 import Bootloader, BitBox02
+from bitbox02.bitbox02 import (
+ BitBox02,
+ Bootloader,
+ BootloaderError,
+ bootloader as bootloader_api,
+)
from bitbox02 import util
+BOOTLOADER_REBOOT_TIMEOUT_SECONDS = 30.0
+
+FLASHING_HELP = """\
+Flashing behavior:
+ signed firmware + production device flash firmware and signatures
+ signed firmware + development device flash firmware, attempt signatures, and treat a
+ signature error as a warning
+ unsigned firmware + production device flash firmware, but it will not boot
+ unsigned firmware + development device flash firmware without signatures
+
+The input type is detected from the signed-firmware header. Recognized but malformed signed
+firmware is rejected. Every flash asks for confirmation; -y/--yes skips the prompt. Edition
+mismatches and combinations that are expected to fail are called out before confirmation.
+"""
+
+BOOTLOADER_ERROR_MESSAGES: Dict[int, str] = {
+ ord("Z"): (
+ "The bootloader rejected the signature data. The signatures may be invalid, corrupt, or "
+ "for a different device."
+ ),
+ ord("V"): (
+ "The bootloader rejected a firmware or signing-key downgrade. Use a signed firmware with "
+ "versions at least as new as those already stored on the device."
+ ),
+ ord("N"): "The bootloader rejected an invalid firmware length or chunk number.",
+ ord("M"): (
+ "The loader and bootloader disagree about the firmware chunk size. Update the Python "
+ "package and retry."
+ ),
+ ord("W"): "The device could not write the firmware to flash memory.",
+ ord("C"): "The device could not verify flash memory after writing or erasing it.",
+ ord("A"): "The device aborted the firmware operation.",
+ ord("E"): "The device could not erase flash memory.",
+ ord("L"): ("The bootloader was not ready for this operation. Reconnect the device and retry."),
+ ord("I"): (
+ "The bootloader does not support this command. Check that the Python package is compatible "
+ "with the bootloader."
+ ),
+ ord("U"): "The device could not unlock flash memory for writing.",
+ ord("K"): "The device could not lock flash memory after writing.",
+}
+
+
+class FirmwareInput(NamedTuple):
+ """A validated firmware input and its parsed signed-container fields."""
+
+ payload: bytes
+ magic: Optional[bytes]
+ sigdata: Optional[bytes]
+
+
+class FirmwareInputError(Exception):
+ """The selected firmware file is invalid."""
+
+
def eprint(*args: Any, **kwargs: Any) -> None:
"""
Like print, but defaults to stderr.
@@ -27,6 +99,35 @@ def eprint(*args: Any, **kwargs: Any) -> None:
print(*args, **kwargs)
+def _bootloader_error_message(error: BootloaderError) -> str:
+ """Return an actionable description of a bootloader status."""
+ return BOOTLOADER_ERROR_MESSAGES.get(
+ error.code, f"The bootloader returned an unknown error status (0x{error.code:02x})."
+ )
+
+
+def _read_firmware(filename: str) -> FirmwareInput:
+ """Read, classify, and validate a signed container or raw firmware."""
+ try:
+ firmware = Path(filename).read_bytes()
+ except OSError as error:
+ raise FirmwareInputError(f"Could not read firmware file '{filename}': {error}") from error
+
+ if not firmware:
+ raise FirmwareInputError("The firmware file is empty.")
+
+ try:
+ magic, sigdata, payload = bootloader_api.parse_signed_firmware(firmware)
+ except bootloader_api.InvalidFirmwareMagic:
+ payload = firmware
+ magic = None
+ sigdata = None
+ except ValueError as error:
+ raise FirmwareInputError(f"Invalid signed firmware: {error}") from error
+
+ return FirmwareInput(payload, magic, sigdata)
+
+
def _get_bitbox_and_reboot(use_cache: bool) -> devices.DeviceInfo:
"""Search for a bitbox and then reboot it into bootloader"""
device = devices.get_any_bitbox02()
@@ -62,14 +163,26 @@ def show_pairing(self, code: str, device_response: Callable[[], bool]) -> bool:
raise RuntimeError("User aborted")
# wait for it to reboot
+ deadline = time.monotonic() + BOOTLOADER_REBOOT_TIMEOUT_SECONDS
+ waiting = False
while True:
try:
bootloader_device = devices.get_any_bitbox02_bootloader()
except NoneFoundException:
+ if time.monotonic() >= deadline:
+ if waiting:
+ print()
+ raise TimeoutError(
+ "The device did not enter bootloader mode within 30 seconds. "
+ "Reconnect it and retry."
+ )
sys.stdout.write(".")
sys.stdout.flush()
- sleep(1)
+ waiting = True
+ time.sleep(1)
continue
+ if waiting:
+ print()
return bootloader_device
@@ -106,46 +219,140 @@ def _find_and_open_usb_bitbox02(use_cache: bool) -> Tuple[devices.DeviceInfo, Tr
return bootloader_device, u2fhid.U2FHid(hid_device)
-def main() -> int:
+def _confirm_flash(signed: bool, devdevice: bool, product_string: str) -> bool:
+ """Ask the user to confirm the detected firmware and device combination."""
+ firmware_kind = "signed" if signed else "unsigned"
+ device_kind = "development" if devdevice else "production"
+ try:
+ response = input(
+ f"Flash {firmware_kind} firmware to this {device_kind} device "
+ f"({product_string})? [Y/n] "
+ )
+ except EOFError:
+ return False
+ return response.strip().lower() in ("", "y", "yes")
+
+
+def _flash_firmware(
+ bootloader: Bootloader,
+ bootloader_device: devices.DeviceInfo,
+ firmware: FirmwareInput,
+ yes: bool,
+ progress: Callable[[float], None],
+) -> bool:
+ """Flash a validated firmware input. Returns false if the user declines."""
+ devdevice = bootloader.is_devdevice()
+ signed = firmware.sigdata is not None
+
+ if not signed and not devdevice:
+ eprint(
+ "WARNING: Unsigned firmware cannot boot on a production device; rebooting is "
+ "expected to show a firmware verification error."
+ )
+ if signed and firmware.magic != bootloader.expected_magic:
+ eprint(
+ "WARNING: The signed firmware edition does not match the connected device; "
+ "installing its signature data is expected to fail."
+ )
+ if signed and devdevice:
+ eprint(
+ "WARNING: Development device detected. Signature data will be attempted, but a "
+ "rejection will not prevent the firmware from booting."
+ )
+
+ if not yes and not _confirm_flash(signed, devdevice, bootloader_device["product_string"]):
+ return False
+
+ bootloader.flash_unsigned_firmware(firmware.payload, progress)
+ if not signed:
+ return True
+
+ assert firmware.sigdata is not None
+ try:
+ bootloader.flash_sigdata(firmware.sigdata)
+ except Exception as error: # pylint: disable=broad-exception-caught
+ if not devdevice:
+ raise
+ if isinstance(error, BootloaderError):
+ message = _bootloader_error_message(error)
+ else:
+ message = f"Could not install the signature data: {error}"
+ eprint(f"WARNING: {message}")
+ eprint("The firmware payload was flashed and the development device will be rebooted.")
+ return True
+
+
+def main(argv: Optional[Sequence[str]] = None) -> int:
"""Main function"""
parser = argparse.ArgumentParser(
- description="Tool for flashing a new firmware on BitBox devices."
+ description="Tool for flashing a new firmware on BitBox devices.",
+ epilog=FLASHING_HELP,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--no-cache", action="store_true", help="Don't use cached or store noise keys"
)
- parser.add_argument("--debug", action="store_true", help="Flash a debug (unsigned) firmware.")
- parser.add_argument("firmware", nargs=1, help="Firmware to flash.")
- args = parser.parse_args()
+ parser.add_argument(
+ "-y",
+ "--yes",
+ action="store_true",
+ help="Skip the confirmation prompt.",
+ )
+ parser.add_argument(
+ "--debug",
+ action="store_true",
+ help="Deprecated no-op; firmware type is detected automatically.",
+ )
+ parser.add_argument("firmware", help="Firmware to flash.")
+ args = parser.parse_args(argv)
+
+ if args.debug:
+ eprint(
+ "WARNING: --debug is deprecated and has no effect; firmware type is detected "
+ "automatically."
+ )
- if not args.debug and ".signed.bin" not in args.firmware[0]:
- eprint("Expecting firmware to end with '.signed.bin'")
+ try:
+ firmware = _read_firmware(args.firmware)
+ except FirmwareInputError as error:
+ eprint(f"Error: {error}")
return 1
- bootloader_device, transport = _find_and_open_usb_bitbox02(not args.no_cache)
+ try:
+ bootloader_device, transport = _find_and_open_usb_bitbox02(not args.no_cache)
+ except TimeoutError as error:
+ eprint(f"Error: {error}")
+ return 1
bootloader = Bootloader(transport, bootloader_device)
- with open(args.firmware[0], "rb") as file:
- firmware = file.read()
-
def progress(perc: float) -> None:
sys.stdout.write(f"{perc*100:.02f}%\r")
- if bootloader.erased():
- print("device contains NO firmware")
- else:
- print("firmware version: %d\nsigning pubkeys version: %d" % bootloader.versions())
- firmware_hash, signing_keydata_hash = bootloader.get_hashes()
- print("firmware hash:", firmware_hash.hex())
- print("signing keydata hash:", signing_keydata_hash.hex())
+ try:
+ if bootloader.erased():
+ print("device contains NO firmware")
+ else:
+ print("firmware version: %d\nsigning pubkeys version: %d" % bootloader.versions())
+ firmware_hash, signing_keydata_hash = bootloader.get_hashes()
+ print("firmware hash:", firmware_hash.hex())
+ print("signing keydata hash:", signing_keydata_hash.hex())
- if args.debug:
- bootloader.flash_unsigned_firmware(firmware, progress)
- else:
- bootloader.flash_signed_firmware(firmware, progress)
- print() # print a newline
+ try:
+ flashed = _flash_firmware(bootloader, bootloader_device, firmware, args.yes, progress)
+ finally:
+ print() # Finish the progress line, including when flashing fails.
+ except BootloaderError as error:
+ eprint(f"Error: {_bootloader_error_message(error)}")
+ return 1
+ except ValueError as error:
+ eprint(f"Error: {error}")
+ return 1
+
+ if not flashed:
+ eprint("Firmware flashing aborted.")
+ return 1
- sleep(1) # Pause to show the upgrade finished at 100%
+ time.sleep(1) # Pause to show the upgrade finished at 100%
bootloader.reboot()
return 0
### test/scripts/test_load_firmware.py
@@ -0,0 +1,467 @@
+# SPDX-License-Identifier: Apache-2.0
+
+"""Tests for py/load_firmware.py and its bootloader helpers."""
+
+import contextlib
+import importlib.util
+import io
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
+PYTHON_PACKAGE_ROOT = REPOSITORY_ROOT / "py" / "bitbox02"
+sys.path.insert(0, str(PYTHON_PACKAGE_ROOT))
+
+MODULE_PATH = REPOSITORY_ROOT / "py" / "load_firmware.py"
+SPEC = importlib.util.spec_from_file_location("load_firmware_under_test", MODULE_PATH)
+if SPEC is None or SPEC.loader is None:
+ raise RuntimeError(f"Could not load {MODULE_PATH}")
+LOAD_FIRMWARE = importlib.util.module_from_spec(SPEC)
+sys.modules[SPEC.name] = LOAD_FIRMWARE
+SPEC.loader.exec_module(LOAD_FIRMWARE)
+
+import bitbox02 as bitbox02_package # noqa: E402
+from bitbox02.bitbox02 import bootloader as bootloader_module # noqa: E402
+
+
+MAGIC = bootloader_module.SIGDATA_MAGIC_BITBOX02_MULTI
+OTHER_MAGIC = bootloader_module.SIGDATA_MAGIC_BITBOX02_BTCONLY
+SIGDATA = bytes(bootloader_module.SIGDATA_LEN)
+PAYLOAD = b"firmware payload"
+
+
+def device_info(serial_number: str = "v1.2.2") -> dict:
+ """Return minimal HID device information for a Multi bootloader."""
+ return {
+ "serial_number": serial_number,
+ "path": b"test-path",
+ "product_string": bootloader_module.BB02MULTI_BOOTLOADER,
+ }
+
+
+def signed_input(magic: bytes = MAGIC) -> LOAD_FIRMWARE.FirmwareInput:
+ """Build a parsed signed-firmware input."""
+ return LOAD_FIRMWARE.FirmwareInput(PAYLOAD, magic, SIGDATA)
+
+
+class DummyTransport:
+ """Minimal bootloader transport for package-level tests."""
+
+ def __init__(self, response_code: int = 0) -> None:
+ self.response_code = response_code
+ self.queries = []
+
+ def generate_cid(self) -> int:
+ return 1
+
+ def query(self, data: bytes, _endpoint: int, _cid: int) -> bytes:
+ self.queries.append(data)
+ return bytes((data[0], self.response_code))
+
+ def write(self, _data: bytes, _endpoint: int, _cid: int) -> None:
+ pass
+
+ def close(self) -> None:
+ pass
+
+
+class BootloaderPackageTests(unittest.TestCase):
+ """Test the public bootloader helpers used by the loader."""
+
+ def test_compat_package_exports_bootloader_module(self) -> None:
+ self.assertIs(bitbox02_package.bootloader, bootloader_module)
+
+ def test_parse_signed_firmware_wrong_magic(self) -> None:
+ with self.assertRaises(bootloader_module.InvalidFirmwareMagic):
+ bootloader_module.parse_signed_firmware(PAYLOAD)
+
+ def test_parse_signed_firmware_rejects_invalid_payload_size(self) -> None:
+ for payload in (b"", b"x" * (bootloader_module.MAX_FIRMWARE_SIZE + 1)):
+ with self.subTest(size=len(payload)), self.assertRaises(ValueError):
+ bootloader_module.parse_signed_firmware(MAGIC + SIGDATA + payload)
+
+ def test_is_devdevice(self) -> None:
+ for serial_number in (
+ "bb02.bl:v1.0.0-dev",
+ "v1.2.2+dev",
+ "v1.2.2+git.123.dev",
+ ):
+ with self.subTest(serial_number=serial_number):
+ bootloader = bootloader_module.Bootloader(
+ DummyTransport(), device_info(serial_number)
+ )
+ self.assertTrue(bootloader.is_devdevice())
+
+ def test_is_devdevice_rejects_other_version_identifiers(self) -> None:
+ for serial_number in (
+ "v1.2.2",
+ "v1.2.2+git.123",
+ "v1.2.2+device",
+ "v1.2.2-development",
+ ):
+ with self.subTest(serial_number=serial_number):
+ bootloader = bootloader_module.Bootloader(
+ DummyTransport(), device_info(serial_number)
+ )
+ self.assertFalse(bootloader.is_devdevice())
+
+ def test_is_devdevice_preserves_normalized_version(self) -> None:
+ bootloader = bootloader_module.Bootloader(
+ DummyTransport(), device_info("bb02.bl:v1.0.0-dev")
+ )
+ self.assertEqual(str(bootloader.version), "1.0.0")
+
+ def test_query_raises_structured_bootloader_error(self) -> None:
+ bootloader = bootloader_module.Bootloader(DummyTransport(ord("V")), device_info())
+ with self.assertRaises(bootloader_module.BootloaderError) as raised:
+ bootloader.versions()
+ self.assertEqual(raised.exception.code, ord("V"))
+
+ def test_flash_sigdata(self) -> None:
+ transport = DummyTransport()
+ bootloader = bootloader_module.Bootloader(transport, device_info())
+ bootloader.flash_sigdata(SIGDATA)
+ self.assertEqual(transport.queries, [b"s" + SIGDATA])
+
+ def test_flash_sigdata_rejects_wrong_length(self) -> None:
+ bootloader = bootloader_module.Bootloader(DummyTransport(), device_info())
+ with self.assertRaises(ValueError):
+ bootloader.flash_sigdata(SIGDATA[:-1])
+
+ def test_flash_unsigned_firmware_rejects_oversized_payload(self) -> None:
+ transport = DummyTransport()
+ bootloader = bootloader_module.Bootloader(transport, device_info())
+ with self.assertRaises(ValueError):
+ bootloader.flash_unsigned_firmware(b"x" * (bootloader_module.MAX_FIRMWARE_SIZE + 1))
+ self.assertEqual(transport.queries, [])
+
+
+class FirmwareInputTests(unittest.TestCase):
+ """Test input classification before a device is touched."""
+
+ def _write(self, data: bytes) -> str:
+ temporary = tempfile.NamedTemporaryFile(delete=False)
+ self.addCleanup(Path(temporary.name).unlink, missing_ok=True)
+ temporary.write(data)
+ temporary.close()
+ return temporary.name
+
+ def test_read_signed_firmware(self) -> None:
+ filename = self._write(MAGIC + SIGDATA + PAYLOAD)
+ firmware = LOAD_FIRMWARE._read_firmware(filename)
+ self.assertEqual(firmware, signed_input())
+
+ def test_read_unsigned_firmware(self) -> None:
+ for payload in (b"x", PAYLOAD):
+ with self.subTest(size=len(payload)):
+ filename = self._write(payload)
+ firmware = LOAD_FIRMWARE._read_firmware(filename)
+ self.assertEqual(firmware, LOAD_FIRMWARE.FirmwareInput(payload, None, None))
+
+ def test_read_truncated_signed_firmware_is_rejected(self) -> None:
+ filename = self._write(MAGIC + b"truncated")
+ with self.assertRaises(LOAD_FIRMWARE.FirmwareInputError):
+ LOAD_FIRMWARE._read_firmware(filename)
+
+ def test_read_firmware_rejects_empty_and_oversized_payloads(self) -> None:
+ for data in (
+ b"",
+ MAGIC + SIGDATA,
+ MAGIC + SIGDATA + b"x" * (bootloader_module.MAX_FIRMWARE_SIZE + 1),
+ ):
+ with self.subTest(size=len(data)):
+ filename = self._write(data)
+ with self.assertRaises(LOAD_FIRMWARE.FirmwareInputError):
+ LOAD_FIRMWARE._read_firmware(filename)
+
+ def test_main_reads_firmware_before_device_discovery(self) -> None:
+ with mock.patch.object(LOAD_FIRMWARE, "_find_and_open_usb_bitbox02") as find_device:
+ with contextlib.redirect_stderr(io.StringIO()):
+ result = LOAD_FIRMWARE.main(["does-not-exist.bin"])
+ self.assertEqual(result, 1)
+ find_device.assert_not_called()
+
+
+class FlashingMatrixTests(unittest.TestCase):
+ """Test all supported device and firmware combinations."""
+
+ def setUp(self) -> None:
+ self.bootloader = mock.Mock()
+ self.bootloader.expected_magic = MAGIC
+ self.device = device_info()
+ self.progress = mock.Mock()
+
+ def test_unsigned_development_firmware(self) -> None:
+ self.bootloader.is_devdevice.return_value = True
+ firmware = LOAD_FIRMWARE.FirmwareInput(PAYLOAD, None, None)
+ self.assertTrue(
+ LOAD_FIRMWARE._flash_firmware(
+ self.bootloader, self.device, firmware, True, self.progress
+ )
+ )
+ self.bootloader.flash_unsigned_firmware.assert_called_once_with(PAYLOAD, self.progress)
+ self.bootloader.flash_sigdata.assert_not_called()
+
+ def test_unsigned_production_firmware_is_allowed_with_warning(self) -> None:
+ self.bootloader.is_devdevice.return_value = False
+ firmware = LOAD_FIRMWARE.FirmwareInput(PAYLOAD, None, None)
+ stderr = io.StringIO()
+ with contextlib.redirect_stderr(stderr):
+ result = LOAD_FIRMWARE._flash_firmware(
+ self.bootloader, self.device, firmware, True, self.progress
+ )
+ self.assertTrue(result)
+ self.assertIn("cannot boot", stderr.getvalue())
+ self.bootloader.flash_unsigned_firmware.assert_called_once_with(PAYLOAD, self.progress)
+
+ def test_signed_production_firmware(self) -> None:
+ self.bootloader.is_devdevice.return_value = False
+ firmware = signed_input()
+ self.assertTrue(
+ LOAD_FIRMWARE._flash_firmware(
+ self.bootloader, self.device, firmware, True, self.progress
+ )
+ )
+ self.bootloader.flash_unsigned_firmware.assert_called_once_with(PAYLOAD, self.progress)
+ self.bootloader.flash_sigdata.assert_called_once_with(SIGDATA)
+
+ def test_mismatched_signed_production_firmware_is_attempted(self) -> None:
+ self.bootloader.is_devdevice.return_value = False
+ stderr = io.StringIO()
+ with contextlib.redirect_stderr(stderr):
+ result = LOAD_FIRMWARE._flash_firmware(
+ self.bootloader, self.device, signed_input(OTHER_MAGIC), True, self.progress
+ )
+ self.assertTrue(result)
+ self.assertIn("expected to fail", stderr.getvalue())
+ self.bootloader.flash_unsigned_firmware.assert_called_once_with(PAYLOAD, self.progress)
+ self.bootloader.flash_sigdata.assert_called_once_with(SIGDATA)
+
+ def test_signed_development_firmware_attempts_sigdata(self) -> None:
+ self.bootloader.is_devdevice.return_value = True
+ firmware = signed_input()
+ events = []
+ self.bootloader.flash_unsigned_firmware.side_effect = lambda *_args: events.append(
+ "firmware"
+ )
+ self.bootloader.flash_sigdata.side_effect = lambda *_args: events.append("sigdata")
+ with contextlib.redirect_stderr(io.StringIO()):
+ result = LOAD_FIRMWARE._flash_firmware(
+ self.bootloader, self.device, firmware, True, self.progress
+ )
+ self.assertTrue(result)
+ self.assertEqual(events, ["firmware", "sigdata"])
+
+ def test_signed_development_sigdata_error_is_warning(self) -> None:
+ self.bootloader.is_devdevice.return_value = True
+ self.bootloader.flash_sigdata.side_effect = bootloader_module.BootloaderError(ord("V"))
+ stderr = io.StringIO()
+ with contextlib.redirect_stderr(stderr):
+ result = LOAD_FIRMWARE._flash_firmware(
+ self.bootloader, self.device, signed_input(), True, self.progress
+ )
+ self.assertTrue(result)
+ self.assertIn("WARNING", stderr.getvalue())
+ self.assertIn("downgrade", stderr.getvalue())
+
+ def test_signed_development_payload_error_is_fatal(self) -> None:
+ self.bootloader.is_devdevice.return_value = True
+ self.bootloader.flash_unsigned_firmware.side_effect = bootloader_module.BootloaderError(
+ ord("W")
+ )
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(
+ bootloader_module.BootloaderError
+ ):
+ LOAD_FIRMWARE._flash_firmware(
+ self.bootloader, self.device, signed_input(), True, self.progress
+ )
+ self.bootloader.flash_sigdata.assert_not_called()
+
+ def test_signed_development_transport_error_is_warning(self) -> None:
+ self.bootloader.is_devdevice.return_value = True
+ self.bootloader.flash_sigdata.side_effect = Exception("transport failed")
+ stderr = io.StringIO()
+ with contextlib.redirect_stderr(stderr):
+ result = LOAD_FIRMWARE._flash_firmware(
+ self.bootloader, self.device, signed_input(), True, self.progress
+ )
+ self.assertTrue(result)
+ self.assertIn("transport failed", stderr.getvalue())
+
+ def test_flash_can_be_declined(self) -> None:
+ self.bootloader.is_devdevice.return_value = True
+ with mock.patch.object(LOAD_FIRMWARE, "_confirm_flash", return_value=False) as confirm:
+ with contextlib.redirect_stderr(io.StringIO()):
+ result = LOAD_FIRMWARE._flash_firmware(
+ self.bootloader,
+ self.device,
+ signed_input(),
+ False,
+ self.progress,
+ )
+ self.assertFalse(result)
+ confirm.assert_called_once_with(True, True, self.device["product_string"])
+ self.bootloader.flash_unsigned_firmware.assert_not_called()
+ self.bootloader.flash_sigdata.assert_not_called()
+
+ def test_flash_can_be_confirmed(self) -> None:
+ self.bootloader.is_devdevice.return_value = True
+ with mock.patch.object(LOAD_FIRMWARE, "_confirm_flash", return_value=True) as confirm:
+ with contextlib.redirect_stderr(io.StringIO()):
+ result = LOAD_FIRMWARE._flash_firmware(
+ self.bootloader,
+ self.device,
+ signed_input(),
+ False,
+ self.progress,
+ )
+ self.assertTrue(result)
+ confirm.assert_called_once_with(True, True, self.device["product_string"])
+ self.bootloader.flash_unsigned_firmware.assert_called_once()
+ self.bootloader.flash_sigdata.assert_called_once_with(SIGDATA)
+
+ def test_yes_skips_confirmation(self) -> None:
+ self.bootloader.is_devdevice.return_value = True
+ firmware = LOAD_FIRMWARE.FirmwareInput(PAYLOAD, None, None)
+ with mock.patch.object(LOAD_FIRMWARE, "_confirm_flash") as confirm:
+ LOAD_FIRMWARE._flash_firmware(
+ self.bootloader, self.device, firmware, True, self.progress
+ )
+ confirm.assert_not_called()
+
+
+class LoaderUxTests(unittest.TestCase):
+ """Test user-visible failures, help, rebooting, and timeouts."""
+
+ def test_known_bootloader_errors_are_human_readable(self) -> None:
+ self.assertEqual(
+ set(LOAD_FIRMWARE.BOOTLOADER_ERROR_MESSAGES),
+ {ord(code) for code in "ZVNMWCAELIUK"},
+ )
+ for code in LOAD_FIRMWARE.BOOTLOADER_ERROR_MESSAGES:
+ with self.subTest(code=code):
+ message = LOAD_FIRMWARE._bootloader_error_message(
+ bootloader_module.BootloaderError(code)
+ )
+ self.assertNotIn("code=", message)
+ self.assertTrue(message)
+
+ def test_unknown_bootloader_error_has_fallback(self) -> None:
+ message = LOAD_FIRMWARE._bootloader_error_message(bootloader_module.BootloaderError(0xFE))
+ self.assertIn("unknown", message)
+ self.assertIn("0xfe", message)
+
+ def test_help_documents_flashing_matrix(self) -> None:
+ stdout = io.StringIO()
+ with contextlib.redirect_stdout(stdout), self.assertRaises(SystemExit) as raised:
+ LOAD_FIRMWARE.main(["--help"])
+ self.assertEqual(raised.exception.code, 0)
+ help_text = stdout.getvalue()
+ self.assertIn("signed firmware + production device", help_text)
+ self.assertIn("signed firmware + development device", help_text)
+ self.assertIn("unsigned firmware + development device", help_text)
+ self.assertIn("unsigned firmware + production device", help_text)
+ self.assertIn("--yes", help_text)
+ self.assertNotIn("--unsigned", help_text)
+ self.assertIn("-y, --yes", help_text)
+ self.assertIn("--debug", help_text)
+
+ def test_debug_option_is_noop_with_warning(self) -> None:
+ stderr = io.StringIO()
+ with mock.patch.object(LOAD_FIRMWARE, "_find_and_open_usb_bitbox02") as find_device:
+ with contextlib.redirect_stderr(stderr):
+ result = LOAD_FIRMWARE.main(["--debug", "does-not-exist.bin"])
+ self.assertEqual(result, 1)
+ self.assertIn("--debug is deprecated and has no effect", stderr.getvalue())
+ find_device.assert_not_called()
+
+ def test_confirmation_defaults_to_yes(self) -> None:
+ with mock.patch("builtins.input", return_value="") as prompt:
+ self.assertTrue(LOAD_FIRMWARE._confirm_flash(True, False, "BitBox02 Multi bootloader"))
+ self.assertIn("signed firmware", prompt.call_args.args[0])
+ self.assertIn("production device", prompt.call_args.args[0])
+
+ def test_confirmation_rejects_eof(self) -> None:
+ with mock.patch("builtins.input", side_effect=EOFError):
+ self.assertFalse(LOAD_FIRMWARE._confirm_flash(False, True, "BitBox02 Multi bootloader"))
+
+ def test_sigdata_warning_still_reboots(self) -> None:
+ bootloader = mock.Mock()
+ bootloader.erased.return_value = True
+ bootloader.is_devdevice.return_value = True
+ bootloader.expected_magic = MAGIC
+ bootloader.flash_sigdata.side_effect = bootloader_module.BootloaderError(ord("V"))
+ with mock.patch.object(
+ LOAD_FIRMWARE, "_read_firmware", return_value=signed_input()
+ ), mock.patch.object(
+ LOAD_FIRMWARE,
+ "_find_and_open_usb_bitbox02",
+ return_value=(device_info(), mock.Mock()),
+ ), mock.patch.object(
+ LOAD_FIRMWARE, "Bootloader", return_value=bootloader
+ ), mock.patch.object(
+ LOAD_FIRMWARE.time, "sleep"
+ ), contextlib.redirect_stdout(
+ io.StringIO()
+ ), contextlib.redirect_stderr(
+ io.StringIO()
+ ):
+ result = LOAD_FIRMWARE.main(["-y", "firmware.signed.bin"])
+ self.assertEqual(result, 0)
+ bootloader.reboot.assert_called_once_with()
+
+ def test_production_bootloader_error_is_fatal(self) -> None:
+ bootloader = mock.Mock()
+ bootloader.erased.return_value = True
+ bootloader.is_devdevice.return_value = False
+ bootloader.expected_magic = MAGIC
+ bootloader.flash_sigdata.side_effect = bootloader_module.BootloaderError(ord("V"))
+ stderr = io.StringIO()
+ with mock.patch.object(
+ LOAD_FIRMWARE, "_read_firmware", return_value=signed_input()
+ ), mock.patch.object(
+ LOAD_FIRMWARE,
+ "_find_and_open_usb_bitbox02",
+ return_value=(device_info(), mock.Mock()),
+ ), mock.patch.object(
+ LOAD_FIRMWARE, "Bootloader", return_value=bootloader
+ ), contextlib.redirect_stdout(
+ io.StringIO()
+ ), contextlib.redirect_stderr(
+ stderr
+ ):
+ result = LOAD_FIRMWARE.main(["--yes", "firmware.signed.bin"])
+ self.assertEqual(result, 1)
+ self.assertIn("downgrade", stderr.getvalue())
+ bootloader.reboot.assert_not_called()
+
+ def test_wait_for_bootloader_times_out(self) -> None:
+ bitbox = mock.Mock()
+ bitbox.reboot.return_value = True
+ with mock.patch.object(
+ LOAD_FIRMWARE.devices, "get_any_bitbox02", return_value=device_info()
+ ), mock.patch.object(
+ LOAD_FIRMWARE.devices,
+ "get_any_bitbox02_bootloader",
+ side_effect=LOAD_FIRMWARE.NoneFoundException(),
+ ), mock.patch.object(
+ LOAD_FIRMWARE.hid, "device", return_value=mock.Mock()
+ ), mock.patch.object(
+ LOAD_FIRMWARE, "BitBox02", return_value=bitbox
+ ), mock.patch.object(
+ LOAD_FIRMWARE.time, "monotonic", side_effect=(0.0, 30.0)
+ ), mock.patch.object(
+ LOAD_FIRMWARE.time, "sleep"
+ ), contextlib.redirect_stdout(
+ io.StringIO()
+ ):
+ with self.assertRaises(TimeoutError):
+ LOAD_FIRMWARE._get_bitbox_and_reboot(False)
+
+
+if __name__ == "__main__":
+ unittest.main()Why this scored 34/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.