Files
pm3py/CLAUDE.md
michael 0f597bc933 feat(registers): NTAG I2C plus config/session/PT_I2C registers
Add self-describing registers for the NTAG I2C plus (NT3H2111/2211),
decoded bit-exact from the NT3H2111 datasheet (Tables 10-14):

- NtagI2CConfig   config registers E8h/E9h (NC_REG: pass-through, FD_ON/
                  FD_OFF, SRAM mirror, transfer dir; LAST_NDEF_BLOCK;
                  SRAM_MIRROR_BLOCK; watchdog; I2C_CLOCK_STR; REG_LOCK)
- NtagI2CSession  session registers ECh/EDh — same NC_REG copy plus the
                  NS_REG status byte and NEG_AUTH_REACHED
- NtagI2CPtI2C    PT_I2C (E7h): 2K_PROT / SRAM_PROT / I2C_PROT

Config and session share one _NtagI2CRegBlock base (byte 6 differs:
REG_LOCK vs NS_REG). Bound via tag.config / tag.session / tag.pt_i2c;
the fresh-tag decode matches the shipped defaults. 7 new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:45:39 -07:00

9.9 KiB

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

# 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). Fields are
                           #   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

pm3.hw.ping()              # hardware
pm3.hf.iso14a.scan()       # ISO 14443-A
pm3.hf.iso15.rdbl(4)       # ISO 15693
pm3.hf.mfc.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