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:
39
pm3py/_firmware.py
Normal file
39
pm3py/_firmware.py
Normal 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
|
||||||
@@ -16,6 +16,7 @@ from .hf_iso15 import HF15Commands
|
|||||||
from .hf_mfc import HFMFCommands
|
from .hf_mfc import HFMFCommands
|
||||||
from .hf_mfu import HFMFUCommands
|
from .hf_mfu import HFMFUCommands
|
||||||
from .flash import Flasher
|
from .flash import Flasher
|
||||||
|
from .._firmware import FIRMWARE_PIN, matches as _fw_matches
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ class FirmwareInfo:
|
|||||||
section_size: int = 0
|
section_size: int = 0
|
||||||
ng_protocol: bool = False
|
ng_protocol: bool = False
|
||||||
compatible: 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)
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -116,6 +119,10 @@ class Proxmark3:
|
|||||||
except PM3Error:
|
except PM3Error:
|
||||||
fw.warnings.append("Could not read firmware version — device may be in bootloader mode")
|
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
|
# Step 3: Assess compatibility
|
||||||
fw.compatible = fw.ng_protocol and len(fw.warnings) == 0
|
fw.compatible = fw.ng_protocol and len(fw.warnings) == 0
|
||||||
|
|
||||||
|
|||||||
115
pm3py/flash_cli.py
Normal file
115
pm3py/flash_cli.py
Normal 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())
|
||||||
@@ -43,6 +43,9 @@ dependencies = [
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest"]
|
dev = ["pytest"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
pm3flash = "pm3py.flash_cli:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["pm3py*"]
|
include = ["pm3py*"]
|
||||||
|
|
||||||
|
|||||||
75
tests/test_firmware_pin.py
Normal file
75
tests/test_firmware_pin.py
Normal 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
|
||||||
Reference in New Issue
Block a user