feat(flash): firmware pin + matches_pin detection + pm3flash CLI

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 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-05 21:37:46 -07:00
parent 762e305dd9
commit b0b46b3919
5 changed files with 239 additions and 0 deletions

39
pm3py/_firmware.py Normal file
View File

@@ -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

View File

@@ -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

115
pm3py/flash_cli.py Normal file
View File

@@ -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())

View File

@@ -43,6 +43,9 @@ dependencies = [
[project.optional-dependencies]
dev = ["pytest"]
[project.scripts]
pm3flash = "pm3py.flash_cli:main"
[tool.setuptools.packages.find]
include = ["pm3py*"]

View File

@@ -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