From b0b46b3919cc7625c37e95f6228dc3ba0ec96543 Mon Sep 17 00:00:00 2001 From: michael Date: Sun, 5 Jul 2026 21:37:46 -0700 Subject: [PATCH] feat(flash): firmware pin + matches_pin detection + pm3flash CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 'is the device running the right firmware, and flash it if not' layer on top of the flasher. - _firmware.py: FirmwarePin (pinned to the firmware/ submodule commit) and matches() — a device version-string test against the pinned git SHA (or a release tag, once tagged releases exist). - FirmwareInfo gains expected_firmware + matches_pin, computed on connect, so pm3.firmware.matches_pin tells you whether the fork build is present. - pm3flash console-script: auto-detect, compare to the pin, resolve the image (--image / $PM3PY_FIRMWARE / local build), confirm, flash with progress, then reopen and verify the new version. Flashes only on mismatch unless --force; fullimage-only unless --allow-bootrom. Detection + CLI are unit-tested; the auto-download layer (fetch the pinned release asset) and hardware validation are still to come. Suite 1034 pass. Co-Authored-By: Claude Opus 4.8 --- pm3py/_firmware.py | 39 +++++++++++++ pm3py/core/client.py | 7 +++ pm3py/flash_cli.py | 115 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 + tests/test_firmware_pin.py | 75 ++++++++++++++++++++++++ 5 files changed, 239 insertions(+) create mode 100644 pm3py/_firmware.py create mode 100644 pm3py/flash_cli.py create mode 100644 tests/test_firmware_pin.py diff --git a/pm3py/_firmware.py b/pm3py/_firmware.py new file mode 100644 index 0000000..e7994f6 --- /dev/null +++ b/pm3py/_firmware.py @@ -0,0 +1,39 @@ +"""Firmware pin — the fork firmware build this pm3py release corresponds to. + +pm3py and the firmware fork share a wire contract (command IDs, the response-table +format, the WTX relay, the sim-trace path), so a given pm3py must be paired with a +specific firmware build. This module records that pairing. + +The pin is stamped from the ``firmware/`` submodule commit. Until tagged firmware +releases exist it is the raw commit SHA; a release process will later set ``tag`` +and asset URLs, and auto-download will resolve the matching ``fullimage.elf`` +against them. Nothing here reads the submodule at runtime — a pip-installed wheel +has no submodule — so the value is baked in and bumped when the pin moves. +""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FirmwarePin: + """A pinned firmware build.""" + sha: str # firmware/ submodule commit (short is fine) + tag: str | None = None # release tag, once tagged releases exist + + +# The firmware/ submodule commit this pm3py build was developed and tested against. +FIRMWARE_PIN = FirmwarePin(sha="303bd5873") + + +def matches(version_string: str, pin: FirmwarePin = FIRMWARE_PIN) -> bool: + """True if a device's firmware version string corresponds to ``pin``. + + The firmware embeds its git revision (e.g. ``Iceman/main/303bd5873-dirty``) + in the version string, so a 7-char SHA prefix (git's default abbreviation) is + a reliable substring test. A configured ``tag`` matches too, once releases + are tagged. + """ + if not version_string: + return False + if pin.tag and pin.tag in version_string: + return True + return bool(pin.sha) and pin.sha[:7] in version_string diff --git a/pm3py/core/client.py b/pm3py/core/client.py index 44875d7..16478a9 100644 --- a/pm3py/core/client.py +++ b/pm3py/core/client.py @@ -16,6 +16,7 @@ from .hf_iso15 import HF15Commands from .hf_mfc import HFMFCommands from .hf_mfu import HFMFUCommands from .flash import Flasher +from .._firmware import FIRMWARE_PIN, matches as _fw_matches log = logging.getLogger(__name__) @@ -28,6 +29,8 @@ class FirmwareInfo: section_size: int = 0 ng_protocol: bool = False compatible: bool = False + expected_firmware: str = "" # the fork build this pm3py is pinned to + matches_pin: bool = False # device firmware == the pinned build? warnings: list[str] = field(default_factory=list) @@ -116,6 +119,10 @@ class Proxmark3: except PM3Error: fw.warnings.append("Could not read firmware version — device may be in bootloader mode") + # Step 2.5: Compare against the pinned fork firmware (for pm3flash / live-sim) + fw.expected_firmware = FIRMWARE_PIN.tag or FIRMWARE_PIN.sha + fw.matches_pin = _fw_matches(fw.version_string) + # Step 3: Assess compatibility fw.compatible = fw.ng_protocol and len(fw.warnings) == 0 diff --git a/pm3py/flash_cli.py b/pm3py/flash_cli.py new file mode 100644 index 0000000..e873d40 --- /dev/null +++ b/pm3py/flash_cli.py @@ -0,0 +1,115 @@ +"""``pm3flash`` — check the device firmware and flash the pinned fork build. + +Auto-detects the Proxmark3, compares its firmware against the build this pm3py is +pinned to, and (with confirmation) flashes the matching ``fullimage.elf``. By +default it only flashes when the device does not already match the pin. + + pm3flash # flash the pinned firmware if the device differs + pm3flash --image fw.elf # flash a specific image + pm3flash --force # flash even if already up to date + pm3flash --yes # no confirmation prompt + +Image source (in order): --image, then $PM3PY_FIRMWARE, then a local build +(firmware/armsrc/obj/fullimage.elf). Auto-download of the pinned release is a +later layer that also feeds this same resolution. +""" +import argparse +import asyncio +import sys + +from .core.client import Proxmark3 +from .core.transport import PM3Error +from .core.flash import FlashError, resolve_image +from ._firmware import matches + + +def _print_progress(ev: dict) -> None: + pct = 100 * ev["block"] // ev["total"] + sys.stdout.write(f"\r flashing block {ev['block']}/{ev['total']} ({pct}%)") + sys.stdout.flush() + if ev["block"] == ev["total"]: + sys.stdout.write("\n") + + +async def _run(args) -> int: + # Connect and read the current firmware. + try: + pm3 = Proxmark3(args.port) + await pm3.connect() + except PM3Error as e: + print(f"error: {e}", file=sys.stderr) + return 2 + + fw = pm3.firmware + print(f"device firmware: {fw.version_string or '(unknown — bootloader mode?)'}") + print(f"pinned firmware: {fw.expected_firmware}") + + if fw.matches_pin and not args.force: + print("device already runs the pinned firmware — nothing to do.") + await pm3.disconnect() + return 0 + + # Resolve the image up front so a missing file fails before we prompt/flash. + try: + image = resolve_image(args.image) + except FlashError as e: + print(f"error: {e}", file=sys.stderr) + await pm3.disconnect() + return 2 + + if not args.yes and sys.stdin.isatty(): + reason = "forced" if fw.matches_pin else "firmware differs from pin" + ans = input(f"Flash {fw.expected_firmware} onto {pm3._transport.port} " + f"({reason})? [y/N] ").strip().lower() + if ans not in ("y", "yes"): + print("aborted.") + await pm3.disconnect() + return 1 + + try: + result = await pm3.flasher.flash( + image, allow_bootrom=args.allow_bootrom, on_progress=_print_progress) + except (PM3Error, FlashError) as e: + print(f"\nflash failed: {e}", file=sys.stderr) + return 2 + + print(f"wrote {result['bytes']} bytes in {result['blocks']} blocks; device reset.") + + # Verify: the device rebooted into the new OS — reopen and re-read the version. + try: + await pm3._transport.reopen() + ver = await pm3.hw.version() + vstr = ver.get("version_string", "") + if matches(vstr): + print(f"verified: now running {vstr}") + rc = 0 + else: + print(f"warning: post-flash firmware '{vstr}' does not match the pin", + file=sys.stderr) + rc = 3 + except PM3Error as e: + print(f"note: flashed OK but could not re-read the version ({e})") + rc = 0 + await pm3.disconnect() + return rc + + +def main(argv=None) -> int: + p = argparse.ArgumentParser( + prog="pm3flash", + description="Flash the pinned Proxmark3 fork firmware.") + p.add_argument("--port", help="serial port (default: auto-detect)") + p.add_argument("--image", help="path to fullimage.elf " + "(default: $PM3PY_FIRMWARE or local build)") + p.add_argument("--force", action="store_true", + help="flash even if the device already matches the pin") + p.add_argument("--allow-bootrom", action="store_true", + help="permit writing the bootrom region (brick risk)") + p.add_argument("-y", "--yes", action="store_true", + help="do not prompt for confirmation") + args = p.parse_args(argv) + return asyncio.run(_run(args)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 1d6fda9..a365082 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,9 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest"] +[project.scripts] +pm3flash = "pm3py.flash_cli:main" + [tool.setuptools.packages.find] include = ["pm3py*"] diff --git a/tests/test_firmware_pin.py b/tests/test_firmware_pin.py new file mode 100644 index 0000000..65fd17e --- /dev/null +++ b/tests/test_firmware_pin.py @@ -0,0 +1,75 @@ +"""Tests for the firmware pin + needs-reflash detection (no hardware).""" +import asyncio + +from pm3py._firmware import FirmwarePin, FIRMWARE_PIN, matches +from pm3py.core.client import Proxmark3 +from pm3py import flash_cli + + +def run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +# --- matches() --- + +def test_matches_sha_substring(): + pin = FirmwarePin(sha="303bd5873") + assert matches("Iceman/main/303bd5873-dirty", pin) is True + assert matches("Iceman/main/303bd58-clean", pin) is True # 7-char prefix + + +def test_matches_rejects_other_build(): + pin = FirmwarePin(sha="303bd5873") + assert matches("Iceman/master/deadbeef", pin) is False + + +def test_matches_empty_is_false(): + assert matches("", FirmwarePin(sha="303bd5873")) is False + + +def test_matches_tag(): + pin = FirmwarePin(sha="303bd5873", tag="fw-v0.1.0") + assert matches("pm3py fw-v0.1.0 build", pin) is True + + +def test_default_pin_is_present(): + assert FIRMWARE_PIN.sha # a pin is baked in + + +# --- _probe_firmware wiring --- + +def _fake_probe(pm3, version_string): + async def fake_ping(length=32): + return {"success": True} + + async def fake_version(): + return {"version_string": version_string, "chip_id": "0x1", "section_size": 0} + + pm3.hw.ping = fake_ping + pm3.hw.version = fake_version + + +def test_probe_sets_matches_pin_true(): + pm3 = Proxmark3("/dev/ttyFAKE") + _fake_probe(pm3, f"Iceman/main/{FIRMWARE_PIN.sha}-dirty") + run(pm3._probe_firmware()) + assert pm3.firmware.matches_pin is True + assert pm3.firmware.expected_firmware == FIRMWARE_PIN.sha + + +def test_probe_sets_matches_pin_false_for_stock(): + pm3 = Proxmark3("/dev/ttyFAKE") + _fake_probe(pm3, "Iceman/master/00000000") + run(pm3._probe_firmware()) + assert pm3.firmware.matches_pin is False + + +# --- CLI wiring --- + +def test_cli_help_exits_clean(capsys): + try: + flash_cli.main(["--help"]) + except SystemExit as e: + assert e.code == 0 + out = capsys.readouterr().out + assert "pm3flash" in out