From 6de031ce29f482473a1ffca08e05e1be9756704d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20H=C3=B6lzl?= Date: Thu, 20 Aug 2026 11:41:17 +0200 Subject: [PATCH 1/3] [feature] CLI mit Subkommando "pack-bf3" zum Erzeugen von BF3-Files. Das Makesystem der Firmware erzeugt BF3-Files kuenftig selbst, statt ein .bf2 zu bauen und es per Bf3File.bf2_import() zu konvertieren. Weil make2 unter Python 2.7 laeuft, ruft es "bec2format pack-bf3" in einem eigenen Prozess auf. Das Manifest wird komplett ueber stdin gelesen, weil es den Firmware-Key enthaelt: so landet der Key weder in einer Datei noch in der Prozessliste. Entsprechend gibt das Kommando weder das Manifest noch einen Traceback aus, Fehler gehen als "ERROR: ..." nach stderr. cli.py wird bewusst nicht von __init__.py importiert - das Paketverzeichnis wird von der CI in die micropython-lib kopiert, wo weder argparse noch json verfuegbar sind. FW-898 Co-Authored-By: Claude --- .github/workflows/verify-bec2format.yml | 19 ++ .gitignore | 1 + README.md | 74 ++++++ bec2format/cli.py | 247 ++++++++++++++++++++ pyproject.toml | 7 + tasks.py | 8 +- tests/test_cli.py | 299 ++++++++++++++++++++++++ 7 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 bec2format/cli.py create mode 100644 tests/test_cli.py diff --git a/.github/workflows/verify-bec2format.yml b/.github/workflows/verify-bec2format.yml index 4e7e7f7..6c7d5e2 100644 --- a/.github/workflows/verify-bec2format.yml +++ b/.github/workflows/verify-bec2format.yml @@ -2,6 +2,25 @@ name: Verify BEC2 format on: push: jobs: + test: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + id: setup-python + with: + python-version: '3.10' + - name: Tox Cache + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/.tox + key: ${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-tox-${{ hashFiles('tox.ini') }}-poetry-${{ hashFiles('poetry.lock') }} + - name: Tox Setup + run: pip install tox==4.6.0 + - name: Run unit tests + run: tox -- test verify: runs-on: ubuntu-24.04 env: diff --git a/.gitignore b/.gitignore index 55ba84e..5971489 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /.tox/ .mypy_cache/ __pycache__/ +/.pytest_cache/ diff --git a/README.md b/README.md index 1a1276d..1bc8cd5 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,80 @@ Bf3File( ) ``` +## Command line interface + +The `bec2format` console script is available with the CPython installation +(it is not part of the micropython package). + +### `bec2format pack-bf3` + +Creates a BF3 file from a JSON manifest that is read from **stdin**: + +```bash +bec2format pack-bf3 < manifest.json +``` + +> **The manifest contains key material.** Pass it through stdin - never write +> it to a file and never put it on the command line, where it would show up in +> the process list. `pack-bf3` therefore takes no arguments at all, never logs +> the manifest and never prints a traceback. Errors are reported as +> `ERROR: ` on stderr with a non-zero exit code. + +Components that need AES (`"encryption": "FWKEY"` or `"SESSIONKEY"`) require an +AES implementation, so install the package with the `aes` extra: + +```bash +uvx --from "bec2format[aes] @ git+https://github.com/baltech-ag/bec2format.git@v1.02.00" \ + bec2format pack-bf3 < manifest.json +``` + +#### Manifest format + +```json +{ + "dest": "1100_id_engine_z_firmware.bf3", + "fw_key": "401D6C7E98A9B469A6F598DB8E69862B", + "comments": {"CustomerId": "4711"}, + "components": [ + {"tagtype": 132, + "format": "MEMIMAGE", + "encryption": "FWKEY", + "payload": "intermediate/bf3_cmp00.bin", + "instrs": {"Firmware": "1100 IDE Z 2.05.01", + "Creator": "make2", + "Bf3Update": "Supported", + "CRC": "0x1234ABCD", + "SELECT": {"FILTER": "010100B6"}, + "SELECT_IF": {"PROTOCOL": "*"}, + "CHECK_FWVER": {"VERSIONDESC": "*"}, + "REBOOT": {}}} + ] +} +``` + +| Key | | Description | +|---|---|---| +| `dest` | required | Path of the BF3 file to create. | +| `fw_key` | optional | The 16 byte firmware key as a hex string. Required if at least one component uses `"encryption": "FWKEY"`. | +| `comments` | optional | Comments that are written to the header of the BF3 file. `FirmwareId`, `FirmwareVersion`, `Creator`, `Bf3Update` and the `Component` annotations are derived from the components and do not have to be listed here. | +| `components` | required | The components of the BF3 file, in the order in which they would appear in a BF2 stream. | + +Every entry of `components` describes one component: + +| Key | | Description | +|---|---|---| +| `tagtype` | required | The BF2 tag type of the component (`0x84` main firmware, `0x70`/`0x83` loader, `0x35`/`0x39`/`0x3D`/`0x40` peripheral firmware). The tag types `0x34` and `0x48` are control tags and are skipped. | +| `format` | required | `MEMIMAGE`, `BF2COMPATIBLE`, `BLOB` or `TLVCFG`. Peripheral firmware (`0x35`/`0x39`/`0x3D`/`0x40`) has to use `BLOB`. | +| `encryption` | optional | `PLAIN` (default), `FWKEY` (AES-128-CBC with `fw_key`, an all zero IV and zero padding) or `SESSIONKEY`. | +| `payload` | required | Path of the file that contains the payload of the component. | +| `instrs` | optional | The BF2 instructions of the component, see below. | + +`instrs` accumulates over the components in exactly the same way as while +parsing a BF2 stream: every entry stays in effect until a later component +overrides it. `REBOOT`, `CRC` and `CHECK_FWVER` apply to a single component +only, `SELECT`, `SELECT_IF`, `Firmware`, `Creator` and `Bf3Update` apply to all +following ones as well. + ## Run appnotes #### CPython diff --git a/bec2format/cli.py b/bec2format/cli.py new file mode 100644 index 0000000..137abcf --- /dev/null +++ b/bec2format/cli.py @@ -0,0 +1,247 @@ +"""Command line interface of bec2format. + +This module must NOT be imported by ``bec2format/__init__.py``: the whole +package directory is copied into the micropython library, where neither +``argparse`` nor ``json`` is available. It is loaded exclusively through the +``bec2format`` console script. +""" + +import argparse +import json +import sys +from typing import Any, NoReturn, Optional, TextIO + +from .bf3file import BF2_TAGTYPE_MAP, BF3ENC, BF3FMT, BF3TAG, Bf3Component, Bf3File +from .crypto import AES128, create_AES128 +from .error import FormatError + +CMD_PACK_BF3 = "pack-bf3" + +FORMATS = { + "BLOB": BF3FMT.BLOB, + "MEMIMAGE": BF3FMT.MEMORYIMAGE, + "BF2COMPATIBLE": BF3FMT.BF2COMPATIBLE, + "TLVCFG": BF3FMT.TLVCFG, +} +ENCRYPTIONS = { + "PLAIN": BF3ENC.PLAIN, + "FWKEY": BF3ENC.FWKEY, + "SESSIONKEY": BF3ENC.SESSIONKEY, +} + +# these tagtypes address peripheral controllers that are always uploaded as a +# single opaque image +BLOB_ONLY_TAGTYPES = frozenset([0x35, 0x39, 0x3D, 0x40]) + + +class Bf3PackError(Exception): + """A manifest is invalid or no BF3 file can be created from it. + + The message of this exception is written to stderr and must therefore + never contain key material or raw manifest content. + """ + + +def _fail(msg: str) -> NoReturn: + raise Bf3PackError(msg) + + +def _require_aes_backend() -> None: + try: + from .extras import aes # noqa: F401 + except ImportError: + _fail( + "no AES implementation is registered; install bec2format with the " + "'aes' extra or register an own implementation" + ) + + +def _parse_fw_key(fw_key: Any) -> Optional[bytes]: + if fw_key is None: + return None + if not isinstance(fw_key, str): + _fail("'fw_key' must be a hex string") + try: + key = bytes.fromhex(fw_key) + except ValueError: + # deliberately without the offending value + _fail("'fw_key' is not a valid hex string") + if len(key) != AES128.KEY_SIZE: + _fail( + "'fw_key' must be {} bytes long, but is {}".format( + AES128.KEY_SIZE, len(key) + ) + ) + return key + + +def _read_payload(path: Any, cmp_name: str) -> bytes: + if not isinstance(path, str): + _fail("{}: 'payload' must be a file path".format(cmp_name)) + try: + with open(path, "rb") as payload_file: + return payload_file.read() + except OSError as exc: + _fail("{}: cannot read payload: {}".format(cmp_name, exc)) + + +def _create_description(cmp_manifest: dict, cmp_name: str) -> Optional[dict]: + """Derives the BF3 description tags of a component from its manifest. + + Returns None for tagtypes that do not map to a BF3 component at all (the + SM4200/SM6300 control tags). + """ + for key in ("tagtype", "format", "payload"): + if key not in cmp_manifest: + _fail("{}: manifest is missing the key '{}'".format(cmp_name, key)) + tagtype = cmp_manifest["tagtype"] + if not isinstance(tagtype, int) or tagtype not in BF2_TAGTYPE_MAP: + _fail("{}: unsupported tagtype {}".format(cmp_name, tagtype)) + bf3type, hwcid, _default_fmt, interface = BF2_TAGTYPE_MAP[tagtype] + if bf3type is None: + return None + + fmt_name = cmp_manifest["format"] + if fmt_name not in FORMATS: + _fail("{}: invalid format '{}'".format(cmp_name, fmt_name)) + if tagtype in BLOB_ONLY_TAGTYPES and fmt_name != "BLOB": + _fail( + "{}: tagtype 0x{:02X} requires the format BLOB, not '{}'".format( + cmp_name, tagtype, fmt_name + ) + ) + + desc = {BF3TAG.FMT: bytes([FORMATS[fmt_name]]), BF3TAG.TYPE: bytes([bf3type])} + if hwcid is not None: + desc[BF3TAG.HWCID] = hwcid.to_bytes(2, "big") + if interface is not None: + desc[BF3TAG.INTF] = bytes([interface]) + return desc + + +def _encrypt( + payload: bytes, + desc: dict, + cmp_manifest: dict, + cmp_name: str, + fw_key: Optional[bytes], +) -> Bf3Component: + enc_name = cmp_manifest.get("encryption", "PLAIN") + if enc_name not in ENCRYPTIONS: + _fail("{}: invalid encryption '{}'".format(cmp_name, enc_name)) + enc = ENCRYPTIONS[enc_name] + if enc == BF3ENC.PLAIN: + return Bf3Component(desc, payload) + desc[BF3TAG.ENC] = bytes([enc]) + if enc == BF3ENC.SESSIONKEY: + return Bf3Component( + desc, payload, actual_len=len(payload), encrypt_by_session_key=True + ) + if fw_key is None: + _fail("{}: encryption FWKEY requires 'fw_key' in the manifest".format(cmp_name)) + # AES-128-CBC with an all zero IV and zero padding - exactly what the + # reader firmware expects for BF3_ENCRYPT_FWKEY components + cipher: AES128 = create_AES128(fw_key, bytes(AES128.BLOCK_SIZE)) + return Bf3Component(desc, cipher.encrypt(payload), actual_len=len(payload)) + + +def _create_component( + cmp_manifest: Any, + cmp_name: str, + instrs: dict, + comments: dict, + fw_key: Optional[bytes], +) -> Optional[Bf3Component]: + if not isinstance(cmp_manifest, dict): + _fail("{}: manifest entry must be a JSON object".format(cmp_name)) + desc = _create_description(cmp_manifest, cmp_name) + if desc is None: + return None + + # BF2 instructions accumulate over all components, exactly as they do + # while parsing a BF2 stream in Bf3File.bf2_import() + instrs.update(cmp_manifest.get("instrs") or {}) + try: + Bf3File.exec_bf2instrs(instrs, desc, comments) + except FormatError as exc: + _fail("{}: {}".format(cmp_name, exc)) + except (ValueError, IndexError, KeyError) as exc: + _fail("{}: invalid BF2 instruction ({})".format(cmp_name, exc)) + + payload = _read_payload(cmp_manifest["payload"], cmp_name) + return _encrypt(payload, desc, cmp_manifest, cmp_name, fw_key) + + +def pack_bf3(manifest: Any) -> None: + """Creates the BF3 file described by ``manifest``. + + See the README for a description of the manifest format. + """ + _require_aes_backend() + if not isinstance(manifest, dict): + _fail("manifest must be a JSON object") + for key in ("dest", "components"): + if key not in manifest: + _fail("manifest is missing the key '{}'".format(key)) + fw_key = _parse_fw_key(manifest.get("fw_key")) + comments = dict(manifest.get("comments") or {}) + instrs: dict = {} + components = [] + for cmp_ndx, cmp_manifest in enumerate(manifest["components"]): + component = _create_component( + cmp_manifest, "component {}".format(cmp_ndx), instrs, comments, fw_key + ) + if component is not None: + components.append(component) + if not components: + _fail("manifest does not contain a single BF3 component") + + components.sort(key=lambda comp: comp.description[BF3TAG.TYPE]) + comments.update(Bf3File.annotations(components)) + Bf3File(comments, components).write_file(manifest["dest"]) + + +def _load_manifest(stream: TextIO) -> Any: + try: + return json.load(stream) + except ValueError as exc: + # the message of a JSON error refers to a position, not to content, + # and thus cannot leak the firmware key + _fail("cannot parse the manifest: {}".format(exc)) + + +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="bec2format", description="Tools for the BALTECH BEC2/BF3 file formats" + ) + subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND") + subparsers.add_parser( + CMD_PACK_BF3, + help="create a BF3 file from a manifest that is read from stdin", + description=( + "Creates a BF3 file from a JSON manifest that is read from stdin. " + "The manifest may contain the firmware key, which is why it is " + "neither passed as a file nor on the command line." + ), + ) + return parser + + +def main(argv: Optional[list] = None) -> int: + args = create_parser().parse_args(argv) + try: + if args.command == CMD_PACK_BF3: + pack_bf3(_load_manifest(sys.stdin)) + except Bf3PackError as exc: + sys.stderr.write("ERROR: {}\n".format(exc)) + return 1 + except Exception as exc: + # never let a traceback escape: it could expose the firmware key or + # other parts of the manifest + sys.stderr.write("ERROR: {}: {}\n".format(type(exc).__name__, exc)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 75fa356..95e4f60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,9 @@ cryptography = { version = "*", optional = true } [tool.poetry.extras] aes = ["cryptography"] +[tool.poetry.scripts] +bec2format = "bec2format.cli:main" + [tool.poetry.dev-dependencies] pytest = "*" @@ -63,6 +66,10 @@ ignore = [ # whitespace before ':' "E203", ] +per-file-ignores = [ + # asserts are the very purpose of a test + "tests/*:S101", +] [build-system] requires = ["poetry-core"] diff --git a/tasks.py b/tasks.py index ed3dd89..1c02dd0 100644 --- a/tasks.py +++ b/tasks.py @@ -7,7 +7,13 @@ @task() def install(ctx: Context) -> None: """install project dependencies""" - ctx.run("poetry install") + ctx.run("poetry install --all-extras") + + +@task() +def test(ctx: Context) -> None: + """runs the unit tests""" + ctx.run(f"pytest {Path(__file__).parent / 'tests'}") @task() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..f3995b8 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,299 @@ +import io +import json +import sys +from pathlib import Path +from typing import Any + +import pytest +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from bec2format.bf3file import ( + BF3ENC, + BF3FMT, + BF3TAG, + BF3TYPE, + DEFAULT_SESSION_KEY, + Bf3Component, + Bf3File, +) +from bec2format.cli import main + +FW_KEY = "401D6C7E98A9B469A6F598DB8E69862B" +LOADER_PAYLOAD = bytes([0xA5] * 0x40) +MAIN_PAYLOAD = bytes(range(0x100)) + bytes(range(0x2C)) + +# exec_bf2instrs() reads the firmware id from [:4] and the version from +# [15:22] of the firmware string +FIRMWARE_STRING = "1100 IDE Z".ljust(15) + "2.05.01" + +LOADER_INSTRS: dict = { + "Firmware": FIRMWARE_STRING, + "Creator": "make2", + "Bf3Update": "Supported", + "CRC": "0x1234ABCD", + "SELECT": {"FILTER": "010100B6", "PFIDS": "0x0000000000"}, + "SELECT_IF": {"PROTOCOL": "BRP"}, + "CHECK_FWVER": {"VERSIONDESC": "*"}, +} +MAIN_INSTRS: dict = { + "CRC": "0x89ABCDEF", + "SELECT_IF": {"PROTOCOL": "*"}, + "CHECK_FWVER": {"VERSIONDESC": "*"}, + "REBOOT": {}, +} + + +def decrypt(blob: bytes, key: bytes) -> bytes: + """Decrypts as the reader firmware does: AES-128-CBC with an all zero IV.""" + decryptor = Cipher(algorithms.AES128(key), modes.CBC(bytes(16))).decryptor() + return decryptor.update(blob) + decryptor.finalize() + + +def create_manifest(tmp_path: Path) -> dict: + loader_payload_file = tmp_path / "bf3_cmp00.bin" + loader_payload_file.write_bytes(LOADER_PAYLOAD) + main_payload_file = tmp_path / "bf3_cmp01.bin" + main_payload_file.write_bytes(MAIN_PAYLOAD) + return { + "dest": str(tmp_path / "1100_id_engine_z_firmware.bf3"), + "fw_key": FW_KEY, + "components": [ + { + "tagtype": 0x70, + "format": "BF2COMPATIBLE", + "encryption": "PLAIN", + "payload": str(loader_payload_file), + "instrs": LOADER_INSTRS, + }, + { + "tagtype": 0x84, + "format": "MEMIMAGE", + "encryption": "FWKEY", + "payload": str(main_payload_file), + "instrs": MAIN_INSTRS, + }, + ], + } + + +def run_cli(monkeypatch: pytest.MonkeyPatch, manifest: Any) -> int: + if not isinstance(manifest, str): + manifest = json.dumps(manifest) + monkeypatch.setattr(sys, "stdin", io.StringIO(manifest)) + return main(["pack-bf3"]) + + +def get_component(bf3_file: Bf3File, bf3type: int) -> Bf3Component: + (comp,) = [ + comp + for comp in bf3_file.components + if comp.description[BF3TAG.TYPE] == bytes([bf3type]) + ] + return comp + + +def test_pack_bf3_writes_a_readable_bf3_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manifest = create_manifest(tmp_path) + + assert run_cli(monkeypatch, manifest) == 0 + + bf3_file = Bf3File.read_file(manifest["dest"]) + assert [comp.description[BF3TAG.TYPE] for comp in bf3_file.components] == [ + bytes([BF3TYPE.LOADER]), + bytes([BF3TYPE.MAIN]), + ] + + +def test_pack_bf3_encrypts_the_payload_with_the_firmware_key( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manifest = create_manifest(tmp_path) + + run_cli(monkeypatch, manifest) + + main_comp = get_component(Bf3File.read_file(manifest["dest"]), BF3TYPE.MAIN) + assert main_comp.description[BF3TAG.FMT] == bytes([BF3FMT.MEMORYIMAGE]) + assert main_comp.description[BF3TAG.ENC] == bytes([BF3ENC.FWKEY]) + assert main_comp.actual_len == len(MAIN_PAYLOAD) + assert main_comp.blob != MAIN_PAYLOAD + plain = decrypt(main_comp.blob, bytes.fromhex(FW_KEY)) + assert plain[: main_comp.actual_len] == MAIN_PAYLOAD + + +def test_pack_bf3_stores_plain_components_unmodified( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manifest = create_manifest(tmp_path) + + run_cli(monkeypatch, manifest) + + loader_comp = get_component(Bf3File.read_file(manifest["dest"]), BF3TYPE.LOADER) + assert BF3TAG.ENC not in loader_comp.description + assert loader_comp.description[BF3TAG.FMT] == bytes([BF3FMT.BF2COMPATIBLE]) + assert loader_comp.blob == LOADER_PAYLOAD + + +def test_pack_bf3_converts_the_bf2_instructions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manifest = create_manifest(tmp_path) + + run_cli(monkeypatch, manifest) + + bf3_file = Bf3File.read_file(manifest["dest"]) + loader_comp = get_component(bf3_file, BF3TYPE.LOADER) + main_comp = get_component(bf3_file, BF3TYPE.MAIN) + assert loader_comp.description[BF3TAG.CRC] == bytes.fromhex("1234ABCD") + assert loader_comp.description[BF3TAG.PFID2] == bytes.fromhex("010100B6") + assert loader_comp.description[BF3TAG.INTF] == bytes([0]) # BRP_HID + assert BF3TAG.REBOOT not in loader_comp.description + assert main_comp.description[BF3TAG.CRC] == bytes.fromhex("89ABCDEF") + assert main_comp.description[BF3TAG.REBOOT] == b"\x01" + # instructions accumulate over the components, just like in bf2_import() + assert main_comp.description[BF3TAG.PFID2] == bytes.fromhex("010100B6") + + +def test_pack_bf3_derives_the_comments( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manifest = create_manifest(tmp_path) + + run_cli(monkeypatch, manifest) + + comments = Bf3File.read_file(manifest["dest"]).comments + assert comments["FirmwareId"] == "1100" + assert comments["FirmwareVersion"] == "2.05.01" + assert comments["Creator"] == "make2 + bf2-to-bf3-converter" + assert comments["Bf3Update"] == "Supported" + assert comments["Component1"].startswith("Main Firmware") + + +def test_pack_bf3_takes_over_the_comments_of_the_manifest( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manifest = create_manifest(tmp_path) + manifest["comments"] = {"CustomerId": "4711"} + + run_cli(monkeypatch, manifest) + + assert Bf3File.read_file(manifest["dest"]).comments["CustomerId"] == "4711" + + +def test_pack_bf3_skips_tagtypes_without_a_bf3_counterpart( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manifest = create_manifest(tmp_path) + # 0x34 is the SM4200 prepare tag, which is a control tag without payload + manifest["components"].insert( + 0, + { + "tagtype": 0x34, + "format": "BLOB", + "encryption": "PLAIN", + "payload": str(tmp_path / "does_not_exist.bin"), + "instrs": {}, + }, + ) + + assert run_cli(monkeypatch, manifest) == 0 + + assert len(Bf3File.read_file(manifest["dest"]).components) == 2 + + +def test_pack_bf3_encrypts_by_session_key( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manifest = create_manifest(tmp_path) + manifest["components"][1]["encryption"] = "SESSIONKEY" + + run_cli(monkeypatch, manifest) + + main_comp = get_component(Bf3File.read_file(manifest["dest"]), BF3TYPE.MAIN) + assert main_comp.description[BF3TAG.ENC] == bytes([BF3ENC.SESSIONKEY]) + assert main_comp.actual_len == len(MAIN_PAYLOAD) + # Bf3File.from_binary() does not decrypt session key components (it + # compares the ENC tag with an int), so this has to be done here + plain = decrypt(main_comp.blob, DEFAULT_SESSION_KEY) + assert plain[: main_comp.actual_len] == MAIN_PAYLOAD + + +@pytest.mark.parametrize( + ("modify_manifest", "expected_error"), + [ + (lambda mf: mf.pop("dest"), "missing the key 'dest'"), + (lambda mf: mf.pop("components"), "missing the key 'components'"), + (lambda mf: mf.update(components=[]), "not contain a single BF3 component"), + (lambda mf: mf.update(fw_key="0011"), "must be 16 bytes long"), + (lambda mf: mf.update(fw_key="NOHEX"), "not a valid hex string"), + (lambda mf: mf.pop("fw_key"), "encryption FWKEY requires 'fw_key'"), + (lambda mf: mf["components"][1].update(tagtype=0xEE), "unsupported tagtype"), + ( + lambda mf: mf["components"][1].update(format="MEMORYIMAGE"), + "invalid format 'MEMORYIMAGE'", + ), + ( + lambda mf: mf["components"][1].update(encryption="RSA"), + "invalid encryption 'RSA'", + ), + (lambda mf: mf["components"][1].pop("payload"), "missing the key 'payload'"), + ( + lambda mf: mf["components"][1].update(payload="does_not_exist.bin"), + "cannot read payload", + ), + ( + lambda mf: mf["components"][1].update(instrs={"SELECT_IF": {}}), + "invalid BF2 instruction", + ), + ], +) +def test_pack_bf3_reports_invalid_manifests( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, + modify_manifest: Any, + expected_error: str, +) -> None: + manifest = create_manifest(tmp_path) + modify_manifest(manifest) + + assert run_cli(monkeypatch, manifest) == 1 + + stderr = capsys.readouterr().err + assert stderr.startswith("ERROR: ") + assert expected_error in stderr + assert not Path(manifest.get("dest", tmp_path / "no_dest")).exists() + + +def test_pack_bf3_reports_a_broken_manifest( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture +) -> None: + assert run_cli(monkeypatch, '{"dest": ') == 1 + + assert "ERROR: cannot parse the manifest" in capsys.readouterr().err + + +def test_pack_bf3_never_writes_the_firmware_key_to_the_output( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture +) -> None: + manifest = create_manifest(tmp_path) + # provoke as many different errors as possible with a valid fw_key present + manifest["components"][1]["format"] = "MEMORYIMAGE" + + assert run_cli(monkeypatch, manifest) == 1 + + captured = capsys.readouterr() + assert FW_KEY not in captured.out + captured.err + assert FW_KEY.lower() not in (captured.out + captured.err).lower() + + +def test_pack_bf3_requires_blob_for_peripheral_tagtypes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture +) -> None: + manifest = create_manifest(tmp_path) + manifest["components"][1].update(tagtype=0x3D, format="MEMIMAGE") + + assert run_cli(monkeypatch, manifest) == 1 + + assert "requires the format BLOB" in capsys.readouterr().err From 09b27def122f7e32debd9a58d2cdd35f5c10800c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20H=C3=B6lzl?= Date: Thu, 20 Aug 2026 11:41:26 +0200 Subject: [PATCH 2/3] [bugfix] Datenverlust in Bf3File.bf2_unpack_payload() behoben. Die Funktion verwarf die erste 128-Byte-Zeile jedes Speicherbereichs nach einer Luecke und behielt zugleich dessen Startadresse: im Gap-Zweig wurde der Block geflusht und cur_block geleert, das Payload der ausloesenden Zeile aber nie angehaengt. Die Daten landeten dadurch 128 Byte zu frueh im Bereich und die ersten 128 Byte fehlten - eine stille Firmware-Korruption. Bisher latent, weil BF2_TAGTYPE_MAP uc-Firmware auf BF2COMPATIBLE abbildet und der MEMORYIMAGE-Pfad nie genommen wurde. Mit FW-898 wird er relevant, sobald jemand ein BF2 nach BF3/MEMIMAGE konvertiert. FW-898 Co-Authored-By: Claude --- bec2format/bf3file.py | 5 +- tests/test_bf2_unpack_payload.py | 80 ++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 tests/test_bf2_unpack_payload.py diff --git a/bec2format/bf3file.py b/bec2format/bf3file.py index a12ca2a..325b096 100644 --- a/bec2format/bf3file.py +++ b/bec2format/bf3file.py @@ -616,8 +616,9 @@ def bf2_unpack_payload(bf2lines: list[Bf2BinLine]) -> dict[int, bytes]: blocks[cur_block_start_adr] = b"".join(cur_block) cur_block = [] cur_block_start_adr = payload_offs - else: - cur_block.append(payload) + # the payload of the line that triggered the gap belongs to the + # block that starts at this very line + cur_block.append(payload) if cur_block_start_adr is None: cur_block_start_adr = payload_offs cur_block_end_adr = payload_offs + payload_len diff --git a/tests/test_bf2_unpack_payload.py b/tests/test_bf2_unpack_payload.py new file mode 100644 index 0000000..e1fcaec --- /dev/null +++ b/tests/test_bf2_unpack_payload.py @@ -0,0 +1,80 @@ +from bec2format.bf3file import BF3FMT, Bf2BinLine, Bf3File + +BF2_LINE_SIZE = 128 +TAGTYPE = 0x84 + + +def create_bf2line(tagtype: int, offs: int, payload: bytes) -> Bf2BinLine: + """Creates a BF2 line as it is emitted by Bf2File.addIntelHex().""" + fwtag = bytes([len(payload) + 2]) + offs.to_bytes(2, "big") + payload + return Bf2BinLine(tagtype, 0, fwtag, b"") + + +def create_bf2lines(start_adr: int, data: bytes) -> list: + """Splits a memory area into BF2 lines of 128 bytes each. + + ``start_adr`` is the address relative to the first BF2 line of the whole + component, as the 64K bank is encoded in the tagtype. + """ + return [ + create_bf2line( + TAGTYPE + (start_adr + offs) // 0x10000, + (start_adr + offs) % 0x10000, + data[offs : offs + BF2_LINE_SIZE], + ) + for offs in range(0, len(data), BF2_LINE_SIZE) + ] + + +def create_memimage_area(start_adr: int, data: bytes) -> bytes: + return start_adr.to_bytes(4, "big") + len(data).to_bytes(4, "big") + data + + +def test_unpack_payload_of_contiguous_area_returns_a_single_block() -> None: + data = bytes(range(0x100)) + + blocks = Bf3File.bf2_unpack_payload(create_bf2lines(0x4000, data)) + + assert blocks == {0x4000: data} + + +def test_unpack_payload_keeps_the_first_line_after_a_gap() -> None: + """Regression test: the line that triggered the gap must not be dropped. + + bf2_unpack_payload() used to flush the current block on a gap without + appending the payload of the triggering line, which silently corrupted + every memory area but the first one. + """ + area1 = bytes(range(0x100)) + area2 = bytes(ndx % 0x100 for ndx in range(0x180)) + + blocks = Bf3File.bf2_unpack_payload( + create_bf2lines(0x4000, area1) + create_bf2lines(0x10000, area2) + ) + + assert blocks == {0x4000: area1, 0x10000: area2} + + +def test_unpack_payload_of_three_areas() -> None: + areas = { + 0x0000: bytes([0xA5] * 0x80), + 0x8000: bytes([0x5A] * 0x100), + 0x20000: bytes([0x11] * 0x180), + } + bf2lines: list = [] + for start_adr, data in sorted(areas.items()): + bf2lines += create_bf2lines(start_adr, data) + + assert Bf3File.bf2_unpack_payload(bf2lines) == areas + + +def test_convert_payload_to_memoryimage_covers_all_areas() -> None: + area1 = bytes(range(0x100)) + area2 = bytes(ndx % 0x100 for ndx in range(0x180)) + bf2lines = create_bf2lines(0x4000, area1) + create_bf2lines(0x10000, area2) + + memimage = Bf3File.bf2_convert_payload(bf2lines, BF3FMT.MEMORYIMAGE) + + assert memimage == create_memimage_area(0x4000, area1) + create_memimage_area( + 0x10000, area2 + ) From 5a08e7b2bfc39f5001eaa238ebe05dd0bf3cca0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20H=C3=B6lzl?= Date: Thu, 20 Aug 2026 11:41:33 +0200 Subject: [PATCH 3/3] [feature] Version 1.03.00 Co-Authored-By: Claude --- README.md | 8 ++++---- package.json | 2 +- pyproject.toml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1bc8cd5..04ed802 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ * Compatible with [CPython](https://www.python.org/) >= 3.10 ```bash -pip install git+https://github.com/baltech-ag/bec2format.git#v1.02.00 +pip install git+https://github.com/baltech-ag/bec2format.git#v1.03.00 # or -poetry add git+https://github.com/baltech-ag/bec2format.git#v1.02.00 +poetry add git+https://github.com/baltech-ag/bec2format.git#v1.03.00 ``` #### Micropython @@ -22,7 +22,7 @@ poetry add git+https://github.com/baltech-ag/bec2format.git#v1.02.00 ```python import mip -mip.install("github:baltech-ag/bec2format/package.json", version="v1.02.00") +mip.install("github:baltech-ag/bec2format/package.json", version="v1.03.00") ``` ## How to use @@ -67,7 +67,7 @@ Components that need AES (`"encryption": "FWKEY"` or `"SESSIONKEY"`) require an AES implementation, so install the package with the `aes` extra: ```bash -uvx --from "bec2format[aes] @ git+https://github.com/baltech-ag/bec2format.git@v1.02.00" \ +uvx --from "bec2format[aes] @ git+https://github.com/baltech-ag/bec2format.git@v1.03.00" \ bec2format pack-bf3 < manifest.json ``` diff --git a/package.json b/package.json index 3cf67a0..6433c3c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bec2format", "description": "BALTECH BEC2 file format", - "version": "1.02.00", + "version": "1.03.00", "authors": [ "Baltech AG " ], diff --git a/pyproject.toml b/pyproject.toml index 95e4f60..b484b5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bec2format" -version = "1.02.00" +version = "1.03.00" description = "BALTECH BEC2 file format" authors = ["Baltech AG "] license = "MIT"