Adds the pyws workspace-plugin documentation (discovery, connection modes, resources, replay effects, tests). Also corrects the client quick-reference: MIFARE Classic is reached via hf.mf, not hf.mfc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
209 lines
12 KiB
Markdown
209 lines
12 KiB
Markdown
# pm3py — Development Guide
|
|
|
|
## What is this?
|
|
|
|
A pure-Python async library that speaks the Proxmark3 NG wire protocol directly over USB serial. Returns structured dicts, not text. No dependency on the C client binary.
|
|
|
|
## Quick reference
|
|
|
|
```bash
|
|
# Run tests (no hardware needed, all mocked)
|
|
cd /home/work/pm3py
|
|
python -m pytest tests/ -v
|
|
|
|
# Install for development
|
|
pip install -e .
|
|
```
|
|
|
|
## Package structure
|
|
|
|
```
|
|
pm3py/
|
|
__init__.py # re-exports Proxmark3, PM3Error, PM3Response, Cmd, PM3Status
|
|
core/ # wire protocol and device commands
|
|
protocol.py # wire constants, CRC-16/A, Cmd enum, PM3Status
|
|
transport.py # frame encode/decode, PM3Transport async serial
|
|
client.py # Proxmark3 class, _SyncProxy, FirmwareInfo
|
|
hw.py # hardware commands, LED API (platform-aware)
|
|
hf.py # HF core — tune, search, sniff, dropfield
|
|
hf_iso14a.py # ISO 14443-A — scan, raw. Uses MIX frames
|
|
hf_iso15.py # ISO 15693 — scan, rdbl, wrbl, thin sniff cmd
|
|
hf_mfc.py # MIFARE Classic — rdbl, wrbl, rdsc, chk, nested, cident
|
|
lf.py # LF commands + T55xxCommands + LFSearchResult
|
|
flash.py # pure-Python firmware flasher (OLD-frame bootloader protocol)
|
|
_firmware.py # firmware pin (matches_pin) — the fork build pm3py corresponds to
|
|
flash_cli.py # `pm3flash` console-script (detect → build/resolve → flash → verify)
|
|
trace/ # firmware trace infrastructure (shared by sniff, sim, reader)
|
|
trace.py # parse_tracelog, TRACELOG_HDR_SIZE
|
|
decode_iso15.py # ISO 15693 command/response decoders
|
|
ndef.py # NDEF TLV/record decode for trace annotation
|
|
format.py # ANSI color formatting, format_sniff_line
|
|
sniff/ # sniff orchestration — protocol detection, session lifecycle
|
|
session.py # SniffSession — start/download/decode per protocol
|
|
sim/ # card simulation infrastructure (19 files)
|
|
transponder.py # Transponder ABC, MemoryRegion
|
|
medium.py # Medium, SoftwareMedium (RF simulation)
|
|
reader.py # Reader ABC, ScriptedReader, InteractiveReader
|
|
frame.py # RFFrame (bit/byte level)
|
|
sim_session.py # SimSession (table compile + WTX relay)
|
|
dual_session.py # DualInterfaceSession (PM3 RF + MCU I2C)
|
|
table_compiler.py # ResponseTable, TableCompiler
|
|
trace_fmt.py # TraceFormatter (14443-A + 15693 decoders)
|
|
pm3medium.py # PM3-backed Medium for real hardware
|
|
mcu_bridge.py # COBS serial bridge to MCU
|
|
mcu_protocol.py # MCU message types and protocol
|
|
fuzzer.py # Transponder fuzzer
|
|
relay.py # Card relay
|
|
replay.py # Trace replay
|
|
access_control/ # Wiegand, OSDP
|
|
transponders/ # tag/transponder models (extracted from sim/)
|
|
bitfield.py # BitField descriptor + Register base — self-describing config/
|
|
# frame registers (T5577, EM4100, NTAG21x, Ultralight EV1/C, MFC
|
|
# access bits, NTAG I2C NC_REG/NS_REG/REG_LOCK/PT_I2C, NTAG5
|
|
# STATUS_REG/CONFIG_REG, NXP AES key privileges). Documented attrs -> shell
|
|
# completion + repr decode table. Layouts cross-checked vs the
|
|
# firmware submodule + NXP datasheets.
|
|
hf/iso14443a/ # Tag14443A_3/4, MifareClassic, DESFire, NfcType2/4
|
|
# nxp/type2,ntag21x,ultralight,ntag_i2c
|
|
# (NTAG210-216, Ultralight/C/EV1, NTAG I2C plus)
|
|
# st/st25tn (Type 2), st/st25ta (Type 4)
|
|
# infineon/optiga_nbt (OPTIGA Authenticate NBT, Type 4)
|
|
hf/iso14443b/ # base.py: Tag14443B (SRIX slot-marker) +
|
|
# Tag14443B_4 (standard REQB/ATTRIB + ISO-DEP).
|
|
# Vendor models (organised by mfg, like the rest):
|
|
# st/st25tb, ti/rf430cl330h (NFC Type 4B)
|
|
hf/iso15693/ # Tag15693, NfcType5, NXP ICODE/SLIX2/DNA/NTAG5
|
|
# st/st25tv, st/st25dv, infineon/myd_vicinity
|
|
# ti/tagit (Tag-it HF-I), ti/rf430frl (sensor)
|
|
lf/ # EM4100, HID, T5577
|
|
reader/ # higher-level reader modes by protocol/vendor (scaffold)
|
|
```
|
|
|
|
## Client API
|
|
|
|
```python
|
|
pm3.hw.ping() # hardware
|
|
pm3.hf.iso14a.scan() # ISO 14443-A
|
|
pm3.hf.iso15.rdbl(4) # ISO 15693
|
|
pm3.hf.mf.rdbl(0) # MIFARE Classic
|
|
pm3.lf.t55.readbl(0) # T55xx
|
|
pm3.hf.tune() # HF antenna tune
|
|
pm3.hf.dropfield() # drop field
|
|
```
|
|
|
|
## Wire protocol essentials
|
|
|
|
- **NG frame (client→device):** magic `0x61334d50` + `length|0x8000` + cmd + payload + crc/nocrc
|
|
- **MIX frame:** Same but `ng` bit unset, payload starts with 3x uint64 args (24 bytes)
|
|
- **Response frame:** magic `0x62334d50` + `length|ng` + status + reason + cmd + payload + crc/nocrc
|
|
- **USB: no CRC** (postamble = `0x3361` cmd / `0x3362` resp). C client sets `send_with_crc_on_usb = false`.
|
|
- **FPC UART: CRC-16/A** with byte-swapped wire encoding
|
|
|
|
## Key patterns
|
|
|
|
- All command methods are `async`. The sync wrapper in `_SyncProxy` (in `core/client.py`) intercepts via `__getattr__` and calls `loop.run_until_complete()`.
|
|
- Command classes hold a `self._t` reference to `PM3Transport` (or mock in tests).
|
|
- Tests use `AsyncMock` for transport. Set `hw._is_rdv4 = False` to skip capabilities fetch in LED tests.
|
|
- `capabilities()` response parsed at known byte/bit offsets from the C struct (version=7 format).
|
|
|
|
## Platform differences (PM3 Easy vs RDV4)
|
|
|
|
| Color | Easy | RDV4 |
|
|
|--------|-----------|-----------|
|
|
| green | A (0x01) | B (0x02) |
|
|
| red | B (0x02) | C (0x04) |
|
|
| orange | C (0x04) | A (0x01) |
|
|
| blue | D (0x08) | *(none)* |
|
|
| red2 | *(none)* | D (0x08) |
|
|
|
|
PWM-capable: Easy = A,B. RDV4 = A,D.
|
|
|
|
## Sim framework
|
|
|
|
Software-defined transponder/reader simulation framework — 750+ tests, merged to master. Pure-Python models for ISO 14443-A, 15693, MIFARE Classic, DESFire, JCOP, LF (EM4100, HID, T5577), NDEF, NXP ICODE/SLIX2/DNA/NTAG5, access control (Wiegand, OSDP), implant profiles. Transponder models in `transponders/`, sim infrastructure in `sim/`.
|
|
|
|
## Table compiler: proprietary command match patterns
|
|
|
|
**Critical:** For NXP custom commands (0xA0+), table entry match patterns must **NOT** include the manufacturer code byte (0x04). The firmware's UID addressing logic consumes the mfg byte as part of UID parsing, so after normalization the mfg byte is absent from the command passed to table lookup.
|
|
|
|
Addressing flow for `22 AB 04 <uid_8_bytes>`:
|
|
1. Firmware sees `cmd[0] & ADDRESS` → addressed mode
|
|
2. `cmd[2]` (mfg code 0x04) doesn't match UID → tries `cmd[3:11]` → UID matches
|
|
3. `cmdCpt` advances past mfg + UID → `cmdCpt = 11`
|
|
4. Normalization: `norm = [flags & ~ADDRESS, cmd] + cmd[cmdCpt:]` → `02 AB` (no mfg code!)
|
|
5. Table lookup on `02 AB` → match pattern must be `[0x02, 0xAB]` (PREFIX), NOT `[0x02, 0xAB, 0x04]`
|
|
|
|
For unaddressed commands (`02 AB 04`), mfg code stays → `02 AB 04`. PREFIX match on `[0x02, 0xAB]` matches both forms.
|
|
|
|
**Rule:** All NXP custom command table entries use `match=bytes([flags, cmd_byte])` with `MATCH_PREFIX`. Never include `0x04` in the match.
|
|
|
|
## Python-driven card simulation
|
|
|
|
Design doc: `docs/PYTHON_SIM_DESIGN.md`. Firmware patch in `firmware/` submodule (proxmark3-pm3py) + Python sim framework. 15693 sim fully working — phone reads all blocks, NDEF, NXP custom commands. Two mechanisms:
|
|
- **Response table** in BigBuf — pre-compiled by Python, served by firmware at wire speed (86µs FDT for 14443-A Layer 3)
|
|
- **WTX relay** — firmware sends S(WTX) on Layer 4 table miss, relays APDU to Python over USB for real-time crypto (DESFire, JCOP, EMV)
|
|
- **15693 retry relay** — reader retry-based relay for unknown commands
|
|
|
|
Firmware maintenance: atomic single-file commits for easy rebase against upstream PM3. See design doc for CI workflow.
|
|
|
|
## Firmware flashing
|
|
|
|
pm3py flashes the fork firmware itself (`pm3.flasher`, `pm3flash` CLI) — no C `pm3-flash`.
|
|
Key facts for maintainers:
|
|
- **OLD frame, not NG.** The bootloader speaks the legacy 544-byte fixed frame (no
|
|
magic/CRC); `core/transport.py` has `send_old`/`reopen`, `core/flash.py` the `Flasher`.
|
|
- **fullimage-only by default.** The bootrom region is refused unless `allow_bootrom=True` —
|
|
a bad OS write is recoverable (proven on hardware); a bad bootrom write bricks to JTAG.
|
|
- **Merge segments sharing a flash page** (`build_blocks`): the SAM7 erase-programs whole
|
|
pages, so two adjacent PT_LOAD segments landing in one page must be written once with both
|
|
segments' data (else 0xFF padding clobbers real bytes — the on-hardware bug we hit).
|
|
- **Platform ≠ is_rdv4.** `is_rdv4` is the running firmware's compile-time flag, not the
|
|
board, so `--build`/`build_firmware` require an explicit `PLATFORM` (`PM3GENERIC` = Easy,
|
|
`PM3RDV4`). Build target: `make -C firmware PLATFORM=... armsrc/all`.
|
|
- **The pin.** `pm3py/_firmware.py` `FIRMWARE_PIN` is the fork build pm3py corresponds to
|
|
(currently the submodule SHA); bump it when you bump the submodule. `matches()` does a
|
|
7-char SHA-prefix test on the device version string. Auto-download of the pinned release
|
|
is the open follow-up (see the DT-Gitea migration plan).
|
|
|
|
## Adding new commands
|
|
|
|
1. Find the `CMD_*` constant in `include/pm3_cmd.h` and add to `Cmd` enum in `core/protocol.py`
|
|
2. Check if the C client uses `SendCommandNG` (→ `send_ng`) or `SendCommandMIX` (→ `send_mix`)
|
|
3. Check the payload struct in `pm3_cmd.h` and use `struct.pack` to build it
|
|
4. Parse the response using `struct.unpack_from` on `resp.data`
|
|
5. Return a dict with human-readable keys
|
|
6. Write test with `AsyncMock` transport — no hardware needed
|
|
|
|
## pyws integration
|
|
|
|
pm3py ships a [pyws](https://github.com/dangerousthings/pyws) workspace plugin
|
|
(`pm3py/pyws_plugin.py`), so pm3py is a first-class pyws workspace: `pyws` drops you into a
|
|
shell with the device connected and the transponder/sim classes in scope. pyws is a separate,
|
|
domain-agnostic engine; the plugin is the only pm3py↔pyws coupling. Plan/design:
|
|
`docs/plans/2026-07-05-pm3py-pyws-plugin-plan.md`.
|
|
|
|
- **Discovery.** Registered via the `pyws.plugins` entry point in `pyproject.toml`
|
|
(`pm3py = "pm3py.pyws_plugin:Pm3pyPlugin"`). pyws must be installed alongside pm3py
|
|
(editable during dev: `.venv/bin/pip install -e /path/to/pyws`; a `[pyws]` extra is the
|
|
packaging target once pyws is published).
|
|
- **One connection mode per workspace.** `reader` (async `Proxmark3`) OR `sim`
|
|
(`SimSession`) — never both live; they contend on `/dev/ttyACM*`. Declaring both refuses
|
|
the second.
|
|
- **`reader` resource** → the connected `Proxmark3` (`.hw/.hf/.lf`). Missing device is
|
|
non-fatal (reports disconnected).
|
|
- **`sim` resource** → a `Sim` facade over `SimSession`: opens the port on load; the sim is
|
|
armed **explicitly** with `sim.start(tag)` (dispatches 14a/15693 by tag type). `sim.stop()`,
|
|
`sim.push(tag)` (→ `tag.sync()`), `sim.frames` (decoded trace).
|
|
- **Namespace.** The transponder classes (NTAG21x, Ultralight, MifareClassic, Type5/15693,
|
|
implant factories), `SimSession`, `Proxmark3`, NDEF helpers — injected so `startup.py` needs
|
|
no imports.
|
|
- **Replay safety (effects).** `Pm3pyPlugin.effects` declares pattern policies against the
|
|
full dotted call path. pyws replays safe-by-default (deny-list): **writes** (`*.wrbl`,
|
|
`*.writebl`, `tag.sync`, `sim.push`), **field** (`tune`, `dropfield`), **sim**
|
|
(`sim.start`, firmware sims) and **destructive** ops are withheld on autoreplay; **reads**
|
|
and pure model edits (`tag.set_page`/`set_ndef`) replay. So reconstructing a session rebuilds
|
|
the tag *model* but never re-pushes to firmware — your explicit `sim.start(tag)` does that.
|
|
- **Tests.** `tests/test_pyws_plugin.py`, hardware-free (injected clients/sessions,
|
|
`AsyncMock`). Uses pm3py's shared-event-loop `_run` convention (NOT `asyncio.run`, which
|
|
closes the loop and poisons later tests).
|