What changed, and why it matters
This commit improves a Python helper used by developers to flash firmware onto BitBox hardware wallets. It makes the tool smarter about whether the connected device is a production or development unit, detects signed vs unsigned firmware files automatically, and adds clearer warnings and confirmation prompts. The changes are mostly usability and safety improvements for a developer-facing script, not a fix for a remote attack on user devices.
No urgent action required. This is a developer-tooling improvement. Reviewers should verify that the new flashing matrix and confirmation prompts match intended internal workflows, and that the BootloaderError mapping covers all bootloader status codes used by the device.
Security signals we found
Developer-only flashing tool, not user-facing firmware runtime
Adds explicit warnings for unsafe combinations (unsigned firmware on production device)
Adds confirmation prompt before flashing by default
Makes signature-data errors fatal on production devices, nonfatal on development devices
Adds structured BootloaderError and human-readable error mapping
Adds 30-second timeout for bootloader reboot detection
Validates firmware size and signed-container structure before flashing
No evidence of remote exploitability or bypass of production secure-boot
Evidence from the diff
The patch refactors py/load_firmware.py and py/bitbox02/bitbox02/bootloader.py. Key changes: (1) automatic classification of firmware inputs via signed-firmware magic instead of relying on filename or –debug; (2) detection of development bootloaders from version metadata; (3) a flashing matrix that warns about expected failures (unsigned on production, edition mismatch) but still allows deliberate tests; (4) signature-data errors are nonfatal on development devices but fatal on production devices; (5) structured BootloaderError exception with human-readable messages; (6) 30-second timeout and better error handling when waiting for bootloader mode; (7) –debug becomes a deprecated no-op, replaced by -y/–yes; (8) parse_signed_firmware now validates max firmware size and rejects malformed containers. The commit also adds 467 lines of tests covering the matrix.
Changed components
py/load_firmware.pypy/bitbox02/bitbox02/bootloader.pypy/bitbox02/bitbox02/__init__.pypy/bitbox02/bitbox02/__init__.py (package re-export)Makefile (flash-dev-firmware target)py/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 23/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.