Compare commits

...

132 Commits

Author SHA1 Message Date
michael
907522f0ae feat(lf): test-mode T55xx write (opcode 01) for recovery
Add a `test` flag to lf.t55.writebl that sets the firmware test-mode bit, sending
the datasheet's opcode-01 reconfiguration write (§5.10.3) instead of the standard
opcode-10 write. This is the path that can rewrite a tag a normal write can't
(corrupted/locked config), when the master key still permits it. Matches the C
client's `lf t55 write -t`. Default off; routine writes are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:01:24 -07:00
michael
a990d725ef feat(lf): NRZ + biphase raw physical-layer demod, with a fallback in identify_lf
Add the two missing ASK line codes so an unrecognised tag yields its raw bits
instead of reading as "nothing":

- ask.nrz_bits: NRZ/direct demod. Keys the bit clock off the shortest run (not
  2x a half-bit as in Manchester), resamples, yields both polarities/phases.
- ask.biphase_bits: biphase/differential-Manchester, reusing dsp.biphase_raw_decode
  (the FDX-B path).
- capture.demod_raw: runs both, finds the shortest clean repeating frame, returns
  {period, bits, hex} per encoding + the recovered clock. Sits BELOW the credential
  decoders — it names no format, just hands back the bits.
- identify_lf falls back to demod_raw when no credential decodes off the air, so a
  strong-but-unknown LF signal surfaces its frame instead of None.

Hardware-hardened: on a live non-NRZ/biphase tag the first cut reported a spurious
16-bit "frame" — biphase-decoding a non-biphase signal emits a short prefix then a
constant run that trivially repeats at any period. _frame_period now rejects
near-constant streams and finds the smallest *true* period (so 0101 fills read as
period 2 and are rejected, not reported at min_p). Regression tests cover all three.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:13:36 -07:00
michael
e3a394859c docs: correct the T5577 block-order finding — MAXBLK=1 test is the decisive one
The block-3 write-read-back wasn't decisive: writebl and readbl share the address
path, so they round-trip regardless of addressing correctness. Since proxmark,
Flipper, and pm3py all write the EM4100 header into block 1, ruling out a pm3py
1<->2 read-swap required an addressing-free read. Set MAXBLK=1 so regular-read
streams only physical block 1, read it off the air, and it matched READ(1)
(non-header BDEF7BC0). Confirmed: the tag is a genuine reverse-order clone, pm3py
reads it correctly, no addressing bug. Restored MAXBLK=2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:27:07 -07:00
michael
4226c9a11c docs: C client oracle + LF demod parity notes
Capture the investigation behind the T5577 live validation: why the stock C
client won't run here (VS Code snap sandbox injects core20 libpthread — not a
client bug), the LF clock-detection parity gap vs the C client's DetectASKClock
(pm3py's detect_clock was weaker; now partially closed), and the data-block read
investigation. The latter reached ground truth via datasheet + C source + an
on-hardware write-read-back: no read/addressing bug — the live tag is just a
reverse-order EM4100 clone; the only artifact is unresolved Manchester polarity
(a real, resolvable improvement, noted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:59:14 -07:00
michael
8833753c45 fix(lf): detect_clock keys off the shortest well-supported interval, not the min
detect_clock estimated the bit clock from the *minimum* edge interval, so a few
stray sub-bit edges (which every real ADC capture carries) collapsed the estimate.
On a strongly-coupled, rail-clipping EM4100 the true half-bit of 32 dropped to ~3,
snapping the data clock to 8 and turning the ASK demod into all phase-error markers
— no config, no credential decoded.

Base the symbol on the shortest interval with real support (>=20% of the modal
count) instead of the raw minimum. Verified: clean synthetic still reads 32; the
hardware capture that broke it now clocks correctly. Live on a T5577 emulating
EM4100 00FFFFFFFF: DETECT decodes config 00148040, identify returns the credential.

Regression test reproduces the exact failure (sparse stray edges): old estimator
3.67 (dead demod), new 31.99.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:59:14 -07:00
michael
8f136e7dc4 feat(rawcli): finish T5577 catalog — page/password, RESET, hex data words
Round out the ATA5577C command set to the datasheet-catalog standard:

- RESET command (downlink opcode 00), backed by a new lf.t55.reset() that
  drives CMD_LF_T55XX_RESET_READ. Hardware-validated (status 0).
- READ/WRITE gain optional <page> (1 = traceability) and <password> for
  password-mode access, threaded through read_config / read_t55xx_block.
- WRITE data and all passwords now parse as hex words (like the pm3 client's
  -d/-p and every other rawcli WRITE); blocks/pages stay decimal. Previously
  T5577 WRITE data alone was base-10, so WRITE(0, 00088040) wrote decimal.

Live-validated on a T5577 emulating EM4100 00FFFFFFFF: DETECT/READ(0) decode
the config 00148040 (ASK/RF64/EM4100), RESET/WAKE return ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:20:11 -07:00
michael
218e6dfc2a docs(rawcli): record ICODE DNA catalog sourcing plan + datasheet pointers
Capture (before the follow-up work starts) how to build the DNA catalog from the local
datasheets: SL2S6002_SDS for the command set + memory map (63 user blocks, block 63 =
counter, 48 config blocks), SL2S2602 (SLIX2) for shared opcodes, hardware-confirmed
READ_CONFIG 0xC0/WRITE_CONFIG 0xC1, and docs/NTAG5_SECURITY.md for the AES commands.
Marks NTAG5 done; notes the real NTAG5 datasheets (NTP5210/NTA5332) are also local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:43:19 -07:00
michael
4684d0cc83 feat(rawcli): identify NTAG5 and ICODE DNA (READ_CONFIG probe)
NTAG5, ICODE DNA and SLIX2 all share E0 04 and answer READ_SIGNATURE, so name_iso15
now splits them with an addressed, retried READ_CONFIG (0xC0) probe:
  - session register 0xA0 (STATUS_REG) succeeds only on NTAG5      -> "NTAG 5"
  - 0xA0 errors but config block 0x00 succeeds (DNA has config mem) -> "ICODE DNA"
  - neither (SLIX2 has no READ_CONFIG) -> the 0xBD signature step   -> "ICODE SLIX2"
  - plain SLIX answers neither                                      -> "ICODE SLIX"

Hardware-verified against a real NTAG5 (VivoKey VK Thermo, UID E0 04 01 58, 0xA0 ->
00 01400000) and a real ICODE DNA (UID E0 04 01 18, 0xA0 -> 01 0F error, 0x00 -> 00 ...).
DNA was previously mislabeled "ICODE SLIX2". catalog_for routes DNA to the SLIX2 catalog
for now (a dedicated DNA catalog with READ_CONFIG + AES is a follow-up). Adds a mocked
unit test covering all four branches from the real captures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:33:41 -07:00
michael
9e18a10274 feat(rawcli): NTAG 5 datasheet catalog (config + SRAM + pick-random-UID)
_NTAG5 = the SLIX2 command set minus STAY_QUIET_PERSISTENT (which NTAG5 rejects per the
NTP5210/NTA5332 tables) plus the NTAG5-specific NXP customs: READ_CONFIG (0xC0),
WRITE_CONFIG (0xC1, session registers 0xA0 STATUS_REG / 0xA1 CONFIG_REG), PICK_RANDOM_UID
(0xC2), and READ_SRAM (0xD2) / WRITE_SRAM (0xD3) for the Link/Boost variants. Sourced
from transponders/hf/iso15693/nxp/ntag5_*, routed by catalog_for, verified live.

Not yet wired: auto-identify of NTAG5. NTAG5 and ICODE DNA share UID E0 04 01 18, so
they can't be told apart by UID — routing needs a functional probe (READ_CONFIG of a
session register), which needs a brief NTAG5 tap to nail without risking DNA
misclassification. AES/ISO-29167-10 commands (0x35/0x39/0x3A) noted as a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:14:33 -07:00
michael
d0339bceb5 fix(clientcli): right-arrow help in the dropdown, selection-restoring LEFT, delete refresh
The right-arrow help never worked for subcommand completions and rendered into
the bottom toolbar (unreadable). Rework it to present in the completion dropdown
— the same two-column form as normal completions — and fix the surrounding
keyboard interactions, all verified by driving the real app through a terminal
emulator (not just unit tests).

- Help now renders as dropdown rows (hints.help_rows), never the bottom bar. The
  completer yields the target command's name/usage/options/notes as non-inserting
  Completion("") rows while help is open; the bottom bar keeps only the one-line
  hint.
- resolve_target: strip the trailing space subcommand completions carry, and don't
  re-append a token the menu already inserted into the buffer — both silently made
  the panel resolve to nothing.
- RIGHT captures the highlighted node + buffer/complete_state; LEFT restores the
  EXACT pre-RIGHT selection. Detach the menu with complete_state=None rather than
  cancel_completion(), which calls go_to_completion(None) and wipes the highlighted
  index (this is what made Tab commit the wrong command after a RIGHT/LEFT trip).
- Rebind backspace/delete/C-w/C-u to re-run completion: complete_while_typing only
  fires on insert, so deleting characters left the dropdown stale/blank.

Tests updated to the real live states (trailing-space completions, post-insertion
buffer), plus guards pinning the detach-not-cancel invariant and the delete-key
rebinding.
2026-07-16 13:11:48 -07:00
michael
72815e34eb feat(rawcli): full INVENTORY_READ request body (mask + AFI) — 15693 complete
_inv_read_frame builds the complete ICODE inventory-read request body:
[AFI] | mask_len(bits) | mask value | first_block | count-1. INVENTORY_READ and
FAST_INVENTORY_READ gain optional mask_len/mask (selective anticollision, ceil(bits/8)
value bytes) and afi (filter byte + the 0x10 inventory flag, flags -> 0x36). Mask-less
stays the default, so existing frames are unchanged.

With this the ISO 15693 support is datasheet-complete end to end: the full request-flags
byte, both SLIX and SLIX2 command sets, and the inventory-read body. Verified live
(mask-less / 16-bit mask / AFI forms build + decode + round-trip) and unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:08:27 -07:00
michael
251e276fbb feat(rawcli): complete the 15693 request-flags byte in iso15_frame
The header builder previously set only high-rate, inventory+1slot, addressed and
option — F15_PROTO_EXT and F15_SELECT were even defined but unused, so several valid
request-flag bits could not be expressed. Complete it: sub-carrier (0x01), protocol-
extension (0x08), Select (0x10), 16-slot inventory (Nb_slots=0), and the inventory AFI
flag (0x10) with its AFI byte inserted before the params. Bits 5-6 are now branched
correctly by mode (Select/Address for non-inventory, AFI/Nb_slots for inventory), per
ISO 15693-3 §7.3.1. iso15_flags_decode decodes every bit in both modes, and the byte-0
flag landmarks gain select (0x12) and inventory+AFI (0x36).

Existing frames are unchanged (verified by the preserved 0x02/0x22/0x42/0x26 tests);
adds coverage for every newly-settable bit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:01:54 -07:00
michael
7b028f4ddc feat(rawcli): INVENTORY_READ / FAST_INVENTORY_READ for the SLIX catalogs
Completes the ICODE SLIX/SLIX2 datasheet command set. These fuse an inventory round
with a block read; unlike every other catalog command they need the inventory bit set
in the request flags. Added an `inventory=` option to iso15_frame (sets inventory +
single-slot, 0x26 with high rate) and both commands to _SLIX (inherited by _SLIX2),
with the mask-less body mask_len 0 | first_block | count-1 (matching the tag model's
_handle_inventory_read: first_block at data[4], num_blocks at data[5]+1).

Verified live (completer names both, opcode 0xA0/0xA1 map back, entry round-trips) and
unit-tested. Mask-based inventory (AFI/mask value) left as a future refinement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:56:00 -07:00
michael
a61e9724a6 feat(rawcli): ICODE SLIX2 datasheet catalog
SLIX2 is the SLIX command set plus three SLIX2-only commands and a larger 80-block
memory: READ_SIGNATURE (0xBD, the ECC originality signature identify already probes),
PASSWORD_PROTECTION_64BIT (0xBB), STAY_QUIET_PERSISTENT (0xBC). Built by reusing the
SLIX catalog's commands, so it stays a strict superset; catalog_for routes an identified
"ICODE SLIX2" to it (SLIX2 checked before the SLIX/ICODE fallback).

Verified live through the real completer + PromptSession: command-name completion
surfaces the SLIX2 commands, raw-opcode completion maps 0xBD/0xBB/0xBC back to them, and
entry accepts end to end. Unit test covers the delta + dispatch.

Follow-ups noted in docs/rawcli-todo.md: NTAG5 (needs a finer identify probe first, since
it shares E0 04 with SLIX), and INVENTORY_READ/FAST_INVENTORY_READ for the SLIX catalogs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:51:12 -07:00
michael
2ba570a7ef test(rawcli): guard Ctrl-t breadcrumb flips 0x<->0b + redraw
Closes the last loose end from the byte-line autocomplete fix: the entry-mode toggle
correctly flips the breadcrumb prefix (0x <-> 0b) and requests a redraw. Adds
test_toggle_updates_breadcrumb_prefix and updates the roadmap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:43:58 -07:00
michael
4b5df12a76 test(rawcli): end-to-end live-session regression for byte-line entry + completion
Drive the real PromptSession that run() builds (real key bindings + RawCompleter +
complete_while_typing) via a prompt_toolkit pipe input simulating TTY keystrokes, so
the actual rawcli input stack is exercised, not just the handler in isolation. Asserts
every input path yields the correct accepted line (hex byte line, binary via toggle,
mixed hex/binary, function call, command name) and that the completer is queried live
during binary entry (0 queries before the autocomplete fix, since buf.document= reset
complete_state).

prompt()'s internal asyncio.run() nulls the current-loop pointer; the helper saves and
restores it so the shared-loop `_run` convention in other test files is not poisoned.
Full suite stays green (1383).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:41:34 -07:00
michael
6e46916dd9 fix(rawcli): raw byte-line autocomplete fires in hex and binary
The digit key handler applied each keystroke as `buf.document = Document(...)`.
Assigning the buffer document resets prompt_toolkit's complete_state and never
re-fires completion, so the live autocomplete menu never appeared during raw byte
entry -- in BOTH hex and binary (only command-name completion worked, since that
path already used insert_text).

type_digit only ever appends the digit (optionally after an auto-space) or drops it,
so the change is always a pure suffix. Apply it with buf.insert_text, which triggers
completion exactly like an ordinary keystroke.

Verified by driving the real key binding: the completion menu now populates on every
digit in hex and binary (before: complete_state None, start_completion never called).
Adds a regression test asserting the handler routes through insert_text; updates the
rawcli roadmap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:31:28 -07:00
michael
c675f6b278 fix(transport): handle CMD_WTX wait-extension frames, don't misread them as replies
The firmware sends an async CMD_WTX ("wait N ms") control frame on FPGA bitstream
reload (fpgaloader.c: send_wtx(FPGA_LOAD_WAIT_TIME=1500)). The first HF command after
connect triggers the HF-bitstream load, so a WTX frame lands before the real reply.
Our read loops skipped only DEBUG_PRINT_* frames, so they returned the WTX frame as
the command reply -- leaving the real reply pending and permanently desyncing every
later read (each command then got the *previous* command's response).

This was the root cause of the intermittent "15693 garbage/shift" reads. ping (no
bitstream load) and well-coupled 14a were unaffected, which is why it looked
RF/coupling-specific -- it never was, and it is not a firmware bug: the firmware
behaves correctly and the C client already handles WTX (comms.c). pm3py just was not
a spec-compliant client.

Mirror the C client: on CMD_WTX, add the wtx value to the wait and keep reading.
Applied to both read_response and _read_any_response.

Verified on hardware: an ICODE SLIX GET_SYSTEM_INFO/READ stress went from 100% anomaly
(permanent shift) to 0% (187/187 correct). Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:15:06 -07:00
michael
6ab8582308 feat(rawcli): ICODE SLIX datasheet catalog — 15693 header builder + position-aware completion
Adds ISO 15693 / ICODE support in the datasheet-catalog style:

- iso15_frame() header builder assembles a request (flags | command | [NXP mfg 0x04] | [UID
  LSByte-first] | params); hf.iso15.raw() sends it (CRC host-side, auto long-wait for write/lock).
- ICODE SLIX (SL2S2002) catalog: the datasheet command set — standard 15693 (READ/WRITE/LOCK
  block, READ_MULTIPLE, AFI/DSFID, GET_SYSTEM_INFO, security, select/reset/stay-quiet) and NXP
  customs (GET_NXP_SYSTEM_INFO, GET_RANDOM, SET/WRITE/LOCK_PASSWORD, PROTECT_PAGE, EAS set/reset/
  lock/alarm/write-id, ENABLE_PRIVACY, DESTROY). catalog_for routes SLIX/ICODE names here.
- TagCommand gains an explicit opcode= : in 15693 the frame's first byte is the request FLAGS,
  not the command, so completion keys on the command byte (byte 1).
- Position-aware raw completion for hf15: byte 0 = the flags menu with a live decode of each
  value, byte 1 = the command, byte 2 = the 04 mfg code on custom commands (previewed with the
  user). Function-call form and command-name completion unchanged.
- Per-chip identify: E0 04 -> NXP ICODE; a full 32-byte READ_SIGNATURE distinguishes SLIX2 from
  SLIX. Robust UID fetch (inventory retry + a validated direct GET_SYSTEM_INFO fallback), since
  vicinity anticollision misses often.

Hardware-verified on an ICODE SLIX: identify -> "ICODE SLIX" (reliably), the frames build
correctly (02 2B / 02 20 00 / 02 B2 04), and the flags/command/mfg menus + block map resolve.
(Command *responses* intermittently desync on this specific flaky PM3 link — a known device issue
across protocols, not the framing.) 1376 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:32:09 -07:00
michael
9c54015735 feat(iso15): hf.iso15.raw() — send an arbitrary ISO15693 request frame
The 15693 catalog builds request frames (flags | command | [mfg] | [UID] | params) but there was
no transport to send them — rawcli's hf15 raw path fetched a nonexistent hf.iso15.raw and got
None, so nothing executed. Add it: raw() takes the frame without a CRC (appended host-side via
_iso15_payload), sends CMD_HF_ISO15693_COMMAND, and returns the firmware-relayed tag response.
long_wait selects the write-length tag-ack timeout for WRITE/LOCK-class commands.

This is the foundation for the ICODE SLIX/SLIX2/NTAG5 catalogs. Hardware-verified against an ICODE
SLIX: GET_SYSTEM_INFO (32 blocks, IC ref 0x96, no READ_SIGNATURE → SLIX-family), READ_BLOCK,
GET_RANDOM_NUMBER, and the NXP custom 02 B2 04 form all round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:14:44 -07:00
michael
3b4ec4541b feat(rawcli): full MIFARE Classic datasheet command set + value blocks
Completes the MIFARE Classic catalog with its datasheet wire commands (the standing rule: every
transponder exposes its full datasheet command set, like NTAG21x), each carrying its wire opcode
so it autocompletes by name and by the hex/binary opcode value, with the block memory map:

  AUTH_A 0x60 / AUTH_B 0x61   auth a sector (test whether a key works; auth is otherwise implicit)
  READ 0x30 / WRITE 0xA0      (existing)
  INCREMENT 0xC1 / DECREMENT 0xC0 / RESTORE 0xC2 / TRANSFER 0xB0   value-block ops
  PERSONALIZE_UID 0x40 / SET_MOD_TYPE 0x43   EV1 (opcode + hint; execution not yet wired)
  CHK (key finder) / HALT 0x50   (existing)

core: hf.mf.value(block, action, value, transfer_block, key, key_type) wires CMD_HF_MIFARE_VALUE
(0x0627) — payload mirrors the stock client's CmdHF14AMfValue / firmware MifareValue (key[0:6],
action[9], transferBlk[10], operand[11:15], transfer-key[27:33], nested-auth flag[33]); the op is
committed to transfer_block, or in place when None; a cross-sector transfer sets the nested-auth
flag. INCREMENT/DECREMENT/RESTORE commit in place; TRANSFER copies a value block block->dest.

No completer/memory/app changes — the machinery is data-driven off the catalog.

Hardware-verified on a MIFARE Classic 1K: identify; c1/60/b0 (and binary) surface INCREMENT/AUTH_A
/TRANSFER with the block map; AUTH_A reports the right key ok and a wrong key failed; a value
round-trip INCREMENTed 100 -> 105 with the value-block format intact (block restored after). 1370
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:47:22 -07:00
michael
fd16fa8f90 feat(trace): live terminal output under bpython via a shared emit()
Live sniff/sim stream trace frames from a background thread. bpython replaces sys.stdout with a capture object whose write() does a greenlet switch back to the main thread — which raises off-thread; the text buffers but only repaints on the next keypress, so streamed frames 'pile up until you touch a key'. New trace/output.py emit() buffers via the repl's on_write and fires bpython's threadsafe repaint trigger; a plain write everywhere else, with a safe fallback on any internals mismatch.

Route TraceFormatter.print(), SimSession's sim-ended status, and SniffSession's state message through emit(). Flip the trace-line newline convention from leading to trailing so each emitted line self-terminates (format_sniff_line + test updated to match).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 21:25:16 -07:00
michael
0586e2a41a fix(sim): break the transponders<->sim circular import via lazy re-exports
Importing a transponder model first (e.g. iso15693.type5, or iso14443a via the fuzzer) triggered pm3py.sim's __init__, which eagerly re-imported that same transponder before it finished initialising — ImportError on a partially-initialised module. Masked in the full suite by import order; failed whenever a transponder was the first import.

Move sim/__init__'s ~90 transponder re-exports into sim/_models.py and load them lazily via a PEP 562 __getattr__ (public API unchanged: attribute access, from-import, __all__, dir(), and 'import *' all still work). Defer fuzzer.py's iso14443a.base import into the one method that uses it. Add tests/test_import_hygiene.py — subprocess fresh-import guards per transponder family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 21:23:12 -07:00
michael
d59a3c017b docs(readme): pure-Python LF demod, rawcli memory map + per-byte base entry
- LF: drop the stale "demodulation still needs the C client" note; document pm3py.lf
  (EM4100/HID/AWID/FDX-B + T55xx config, hardware-validated) with identify_lf/read_config.
- rawcli: per-byte base intermixed entry (30 + toggle + 00000100 -> 30 00000100 -> 0x30 0x04),
  the completion-menu memory map (page/block roles incl. MIFARE Classic sector trailers), MFC
  authenticated CHK/READ/WRITE + LF T5577 commands, and the no-command/byte-mixing rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:10:10 -07:00
michael
0f77eb9b83 docs: document pm3py client in the README
Adds a feature bullet, a `pm3py client` section (usage, the SWIG/pexpect backends, completion + press-right help + coloured output) and a 'Distributing a drop-in' subsection for --pack; notes the pexpect dependency and the clientcli package in the tree. rawcli content untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 21:06:02 -07:00
michael
4f66a93de9 feat(cli): add pm3py client — autocompleting console over the stock proxmark3 client
Wraps the standard Proxmark3 C client (so it works with any Proxmark/firmware) in a prompt_toolkit REPL: full command-tree completion sourced from the client's commands.json, option-flag completion, Tab-to-descend, a press-right help toggle, and the client's own coloured output.

Two swappable backends behind ClientDriver: in-process SWIG (_pm3) by default, a pexpect-around-the-binary fallback otherwise. Both auto-detect /dev/ttyACM* and discover the checkout via its doc/commands.json marker (walk-up + side-by-side), so no path is hardcoded.

`pm3py client --pack OUT` spins off a self-contained drop-in — a readable .py, or a .pyz with prompt_toolkit/pexpect bundled — amalgamated from the package (the tested source of truth) so it can't drift. Adds pexpect to deps; hardware-free tests in tests/test_clientcli.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 21:05:53 -07:00
michael
925d2c92f0 docs: pyws integration section; fix hf.mfc -> hf.mf in the quick-ref
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>
2026-07-15 21:04:04 -07:00
michael
2b087a302a feat(rawcli): per-byte base intermixed entry; fix MFC hf.mf; help + strict mixing
Entry now tracks each raw byte's base, so hex and binary can share one line with a clean display:
`30` (hex) then Ctrl-/ then `00000100` (binary) shows `30 00000100` and sends 0x30 0x04. The
toggle only changes the base of the NEXT byte — it never rewrites what you've typed. Each byte
auto-seals at its base width (2 hex / 8 binary); the next digit starts a fresh byte in the current
mode; a digit invalid for its byte's base is dropped. Applies to raw byte entry only (function
args stay base-10).

- entry.type_digit / is_byte_line / reconcile_bases carry the logic (pure, unit-tested); the digit
  and backspace key bindings maintain session.byte_bases; the toggle just flips the mode.
- parser.parse_bytes/parse_line take per-byte bases (explicit 0x/0b still wins). A line mixing a
  command with raw bytes (`READ 30`) raises instead of transmitting.
- completer parses the opcode token in its own base, so `30`+binary still autocompletes the page.
- help documents all of it (per-byte base, toggle, auto-seal, base-10 args, no command/byte mix).

Also fixes a real bug found on hardware: the MFC catalog called hf.mfc (nonexistent) — it's
hf.mf. Hardware-verified on a MIFARE Classic 1K: identify, CHK(0)=FFFFFFFFFFFF, READ(0) returns
the manufacturer block, READ(4)/READ(1) the data blocks. Intermixed entry verified end-to-end in
the live prompt (30 00000100 -> 0x30 0x04). 1351 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:43:04 -07:00
michael
f04703c309 feat(rawcli): MIFARE Classic authenticated read/write/chk
Classic block ops are gated by a crypto1 auth per sector, so a bare 0x30 does nothing. Wire the
MFC catalog to hf.mfc (which does the auth + op in firmware), run-based like the T5577 commands:

  READ(<block>[, <12-hex key>][, A|B])   -> auth + read the 16-byte block  (default FFFFFFFFFFFF/A)
  WRITE(<block>, <32-hex>[, key][, A|B]) -> auth + write
  CHK(<block>[, A|B])                    -> try a default key list, report the working key

build() still exposes the wire opcode (0x30/0xA0) so hex/binary completion and the raw-byte page
map keep working; execution goes through run(). Key type A/B accepted as letters or 0/1.

Mock-tested (rdbl/wrbl/chk call args + result lines, key override, failure path). Hardware verify
needs an actual MIFARE Classic card on the antenna (current tag is the NTAG213). 1340 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:41:53 -07:00
michael
260c1adad8 feat(rawcli): MIFARE Classic block-level memory map
MFC gets the same memory-map hinting as NTAG, at the block level:

  block 0        manufacturer block — UID, BCC, SAK, ATQA, vendor data
  sector trailer Key A [0-5], access bits [6-8] + GPB [9], Key B [10-15]
  other blocks   data block (sector N)

Handles 1K (16 sectors), Mini (5 sectors) and 4K — including the eight 16-block sectors 32-39
(trailer = last block, e.g. 143 = sector 32, 255 = sector 39). landmark_pages lists block 0 +
every sector trailer (the map skeleton; data blocks are uniform), so READ( / raw 30 surface it
in the completion dropdown. Also routes "MIFARE Mini" to the MFC catalog (was falling through to
Type 2).

Tests cover the block roles, large-sector trailers, bounds, Mini routing, and the dropdown map.
Path verified end-to-end with a mocked SAK-0x08 scan (identify -> catalog -> completer). Live
hardware verify still needs an actual Classic card on the antenna. 1339 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:35:33 -07:00
michael
56fcfec648 docs(rawcli): roadmap — MVP transponders, binary-autocomplete bug, MFC-first
Captures the named-transponder MVP (NTAG21x/UL EV1 done; MIFARE Classic next; then ICODE
SLIX/SLIX2/NTAG5), the UX contract (all hints in the completion dropdown, hex + binary), the
binary-mode autocomplete bug (entry._regroup_after resetting buf.document cancels the menu), and
the 15693 header-builder + per-chip-identify foundations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:30:47 -07:00
michael
a5c7702398 feat(rawcli): complete + family-accurate NTAG21x / Ultralight EV1 memory map
The page hinting was coarse and partly wrong: pages 0-2 were all "UID", page 3 was always
"CC" (it's OTP on Ultralight), config fields were abbreviated, and the NTAG210/212 (no counter)
and Ultralight (no ASCII mirror) differences were ignored. Now every named region is covered
and the fields track the actual IC:

  00-01  UID / serial number
  02     static lock bytes + internal (locks pages 03-0F)
  03     Capability Container (CC)   [NTAG]   /   OTP (one-time programmable)   [Ultralight]
  04..N  user memory
  <lock> dynamic lock bytes          (present on 212/213/215/216 and MF0UL21; absent on 210/UL11)
  CFG0   AUTH0, MIRROR (mode/page/byte on NTAG only), STRG_MOD_EN
  CFG1   ACCESS: PROT, CFGLCK, [NFC_CNT_EN, NFC_CNT_PWD_PROT on 213/215/216], AUTHLIM
  PWD    32-bit password
  PACK   password acknowledge + RFUI

_LAYOUTS gains a family tag ("ntag"/"ul") and a counter flag; page_role/landmark_pages derive
page 3 (CC vs OTP) and the CFG0/CFG1 field lists from them. The generic fallback (unknown model)
now also names the static-lock and CC/OTP header pages. Raw-byte completion matches the
fixed-width byte form only (so partial "2" no longer wrongly hits page 02).

Hardware-verified on NTAG213: both READ( and raw "30 " list the full 9-region map. Tests cover
the family differences (counter bits, mirror, OTP, dynamic-lock presence) and layout/model drift
for the Ultralight parts too. 1330 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:18:05 -07:00
michael
65be53da8c fix(rawcli): all hints live in the completion dropdown, not the bottom bar
The memory-location info was being surfaced on the bottom toolbar (input_hint). It belongs in
the autocomplete dropdown — the same popup where identify/READ/… appear. Moved it there and
took it off the bottom bar entirely.

- Bottom toolbar shows only the entry-mode status now; the input_hint machinery is removed.
- Raw hex/binary entry: a matched opcode inserts the RAW BYTE (30, 00110000), labelled with
  the command name — not "READ(". You're building a raw byte string, so accepting a hint keeps
  you in raw bytes.
- New: after a page-command opcode in raw entry, the next byte gets the tag's memory map as raw
  bytes (30 04 -> "04  user memory"), matching what READ( offers. Renders in the entry base.
- Function-call page args still insert 0x-hex addresses (the chosen display).

Verified through the real interactive TUI (pty): typing "30 " pops the memory map in the
completion menu as raw bytes; the old bottom-bar hint string no longer appears anywhere. 1328
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:09:16 -07:00
michael
a3cef180d2 fix(rawcli): memory locations live only in suggestions; base-10 call args
Two corrections:

1. Memory-location names are a SUGGESTION, never command output. Removed the region annotation
   printed after each exchange (both the function-call and raw-hex paths) along with its dead
   machinery (region_annotation, pages_for_payload, TagCommand.pages). The location now shows in
   exactly two places: the completion menu (page landmarks) and the live bottom-bar hint —
   which now also covers raw byte entry (30 04 -> "READ(page) … · page 0x04 → user memory")
   via Catalog.by_opcode.

2. Function-call arguments are base 10 by default. READ(04) crashed on int("04", 0) (Python
   rejects leading-zero decimals in base 0). parser.parse_arg_int parses a call argument as
   decimal unless it carries a 0x/0b/0o prefix, so READ(04)/READ(40) work and READ(0x28) still
   overrides to hex. Raw byte entry keeps its opposite default (hex, 0b to intermix) — unchanged.

Hardware-verified on NTAG213: READ(04) sends 30 04 with no region line in the output; the hint
shows the page role for READ(4), READ(04), READ(0x28), raw 30 04 and 30 28; completion still
lists the memory map. 1331 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:38:53 -07:00
michael
899142f23a fix(rawcli): memory annotations survive a flaky/absent GET_VERSION
The page-role annotations and completion were gated on identify resolving an *exact* model
string (NTAG216). On a flaky NG link a single lost GET_VERSION dropped identify to the generic
SAK name "MIFARE Ultralight / NTAG", which isn't in the layout table — so every region
annotation and memory-map completion silently went blank. (The happy-path checks always got a
clean identify, so this never showed in earlier verification.)

Two fixes:
- _probe_version retries GET_VERSION up to 3x, re-selecting between attempts, so a lost shot on
  a flaky link no longer costs the exact model (and its full memory map).
- memory._resolve_layout falls back to a generic Type 2 map (UID / CC / the always-user pages
  0x04-0x0F) when only the NTAG/Ultralight family is known — so READ(0)/READ(4) still annotate
  even when the exact model can't be determined (or an original Ultralight has no GET_VERSION).
  Config pages differ by model and are left unnamed rather than guessed.

Tests: retry recovers the model after two lost shots; a never-answering GET_VERSION yields the
generic name yet still annotates the universal pages; generic names resolve UID/CC/user but not
model-specific pages. 1328 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:23:38 -07:00
michael
33e713549d feat(rawcli): annotate raw hex/binary commands with datasheet memory regions too
Raw '30 04' is a READ(4), so it now earns the same region line as the function-call form —
the annotation follows the bytes, not the syntax:

  30 04       -> 04-07 → user memory
  3A 00 06    -> 00-02 → UID/serial · 03 → CC · 04-06 → user memory
  30 E3       -> E3 → CFG0 · E4 → CFG1 · E5 → PWD · E6 → PACK

catalog.pages_for_payload() reverse-maps a raw payload to the pages it touches by matching the
opcode (payload[0]) to a page/block command and reading its page argument from payload[1:].
dispatch's raw path prints region_annotation() after the exchange, mirroring _handle_call.
Works from either entry mode (it operates on the decoded bytes).

Along the way: TagCommand.opcode() centralises opcode extraction, tolerating data args (valid
hex placeholder) and two-phase (multi-frame) builds — fixing a latent bug where COMPAT_WRITE's
opcode came back as bytes/None. The completer now keys landmark suggestions on the first
parameter being a page/block/start address, so FAST_READ( gets the memory map too.

Hardware-verified on a real NTAG216 (raw 30 04 / 3A 00 06 / 30 E3, and the binary form
00110000 00000100). Tests for the reverse-map, the two-phase opcode, and the raw dispatch
annotation. 1274 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:03:37 -07:00
michael
c53c087e84 feat(rawcli): render page-argument completion in the active entry mode
The memory-map completion for a page/block argument now follows the entry mode instead of
always emitting hex:

  hex mode     READ(  -> 0x04        user memory
  binary mode  READ(  -> 0b00000100  user memory   (full 8 bits — each bit visible)

Matching narrows in the same base (a binary partial filters bit-prefix-wise), and a 0x/0b
prefix on the argument overrides the session mode. Binary matters here because for the config
pages each bit carries its own meaning (CFG0 AUTH0, CFG1 PROT/CFGLCK/AUTHLIM), so seeing the
whole pattern is the point.

Hardware-verified against a real NTAG216 in binary mode (0b00000000..0b11100110 with roles).
Tests for binary rendering, bit-prefix filtering, and prefix-override. 1271 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:29:37 -07:00
michael
b6fdb7bca4 feat(rawcli): suggest the tag's memory map when completing a page argument
Typing the page/block argument of a READ/WRITE now pops the identified tag's memory landmarks
as completions, each labelled with its datasheet role, so you pick a location by meaning:

  READ(      -> 0x00  UID / serial number
                0x03  Capability Container (CC)
                0x04  user memory
                0xE3  CFG0 — MIRROR / MIRROR_PAGE / AUTH0
                0xE5  PWD (32-bit password)  ...
  READ(0xE   -> filters to the E-page landmarks

memory.landmark_pages() derives the notable pages from the same _LAYOUTS the hint/annotation use
(NTAG21x / Ultralight EV1 landmarks; T5577 blocks 0-7). The completer matches the partial in
0x-, bare-hex, or decimal form and inserts canonical 0xNN. A comma ends the page argument, so
data args get no page suggestions; tags with no known layout (MIFARE Classic) offer nothing.

Hardware-verified: completer run against a real identified NTAG216 lists 0x00..0xE6 with roles.
Tests for the map, partial filtering, second-arg guard, T5577 blocks, and the no-layout case.
1269 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:24:45 -07:00
michael
260cb8eb9e feat(rawcli): annotate read/write output with datasheet memory regions
A READ now names the memory locations the pages cover, per the tag's datasheet layout,
grouping consecutive same-role pages:

  READ(0)  -> 00-02 → UID / serial number  ·  03 → Capability Container (CC)
  READ(4)  -> 04-07 → user memory
  READ(0xE3) -> E3 → CFG0 · E4 → CFG1 · E5 → PWD · E6 → PACK

TagCommand gains a pages() callable (which page/block numbers a command touches: READ = 4
pages from <page>, FAST_READ = <start>..<end>, WRITE = one page). memory.region_annotation()
groups those into "lo-hi → role" via the existing page_role layouts; _handle_call prints it
after the exchange. No layout for the tag (e.g. MIFARE Classic) -> no annotation.

Hardware-verified on NTAG216 (READ 0/4/0xE3, FAST_READ). Tests for the grouping + the
dispatch path. 1264 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:18:36 -07:00
michael
a501759c43 fix(transport): auto-resync after a read timeout (the "04 BB stale UID" desync)
On the flaky NG link a command's response can arrive *after* its read times out, and the
next command's read then picks up that stale frame — observed in rawcli as every raw 14a
command returning "04 BB" (the truncated UID from _ensure_selected's timed-out scan) instead
of its own response.

read_response/_read_any_response now set a _resync_needed flag on timeout, and the next
send_frame drains any late/stale input before writing. Cheap — only fires after a timeout
(rare on a healthy link) and no-ops when the stream is clean. Hardware-verified: identify ->
NTAG216, READ(4) 6/6 correct, GET_VERSION correct, with the hardening in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:35:21 -07:00
michael
a0b70a1984 feat(rawcli): command catalogs + hints for identified LF chips
catalog_for() returned None for LF, so after identifying a T5577/EM4100 the completer and
input_hint had nothing — no command suggestions, no hex/binary opcode hints. Now LF chips
get catalogs like the HF ones:

- T5577 catalog: READ(block) / WRITE(block, data) / WAKE(password) / DETECT(), with the
  downlink opcode exposed via build() for hex+binary completion, block-role hints (0=config,
  7=password, 1-6=data), and execution via a new run() path (LF isn't a raw-byte exchange —
  it drives lf.t55 / the demod). READ(0)/DETECT use the reliable rotation-fixed config read;
  data-block reads are best-effort and labelled as such. capture.read_t55xx_block added.
- LF read-only credentials (native EM4100/HID/AWID/FDX-B) get a minimal catalog (INFO -> re-read).
- catalog_for dispatches protocol "lf": "T5577" in the label -> T5577, else read-only.
- TagCommand gains an optional run(device, *args); completer/_opcode tolerate build=None.

Hardware-verified: identify -> catalog T5577, help lists the commands, DETECT()/READ(0) return
config 00148040 (EM4100). Tests: LF resolver, T5577 opcodes, T5577 block-role hints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:53:15 -07:00
michael
6c5409c111 fix(lf): T55xx config via rotation-to-preset match; label the emitted credential
Hardware-validated. Two real bugs in the T55xx config decode, both fixed by requiring a
bit-rotation of the recovered repeating word to match a known preset:

- Rotation ambiguity: a block read recovers the 32-bit config at an arbitrary bit offset.
  A real capture came back 0x000A4020 = the EM4100 word 0x00148040 rotated by one; the old
  "looks sane" scorer accepted the rotation verbatim and mislabeled it FSK1/RF-6. Now every
  rotation is tried and only a preset match (EM4100/HID/Indala/FDX-B/Viking/default) is
  accepted -> resolves to 0x00148040 = EM4100.
- False positives: garbage config captures (all-0, all-1, a rotation of the tag's own
  emission) no longer pass -> decode returns None and the reliable emitted-stream decode
  carries the result.

identify_lf label now names the chip + the actual decoded credential:
"T5577 (EM4100 00FFFFFFFF)" instead of the config's guess. On real hardware this is now
stable 4/4: config 00148040, emitted EM4100 00FFFFFFFF.

Tests: rotation recovery (0x000A4020 -> EM4100), no-false-positive on all-1s/random,
updated label expectation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:26:56 -07:00
michael
e82b94a490 fix(lf): identify_lf fails clearly on a raw async device (not "coroutine has no len")
capture drives the device synchronously (as Proxmark3.sync()'s proxy exposes it). Passing a
bare async Proxmark3() — whose methods return un-awaited coroutines, and which isn't even
connected — blew up deep in the demod with "object of type 'coroutine' has no len()". Now
identify_lf detects a coroutine-function device up front and raises an actionable TypeError
pointing at Proxmark3.sync().

The mock-based tests returned plain values, so they never exercised the async method shape;
added a test using a real coroutine function that reproduces and guards it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:58:30 -07:00
michael
e57e6ed268 feat(lf): force device debug quiet during identify
identify_lf now sets the device debug level to NONE (hw.dbg(0)) before its LF captures.
On debug-heavy firmware a raised g_dbglevel makes LF acquisition emit device-side Dbprintf
frames over USB, which interleave with the sample download and desync it. This is the
device-side knob (g_dbglevel), distinct from the C client's g_debugMode terminal spam;
quieting it removes a source of async frames during pm3py's own reads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:55:02 -07:00
michael
26da98ede6 feat(lf): validate + retry LF capture against a flaky NG link
On this device the NG link is intermittent — a read/download that returns clean samples
one minute comes back as desynced BigBuf memory the next, and lf.read()'s reported count
is sometimes garbage (so sizing the download off it over-reads into memory). Rather than
trust either, capture now:

- downloads a fixed safe span (_DOWNLOAD_BYTES) instead of the flaky read-count,
- validates the result is an envelope, not memory (rejects the 0xDEADBEEF stack canary,
  an embedded PM3 response magic, or a mostly-zero unwritten buffer),
- retries the read+download a few times, giving up cleanly to b'' if the device only
  returns memory (needs a power-cycle / lower debug level).

read_emitted/read_config go through this, so identify recovers from transient desyncs
instead of demodulating garbage. Tests: validator rejects memory patterns; capture retries
past a garbage download to the good one, and gives up when all attempts are garbage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:41:03 -07:00
michael
a06f4d12b8 fix(lf): download only the acquired sample count, not a fixed span
With the debug-frame fix the BigBuf download now returns clean sample frames (verified on
hardware: offsets 0..N, real envelope data, then ACK). But identify downloaded a fixed
15000 bytes while the acquisition captured 12288 samples (98304 bits / 8), so the tail was
BigBuf memory past the samples (heap / the 0xDEADBEEF stack canary) — which flagged as
"garbage" and poisoned the demod.

read_emitted now sizes the download to lf.read()'s reported count (firmware returns the
size in BITS; /8 = samples at 8 bits/sample); read_config downloads the readbl acquisition
span. No trailing memory -> the demod runs on clean samples.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:34:23 -07:00
michael
938a571155 fix(transport): skip async debug frames — the real cause of garbage LF reads
Debug-heavy firmware emits DEBUG_PRINT_STRING/INTEGERS/BYTES (0x0100-0x0102) frames
asynchronously, independent of any command. pm3py's read_response returned the FIRST
frame it saw, so a debug line landing mid-command was misparsed as the reply — observed
on hardware as garbage LF sample counts (e.g. 1593903105), a config-SET that "timed
out", and desynced BigBuf downloads (reading firmware memory / the 0xDEADBEEF stack
canary). The C client routes debug frames to its logger and keeps reading for the real
reply; pm3py did not.

read_response and _read_any_response now loop past DEBUG_PRINT_* (and malformed) frames
up to a bound, returning the actual command reply — for both NG replies and the OLD-frame
bulk-download stream. This is what makes LF read/download reliable on this device.

Tests: single + multiple interleaved debug frames are skipped to reach the real reply.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:17:33 -07:00
michael
f09946c4a3 fix(lf): flush stale input before bulk download (desync -> garbage/DEADBEEF)
A real download came back as BigBuf memory (0xDEADBEEF poison) with a PM3 response
magic (0x62334d50) embedded — the classic signature of a desynced read: the stream
had stale bytes (a prior partial transaction, or the C client having touched the
port), and the bulk reader consumes fixed 544-byte OLD frames that carry no magic to
resync on, so once misaligned it stays misaligned and reads memory as "samples".

transport.download_bulk now flushes the input first (reset_input_buffer + drain the
asyncio StreamReader until an 80ms quiet gap), so the transfer always begins byte-
aligned. Drains no-op when disconnected. Test asserts the flush runs before the request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:52:22 -07:00
michael
81625a0e93 feat(lf): faithful ASK demod (cleanAskRawDemod) for real-world envelopes
Real LF captures are asymmetric — sharp load-mod fall, slow RC recovery ramp, often
saturated rails — and a naive threshold + fixed-step resample drifts and bit-slips
across a frame (validated against a real T5577 dump: header aligned but 7/10 parity
rows failed). New pm3py/lf/ask.py ports the Proxmark ASK stack (lfdemod.c):

- signal_props (computeSignalProperties): rails + robust middle-80% mean.
- get_hilo (getHiLo 75%): clip thresholds 75% of the way mean->rail, giving hysteresis.
- clean_ask_raw_demod (cleanAskRawDemod): measure rail-to-rail wave widths, quantise each
  to a full- or half-clock symbol so the ramp between rails never triggers a false edge
  and every wave re-syncs the clock.
- trim_to_signal: drop the leading field-settle transient + trailing unfilled zeros that
  otherwise wreck thresholding/clock detection.

protocols._manchester_bits now delegates here, so EM4100 + T55xx-config demod get the
robust path over every raw-invert/Manchester-phase/polarity alignment (framing still
validates each candidate). Synthetic round-trips still green; the earlier blank/undecodable
T5577 correctly stays None (the C client couldn't read it either).

Real decodable-tag confirmation (EM4100 4C007B2FD5) pending the hardware dump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:46:41 -07:00
michael
b536294a7a fix(lf): read() acquired with the field OFF (SNIFF) — no tag was ever powered
lf.read() sent CMD_LF_SNIFF_RAW_ADC (passive sniff, reader field OFF), so it captured
only a dead field — a real hardware dump came back flat at 0x7e-0x80 (±1 count of ADC
noise) with no tag modulation, which is why LF identify found nothing. Reader-mode reads
must use CMD_LF_ACQ_RAW_ADC (SampleLF -> field ON) to power the tag and capture its load
modulation.

- read() -> CMD_LF_ACQ_RAW_ADC (field on); sniff() -> CMD_LF_SNIFF_RAW_ADC (field off),
  no longer delegating to read(). Shared _acquire() packs the real lf_sample_payload_t
  (PACKED bitfield samples:30/realtime:1/verbose:1 + cotag byte = 5 bytes) instead of a
  bare uint32.

Tests: read() uses ACQ with a 5-byte payload and correct samples field; sniff() uses SNIFF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:17:11 -07:00
michael
2f221644f9 fix(lf): BigBuf download spoke the wrong protocol — LF sample download hung
download_samples sent a DOWNLOAD_BIGBUF request per chunk and read replies with the
NG-only reader. But the firmware answers ONE request by streaming the data as OLD-format
CMD_DOWNLOADED_BIGBUF frames (no magic) then a single NG ACK (appmain.c) — so the NG
reader scanned for a magic that never came and timed out (hardware repro: lf.read() ok,
download_samples -> Response timeout). This blocked all LF identify on real hardware.

- transport.download_bulk: send the request once, then read with the existing NG/OLD-
  agnostic reader, accumulating CMD_DOWNLOADED_BIGBUF payloads (trimmed to oldarg[1], not
  the 512-byte padding) until the ACK or `count` bytes; drains the trailing ACK so it
  can't poison the next command. Reusable for EML/SPIFFS/flashmem bulk reads.
- download_samples: one call into download_bulk instead of the broken per-chunk loop.

Tests: count-terminated + ACK-terminated streams, oldarg-length trimming, single request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:11:11 -07:00
michael
5dd8e086cc fix(lf): per-protocol identify label (native HID/AWID/FDX-B no longer KeyError)
identify_lf built its label from emitted['id_hex'], which only EM4100 provides — a
native HID/AWID/FDX-B tag (no T5577 config) would KeyError. Add credential_label() to
format each decoder's own fields: "HID Prox H10301 FC.. Card..", "AWID-26 FC.. Card..",
"FDX-B 124-000000000555 (animal)", "EM4100 <id>". Tests for native FDX-B + HID labels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:00:14 -07:00
michael
42d5f800cb feat(lf): AWID FSK decode (parity-validated Wiegand)
protocols.decode_awid: FSK2a (no Manchester) via the ported detectAWID path, then
removeParity — the 88 bits after the preamble are 22 groups of 3 data + 1 odd-parity
bit, so stripping parity gives 66 bits whose first byte is the format length
(26/34/36/37/50) and the rest the facility/card fields (cmdlfawid.c index map). The 22
parity checks stand in for AWID's missing CRC, so it can't false-positive. Added to the
identify decoder chain.

Tests: AWID-26 round-trip across fc/card, parity gate rejects noise/EM4100. Full suite green.

LF stack now covers EM4100, HID, AWID, FDX-B + T55xx config. Indala (PSK) queued — the
one path needing real PSK phase-tracking (DetectPSKClock/pskRawDemod), best tuned against
a hardware capture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:58:38 -07:00
michael
7e8d924045 feat(lf): FDX-B (ISO 11784/85) biphase demod + CRC-validated framing
- dsp.biphase_raw_decode: faithful port of lfdemod.c BiphaseRawDecode (pairwise
  differential-Manchester decode with phase-fault offset nudge + error markers).
- protocols.decode_fdxb: ASK + biphase, find the 00000000001 preamble, de-interleave
  the 8-bit data groups (every 9th bit a control 1), reassemble the 10-bit country +
  38-bit national code. Accepted only when the CRC-16 over the eight data bytes matches
  the stored one, so it can't false-positive. Both biphase polarities tried.
- protocols.crc16 / crc16_fdxb: parametric bitwise CRC-16 (Rocksoft model) matching the
  firmware crc16_fast; FDX-B is poly 0x1021, init 0, refin=false, refout=true.
  Added to the identify decoder chain (FDX-B is common in implants).

Tests: FDX-B round-trip (country/national/animal), CRC gate rejects noise/EM4100, and
a CRC known-answer test pinning the impl to CRC-16/XMODEM (0x31C3) and KERMIT (0x2189).
Full suite green.

Indala (PSK) + AWID queued.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:55:45 -07:00
michael
e4a3aec46f feat(lf): FSK stack + HID Prox demod (faithful lfdemod.c port)
New pm3py/lf/fsk.py — a function-for-function Python port of the Proxmark FSK
stack (firmware/common/lfdemod.c): fsk_wave_demod (count samples between 0->1
transitions: short wave = fc/8, long = fc/10) + aggregate_bits (runs -> clock-
resolution bits) = fskdemod, plus preamble_search, HIDdemodFSK, detectAWID hooks.
Ported to track the C reference (and thus real-tag output), not reinvented.

protocols.decode_hid: FSK2a + Manchester -> Wiegand. Format is told by frame
structure, not parity alone — the 26-bit H10301 wire frame carries a 0x20 header
at bit 37 + a sentinel 1 at bit 26 (per firmware add_HID_preamble); the 37-bit
H10304 frame is header-less (37-bit Manchester count, no 0x20). Each candidate is
still parity-validated by re-encoding through the shipped HIDProxTag, so a false
alignment can't slip through. Added to the identify decoder chain after EM4100.

Synthetic HID tests build the real add_HID_preamble framing (header+sentinel for
26-bit, bare 37-bit) and render fc/8/fc/10 square waves, so a green test means the
demod inverts what a real HID card / PM3 sim emits. Full suite 1232 green.

AWID (96-bit) framing decode + PSK/Indala + biphase/FDX-B queued next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 14:14:54 -07:00
michael
3c56d40667 feat(lf): Python LF demod stack — ASK/EM4100 + T55xx config; wire into identify
The firmware returns raw ADC envelope samples for LF reads (T55xx readbl replies
PM3_SUCCESS with NULL data); demodulation was always the C client's job, so pm3py
had none and LF identify could never see a tag. This adds the missing DSP stack in
Python — new pm3py.lf package:

- dsp.py: modulation-agnostic primitives — robust threshold, edge-interval clock
  recovery, ASK binarize, half-bit resample, Manchester + biphase decode.
- protocols.py: EM4100 (ASK/Manchester -> 40-bit ID, validated by EM4100Code's own
  header/row/col/stop parity checks) and best-effort T55xx block-0 config read
  (find the repeating 32-bit word, score by T5577Config sanity, name the emulation).
- capture.py: live-device orchestration — read the emitted stream + probe a T55xx via
  block-0 read, combine into "is it a T5577, and what is it (emulating)?".

rawcli identify now demodulates LF instead of the dead readbl-only probe: reports
"T5577 (EM4100 / EM4102)" for an emulator, "EM4100 <id>" for a bare credential.

Fully self-testing without hardware: the LF transponder models already encode these
protocols, so a synthetic envelope built from an encoder round-trips through the
demod. 17 demod tests (EM4100 across IDs/rates/mid-frame/noise, T55xx config, mocked
identify) + updated rawcli LF tests. Full suite 1225 green.

FSK/HID + PSK/Indala + biphase/FDX-B decoders queued (same dsp primitives).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:58:51 -07:00
michael
8726681e06 feat(rawcli): LF T5577 identify + clear stale result per identify
- identify clears the previous field/protocol/transponder up front, so
  consecutive identifies (esp. when swapping tags) never show stale data.
- LF identify: probe a T5577 by reading config block 0 (lf.t55.readbl(0)).
  A valid config confirms the T5577 and, decoded via T5577Config, names
  what it's emulating — "T5577 (EM4100 / EM4102)", "T5577 (HID Prox)", etc.
  (exact-word map, else modulation family). Tightly gated (status ok,
  non-trivial config, known modulation, sane max_block) so no-tag reads
  don't false-positive; lf.search() couldn't do this without demod.
- format_summary shows the config word for LF (no UID).

4 tests (T5577 emulation report, no-T5577, stale-clear).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:31:35 -07:00
michael
0775df4d54 fix(rawcli): identify false-positives on 15693/LF; retry 14a
With only a MIFARE Classic present, identify randomly reported ISO15693
(bogus UID) or "LF tag" — because a flaky-link 14a miss fell through to
lenient checks:
- 15693 accepted any `uid` even without `found`; now requires found AND a
  real E0-prefixed UID (a 14a miss leaves non-E0 garbage).
- LF reported a tag whenever lf.search() returned its (always-truthy)
  sample-capture result; it can't confirm a tag without demod, so drop it
  from identify (reliable LF detection is a follow-up).
- Retry the 14a scan (up to 3x) so a transient miss doesn't fall through.

3 tests (E0-gated 15693, bogus-UID + false-LF rejection).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:26:38 -07:00
michael
c04e50efd0 fix(rawcli): re-select per command + auto-CRC (as needed)
Live-hardware fixes:
- Commands after identify returned nothing because the tag selection went
  stale (and a prior no-CRC frame could drop it). Re-select the tag before
  each command (silent — not traced) via _ensure_selected(). Note: this
  resets auth, so multi-step PWD_AUTH needs a later keep-session mode.
- Auto-append the ISO14443-A CRC on 14a frames so a bare `60` works —
  except the frames that carry no CRC (REQA/WUPA short frames, 0x9x 0x20
  anticollision), via _needs_crc(). Applies to catalog commands and manual
  raw alike.
- Drop the now-redundant GET_VERSION padding trim (raw() trims properly).
- help text: function-style commands are here, not "next phase".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:22:40 -07:00
michael
7b5af3f9d2 fix(14a): raw() returns whole firmware buffer instead of received bytes
hf.iso14a.raw() returned the entire receive buffer the firmware replies
with (sizeof(buf)), so a tag that didn't answer came back as a run of
zeros (~40 bytes) rather than empty. The firmware puts the real received
length in oldarg[0]; trim resp.data to it. No response -> empty (raw=b"",
data=None); adds a `received` count. 2 regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:22:39 -07:00
michael
2f9e825bd4 feat(rawcli): colorized breadcrumb prompt
Color-code the prompt so the session state reads at a glance, using the
trace formatter's palette so prompt and trace look like one UI:

  [ raw / hf / NTAG213 / 0x ]
   dim  ^bold ^cyan  ^bold-green  ^yellow(hex) | magenta(binary)  dim

- bold `raw`; cyan RF field; bold-green identified transponder; entry
  mode yellow for hex, magenta for binary (so the base is obvious and
  flips color on Ctrl-/); dim brackets/separators.
- session.colored_breadcrumb() returns the ANSI form; breadcrumb() stays
  plain (stripping the ANSI yields it — asserted). Prompt message renders
  it via ANSI().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:11:47 -07:00
michael
92b4e8771d fix(rawcli): writes take (location, data); multi-frame + CRC
- A write needs both a location and data. NTAG WRITE (0xA2) already did;
  the two-phase 0xA0 writes (Type 2 COMPAT_WRITE, MIFARE Classic WRITE)
  only took the page/block. They now take (page/block, data) and build a
  frame *list* — the command frame plus the 16-byte data frame.
- Catalog build() may now return a list of frames; _handle_call sends each
  in sequence and traces every exchange (so both phases of a write show).
- 14a catalog commands now append the ISO14443-A CRC (ISO14A_APPEND_CRC),
  which real tags require — manual raw hex is still sent verbatim.

3 new/updated tests (two-phase build + send, CRC flag on reads).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:07:50 -07:00
michael
cecfc03ba7 feat(rawcli): live relevance hint — per-transponder memory map
Surfaces what the input *means* on the identified tag, in a bottom-bar
hint that updates as you type (the point, over aggressive autocomplete):

- memory.py: page_role(transponder, page) maps a page/block to its role
  on the specific IC — UID, Capability Container, user memory, dynamic
  lock, CFG0/AUTH0, CFG1/ACCESS, PWD, PACK. Layouts mirror the models'
  page attributes (NTAG210/212/213/215/216, Ultralight EV1); a test
  guards against drift.
- input_hint(session, text): the command's purpose, and — once a page
  argument is typed (even partial, hex or decimal) — where that page
  lives. e.g. "READ(4)" -> user memory, "READ(0x2B" -> PWD,
  "WRITE(0x29" -> CFG0/AUTH0.
- app: wired as the prompt's bottom_toolbar.

11 new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:02:59 -07:00
michael
1370526883 fix(rawcli): raw response crash + hex/binary opcode completion
- Crash on raw hex: reader raw() returns `data` as a hex STRING; the raw
  path fed that to bytes() -> "string argument without an encoding".
  _raw_exchange now returns the bytes (`raw` field, or converts `data`);
  render_exchange accepts bytes or a hex string.
- Completion for numeric entry, transponder-dependent: typing hex ("60")
  or binary ("01100000") now matches the identified tag's command opcodes
  and surfaces the named command (GET_VERSION) with its help. Honours a
  0x/0b prefix and the current entry mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:00:31 -07:00
michael
7703b638f3 fix(rawcli): drop extra blank line between trace lines
The trace formatter's line now carries a trailing newline; rawcli was
stripping only a leading one (.lstrip), so the trailing \n survived and
the printer added another -> a blank line between every trace line (and a
\n\n join inside render_exchange). Use .strip("\n") so it's correct
whether the formatter emits a leading or trailing newline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:52:33 -07:00
michael
41db552e05 fix(rawcli): render ANSI, stop mangling commands, fuller Type 2 catalog
Three fixes from live hardware use:

- Colors/ANSI now render. Output routes through prompt_toolkit's
  print_formatted_text(ANSI(...)) instead of print(), which showed the
  escape codes literally under patch_stdout. Formatters force is_tty so
  the printer decides color-vs-plain. dispatch()/handlers take an `out`.
- Auto-byte-spacing no longer mangles non-hex input. byte_space() only
  regroups a *pure* hex/binary run; command words ("help transponder",
  "identify", "close") and 0x/0b-prefixed tokens are left intact (a-f in
  words used to trigger regrouping -> failed hex parse).
- Type 2 catalog gains PWD_AUTH (0x1B), COMPAT_WRITE (0xA0), HALT — the
  NTAG213 set was missing password auth.
- identify trims the firmware buffer padding off the GET_VERSION response
  (was dumping ~40 trailing 00 bytes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:50:01 -07:00
michael
6b4a4551d4 feat(rawcli): deep, traced identify — exact model via GET_VERSION
`identify` now probes and shows its work instead of a coarse SAK guess:

- Streams every probe exchange to the trace (proxmark-style annotated):
  the reconstructed 14a activation (WUPA/ATQA/anticollision CL1+CL2/
  SELECT/SAK) plus a REAL GET_VERSION (0x60 +CRC) sent over the persistent
  connection.
- Names the exact IC from GET_VERSION: an NTAG216 now reports "NTAG216",
  not "MIFARE UL/NTAG". Exact map mirrors the transponder models'
  _version_bytes (static, to dodge an import-order circular; a test guards
  against drift). Unknown chips fall back to product family + an honest
  memory-size *range* (AN10833 storage-byte encoding), not a bogus size.
- SAK fallback table + GET_VERSION product-nibble map aligned to NXP
  AN10833 (MIFARE type identification procedure).

11 tests (exact/structural decode, map-model sync, traced probe, SAK
names). GET_VERSION is a real device exchange — the user's hardware test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:40:41 -07:00
michael
5f57cc4202 build(rawcli): make prompt_toolkit a core dependency + document rawcli
`pm3py rawcli` is a headline tool, so prompt_toolkit moves from the
[rawcli] extra into core dependencies — a plain `pip install` gets it and
the command just works. The extra is removed; the missing-module guard now
points at a reinstall (it only fires on a broken env).

README: add the rawcli feature bullet, a dedicated "pm3py rawcli"
section (breadcrumb, entry toggle, identify, function-style commands, live
trace, TLV, completion), prompt_toolkit in the dependency list, and cli/
in the architecture tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:25:52 -07:00
michael
3e7f47a740 fix(rawcli): Ctrl-/ key binding crashed launch ("Invalid key: c-/")
prompt_toolkit doesn't accept "c-/". Ctrl-/ is emitted as Ctrl-_ (0x1F)
by terminals, so bind "c-_" (plus "c-t" as an always-available fallback)
for the hex/binary toggle. install_key_bindings() now ignores any key
name a prompt_toolkit build rejects, so a bad key can never crash launch.
Regression test added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:25:36 -07:00
michael
ae3fd3d4ea fix(rawcli): friendly message when prompt_toolkit isn't installed
`pm3py rawcli` lives behind the optional [rawcli] extra; a plain install
of pm3py doesn't pull prompt_toolkit. Catch the ModuleNotFoundError and
print an install hint (pip install 'pm3py[rawcli]') with exit code 2,
instead of dumping a traceback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:21:20 -07:00
michael
f76663f798 feat(rawcli): ipython-style completion (Phase 6)
- completer.py: RawCompleter completes the leading command token —
  control verbs + the identified transponder's catalog commands — with
  each command's help as the completion meta (tooltip). `help <cmd>`
  completes catalog command names.
- app: wire the completer with complete_while_typing (live menu) +
  AutoSuggestFromHistory (inline hints) for the ipython feel.

Completes the 6-phase rawcli plan. 4 new tests (verb/catalog completion,
tooltips, help-arg completion).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:19:42 -07:00
michael
4e7181d289 feat(rawcli): TLV/NDEF formatting + multi-line editing (Phase 5)
- tlv.py: parse_tlv() walks a Type-2 TLV block (NULL/LOCK/MEM/NDEF/PROP/
  TERMINATOR, incl. 3-byte 0xFF length); format_tlv() renders an indented
  multi-line breakdown and annotates NDEF messages via the existing
  decode_ndef_annotation. prompt_tlv() opens a multi-line editor to
  compose a TLV as hex across lines.
- app/parser: `tlv <hex>` decodes + prints the breakdown; bare `tlv`
  opens the multi-line editor.

5 new tests (structure, extended length, multi-line format, command).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:15:54 -07:00
michael
9c84f9e7b0 feat(rawcli): command catalog + function-style commands (Phase 4)
- catalog.py: declarative per-transponder command sets (name, params,
  build(args)->bytes, help). Seeds: Type 2 (NTAG/UL: READ, FAST_READ,
  GET_VERSION, READ_SIG, READ_CNT, WRITE), MIFARE Classic (READ/WRITE/
  HALT), ISO 15693 (INVENTORY, READ_BLOCK, WRITE_BLOCK, GET_SYSTEM_INFO).
  catalog_for(session) resolves by protocol + identified transponder.
- app: function-style calls (READ(4) -> build -> raw exchange ->
  annotated trace); catalog-aware help (`help` lists the current tag's
  commands, `help READ` shows one). Bad args print usage.

9 new tests (catalog builds, resolver, call dispatch, help). Command
knowledge is declarative + hardware-free-testable; the exchange is the
user's device test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:13:14 -07:00
michael
e024294ec6 feat(rawcli): identify + persistent connection (Phase 3)
- identify.py: probe HF (14a -> 15693) then LF; set session field /
  protocol / transponder. The 14a scan uses NO_DISCONNECT so the tag
  stays selected (the persistent connection). SAK -> coarse family name
  for the breadcrumb.
- session: add `protocol` (drives raw-exchange routing)
- app: connect via Proxmark3.sync() (scan/raw become plain calls); wire
  identify / transponder / close control commands; protocol-aware
  _raw_exchange; breadcrumb fills in field + transponder after identify

8 new tests (identify 14a/15693/none, clear, control commands). Device
probes are mocked; live identify is the user's hardware test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:10:51 -07:00
michael
54e52f029f feat(rawcli): flexible entry + parser (Phase 2)
- parser.py: classify a line into control / function-call / raw payload;
  parse loose hex/binary with intermixed 0x/0b tokens (whitespace-
  insensitive, defaulting to the session entry mode)
- entry.py: byte_space() byte-group formatting + key bindings — Ctrl-/
  toggles hex/binary entry mode (breadcrumb updates), digits auto-space
  into byte groups as you type
- app.py: dispatch() drives the loop via parse_line (testable without a
  live prompt); identify/call are stubbed for Phases 3/4

14 new tests (parser tokens/intermix/errors, byte_space, dispatch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:07:21 -07:00
michael
028ce9c21e feat(rawcli): interactive raw-command TUI skeleton (Phase 1)
First slice of `pm3py rawcli` — the interactive, annotated raw-command
tool (separate from pyws and from the future `pm3py client` CLI).

- pm3py/cli: a top-level `pm3py` console-script dispatcher (argparse)
  with a `rawcli` subcommand; prompt_toolkit gated behind a [rawcli] extra
- rawcli/session.py: RawSession — device/field/transponder/entry-mode
  state + the segmented breadcrumb `[raw / hf|lf / $tag / 0x|0b]`
- rawcli/trace_view.py: render a reader<->tag exchange as annotated,
  Proxmark-style trace lines, reusing TraceFormatter + the 14a/15693
  decoders
- rawcli/app.py: minimal PromptSession loop (connect best-effort,
  breadcrumb prompt, help/quit, raw-hex -> annotated exchange)

Entry-mode toggle, identify/connection, the command catalog, and TLV
editing follow in later phases. 9 hardware-free tests (session, trace
render, CLI dispatch); device I/O is the user's local hardware test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:59:29 -07:00
michael
5d4af2c8e4 feat(hitag): Hitag 2 software-sim transponder + reader (Tier 1)
First piece of Hitag support. Adds the Hitag2 48-bit stream cipher and a
software-sim transponder/reader over SoftwareMedium (parity with the
other LF tags).

- hitag2_crypto.py: pure-Python port of the Proxmark reference cipher
  (firmware tools/hitag2crack/hitag2_gen_nRaR.py). Validated bit-for-bit
  against the classic MIKRON golden vector (keystream 0xD7237FCE, full
  16-byte sequence). Hitag2Cipher wraps a running session (bits() for the
  authenticator, transcrypt() for link encryption).
- hitag2.py: Hitag2Tag (8 x 32-bit pages, UID/key/config/password) and
  Hitag2Reader. Faithful UID request (5-bit START_AUTH), password-mode
  auth (page-1 RWD password), and crypto-mode auth (64-bit nR||aR + aR
  verify + transcrypt). Wrong key is rejected; UID page is read-only.

Software-sim command framing between our own tag/reader; the crypto and
auth handshake are bit-faithful to real tags. 12 tests.

Hitag S / Hitag u, the reader client commands, and sniff/trace decode
follow in subsequent commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:34:24 -07:00
michael
130bcd1a90 feat(registers): ST25DV RF_Ai_SS area-security register
Add St25dvRfAreaSS (Register) for the ST25DV per-area RF security
registers (RFA1SS..RFA4SS): pwd_ctrl (which RF password gates the area)
and rw_protection (read/write protection level). Bound via
tag.rf_area_ss(area) / tag.set_rf_area_ss(area, value). Modelled from
the model's own tested protection logic (single source of truth) and
cross-checked against _read_allowed / _write_allowed. 3 new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:05:55 -07:00
michael
f9e56e32ef feat(registers): ICODE feature-flags decoder (GET NXP SYSTEM INFO)
Add IcodeFeatureFlags (Register) for the 16-bit ICODE feature word
reported by GET NXP SYSTEM INFO (ICODE 3 Table 113; DNA/SLIX2 report a
subset). Read-only capability decoder — decode a captured word to see
what a tag supports. Fields mirror the existing _FEATURE_* constants
one-to-one (asserted in a test). 3 new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:01:52 -07:00
michael
e795b5bb1d feat(registers): NXP AES key-privilege register (ICODE DNA / NTAG5)
Add NxpKeyPrivileges (Register) for the Table 5 AES key-privilege byte
and bind it on the NxpAesAuth mixin (ICODE DNA + NTAG5 Link/Boost):

- read / write / privacy / destroy / eas_afi / crypto_config /
  area1_read / area1_write, each a documented bit
- NxpKeyPrivileges.build(read=True, write=True, ...) keyword builder
- tag.key_privileges(key_id) / tag.set_key_privileges(key_id, value)

Additive: the existing PRIV_* constants and has_privilege() path are
unchanged and see the same bytes. 5 new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:58:24 -07:00
michael
f8d01a9120 feat(registers): NTAG5 STATUS_REG / CONFIG_REG self-describing views
Add Ntag5StatusReg and Ntag5ConfigReg (Register subclasses) and bind
them as tag.status_reg / tag.config_reg on the NTAG 5 platform. Purely
additive: every existing per-field accessor (disable_nfc, arbiter_mode,
sram_enable, ...) stays, and the config_reg setter re-syncs the
_arbiter_mode / _sram_enabled sibling caches the per-field setters keep,
so the sim internals are untouched.

- Ntag5StatusReg (0xA0): field/VCC/boot/lock/EEPROM/SRAM status bits
- Ntag5ConfigReg (0xA1): disable_nfc, arbiter_mode (normal/mirror/
  pass-through/PHDC), sram_enable, config_pt_transfer_dir

Bit layout taken straight from the existing hand-rolled accessors, so
the register view decodes the very same bytes (cross-checked in tests).
5 new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:54:24 -07:00
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
michael
2063a33a2b feat(registers): self-describing config/frame registers
Add a BitField descriptor + Register base and per-IC config/frame
registers so opaque words decode themselves and build correctly:

- T5577Config          LF block-0 config word, mode-dependent data rate
- EM4100Code           64-bit frame + all parities (EM4102/EM4200 too)
- Type2Config          shared NXP CFG0/CFG1 base (auth0/prot/cfglck/authlim)
- Ntag21xConfig        + mirror / MIRROR_PAGE / NFC counter fields
- UltralightEV1Config  EV1 CFG0/CFG1
- UltralightCConfig    UL-C AUTH0 + AUTH1 protect scope
- MifareAccessConditions  per-block C1/C2/C3 -> English, inverted-copy
                          validation, build() that fills the inverted copy

Fields are documented attributes, so `<tab>` completes them in the shell
(bpython/ipython show each field's doc as the tooltip) and `repr` prints
the decode table. Bound to tags via `tag.config` / `tag.access(sector)`
/ `tag.code`. Bit layouts cross-checked against the firmware submodule
(cmdlft55xx, mifare4.c) and the NXP/EM datasheets. 40 new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:36:40 -07:00
michael
3aabb64402 pyws plugin P2: live sim resource
- Sim facade (bound as `sim`): start(tag) arms the sim, dispatching 14a/15693 by tag
  type; stop(), push(tag) -> tag.sync(), frames -> decoded trace
- SimResource: opens SimSession on load, closes on exit; a missing device is non-fatal
- one connection mode per workspace (reader OR sim) — the second is refused (serial contention)
- sim.push added to the write effect patterns (withheld on autoreplay)

Arming stays explicit. Hardware-free tests via injected sessions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:50:02 -07:00
michael
042f49a9ec fix(sim): bound 14a sim abort latency + detect teardown host-side
Bump firmware pin to 3b6201d97, which bounds the 14a sim button / host-stop
latency under continuous reader traffic (was multi-second, now ~10ms).

SimSession now treats an HF_MIFARE_SIMULATE (0x0610) reply as end-of-sim for
14a: the sim starts with HF_ISO14443A_SIMULATE but the firmware tears down
with HF_MIFARE_SIMULATE, so _trace_reader and _relay_loop watch for it and
flip _active off on a button-driven stop (previously the host never noticed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:04:44 -07:00
michael
829f254194 Add pyws workspace plugin (P1: reader resource + namespace + effects)
Exposes pm3py as a pyws workspace, discovered via the pyws.plugins entry point:
- ReaderResource: connects/disconnects a Proxmark3 as `reader`; a missing device is
  non-fatal (reports disconnected)
- namespace injection of the transponder/sim classes, SimSession and Proxmark3
- effect declarations so autoreplay/replay withhold writes/field/sim/destructive while
  reads and pure model edits replay

Hardware-free tests via AsyncMock. The live `sim` resource is P2.
See docs/plans/2026-07-05-pm3py-pyws-plugin-plan.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 07:56:13 -07:00
michael
0ba5256d02 docs: Pi install + pm3flash firmware flashing (README, CLAUDE, roadmap)
- README: rewrite Install (from source, submodules, deps, Pi/aarch64); new
  'Flashing firmware (pm3flash)' section — CLI usage, --build, image resolution,
  cross-compile + PLATFORM note, Raspberry Pi bring-up, programmatic API; point
  Firmware Compatibility at it; add a License section (MIT).
- CLAUDE.md: flash.py/_firmware.py/flash_cli.py in the structure; a 'Firmware
  flashing' section with the maintainer gotchas (OLD frame, fullimage-only,
  page-merge, platform != is_rdv4, the pin).
- roadmap: new 2026-07-05-firmware-flasher plan doc + a Firmware-flashing row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:20:15 -07:00
michael
c0ae7b90c8 feat(flash): pm3flash --build PLATFORM (build from the submodule, then flash)
build_firmware()/find_firmware_src() cross-compile fullimage.elf via
'make -C firmware PLATFORM=<explicit> armsrc/all' and pm3flash --build flashes it.
PLATFORM is explicit (never inferred from is_rdv4 — that's the running firmware's
flag, not the board).

Hardened per an adversarial multi-agent review of the change:
- --build now implies --force: it must flash the image it just built. A dirty
  rebuild keeps the same base SHA, so matches_pin can't distinguish it from the
  old firmware and would otherwise silently skip the flash (exit 0).
- make missing/not-executable -> FlashError (was an uncaught OSError traceback).
- find_firmware_src no longer falls back to a CWD-relative ./firmware — running
  make on a stray Makefile is arbitrary code execution; only explicit arg /
  $PM3PY_FIRMWARE_SRC / the package repo root. Suite 1045 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:20:04 -07:00
michael
9f926a176c fix(flash): merge segments sharing a flash page
Found on the first real-hardware flash (PM3 Easy). The fullimage ELF has two
adjacent PT_LOAD segments (seg1 ends at 0x15E5AC, seg2 starts there) that share
the 0x15E400 flash page. build_blocks aligned each segment independently, so the
shared page was written twice — seg2's 0xFF-padded head, written last,
erase-programmed the page and wiped seg1's real bytes there (the SAM7 programs a
whole page atomically). That region holds version_information, so the flashed OS
booted but reported 'Missing/Invalid version information'.

Fix: merge segments whose block-aligned spans touch into one buffer, so every
shared page is written once with both segments' real data; 0xFF only fills gaps
and aligned outer edges. Re-flash verified on hardware: 3df2ed2e8 -> 303bd5873,
valid version, matches_pin true. Bootrom untouched throughout (fullimage-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:59:31 -07:00
michael
b0b46b3919 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>
2026-07-05 21:37:46 -07:00
michael
762e305dd9 feat(flash): pure-Python firmware flasher over the OLD-frame protocol
Drives the full flash flow over the existing USB-serial link — enter
bootloader, arm window, stream the ELF block-by-block, reset — with no
external pm3-flash/DFU/JTAG.

- core/flash.py: Flasher (device_info, auto OS->bootloader handover,
  flash_image block loop with ACK/NACK+EFC status, reset, end-to-end flash);
  ELF32-ARM PT_LOAD parsing; 0xFF-padded block alignment; image resolver.
- core/transport.py: OLD 544-byte frame encode/decode, send_old /
  send_old_no_response, magic-branching frame reader, reopen() for USB
  re-enumeration.
- core/protocol.py: DEVICE_INFO flags, START_FLASH_MAGIC, flash geometry,
  BL version helpers.
- Wired as pm3.flasher; reachable through the sync proxy.

Safety: fullimage-only by default; bootrom region refused unless
allow_bootrom=True; every block bounds-checked against the flash window.
Verification is per-block ACK/NACK (as the C flasher does); read-back verify
and hardware validation are follow-ups. 22 tests; suite 1026 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:33:01 -07:00
michael
42a9146e6c chore(packaging): MIT license + consolidate metadata into pyproject
- Add MIT LICENSE (PEP 639 'license = "MIT"' + license-files).
- Move all metadata into a [project] table (description, readme, authors,
  keywords, classifiers); drop the now-redundant setup.py.
- Declare bitarray and pycryptodome: used by pm3py.sim and the transponder
  crypto paths but previously undeclared, so a clean install failed on the
  README's own 'from pm3py.sim import SimSession'.
- gitignore build/ and dist/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:32:45 -07:00
michael
6e77beea1e docs: firmware-fork DT-Gitea migration runbook + roadmap entry
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:29:18 -07:00
michael
14844bc700 chore(firmware): bump submodule to live-sim EML host-poll (303bd5873)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:29:18 -07:00
michael
fb3dc5cd68 feat(sim): load 14a tag memory into emulator RAM (mfu_dump build + tag.sync)
- NfcType2Tag._build_mfu_dump(): assemble the firmware mfu_dump_t (56-byte prefix
  version/pages/signature + page data) so the sim serves real memory (UID/CC/NDEF/
  PWD/PACK). Firmware is data-driven for tagtype 2/7, so an NTAG213 dump presents
  correctly under tagtype 7.
- NfcType2Tag.sync(): push the image via CMD_HF_MIFARE_EML_MEMSET (same primitive
  as the client's hf mfu esetblk), chunked. Updates a live sim with the fw host-poll.
- start_14a: load the dump before HF_ISO14443A_SIMULATE so the reader sees real
  page data instead of an empty buffer (works on current fw; no reflash for this).
- fix: set_ndef/clear_ndef cleared memory to the end, wiping the lock/config/PWD/
  PACK pages on NTAG parts. Bounded to the CC-declared NDEF area now.
- tests: mfu_dump layout, config-page preservation, EML_MEMSET framing/chunking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:29:12 -07:00
michael
3123d4bc6a fix(submodule): repoint firmware at git.dngr.us (was dead GitHub URL)
The .gitmodules url pointed at github.com/dangerous-tac0s/proxmark3-pm3py, which
lacks our commits and the pinned gitlink — fresh recursive clones broke. Point it
at the real fork on the DT Gitea over SSH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:35:02 -07:00
michael
615d4a6115 docs: roadmap index + custom-14a-command plans + backfill plan docs
- Add docs/plans/README.md as the roadmap index cataloguing all design/plan docs.
- Add the custom ISO14443-A command handling design + plan (L3 NTAG I2C
  SECTOR_SELECT / cross-sector reads + native auth; L4 static APDUs + WTX relay),
  produced from a multi-agent design workflow.
- Backfill previously-uncommitted plan docs (trace-formatter, ndef-trace-decode,
  live-sniff, firmware-upstream-rebase, 14a-live-trace) and NTAG5_SECURITY.md.
- Sync CLAUDE.md package structure with the committed transponder models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:58:48 -07:00
michael
a24f0f803f feat(sim): reject wrong-protocol tags in start_14a/start_15693; export tags; uid getter
- start_14a now raises TypeError for a non-Tag14443A transponder, start_15693
  likewise for a non-Tag15693 (local imports to avoid the base<->sim circular
  import). Prevents silently starting a sim with an incompatible tag model.
- Re-export the full 14a/14b/15693 tag lineup (NTAG21x, Ultralight family, NTAG
  I2C, ST25*, OPTIGA, ISO14443B, TI parts) from pm3py.sim for `from pm3py.sim
  import NTAG213`-style use.
- Add a read-only `uid` property on the Transponder base (mirrors set_uid()).
- Tests for both guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:58:37 -07:00
michael
77ff1bb076 chore(firmware): bump submodule to 14a sim-trace fixes (3df2ed2e8)
Full-drain flush + EmLogTrace central answer capture (READ/FAST_READ/ACK/NAK now
traced). Builds clean for PM3GENERIC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:58:23 -07:00
michael
9843ecdc30 chore: ignore .venv/.claude/.vscode/.mcp.json, untrack committed pyc
.gitignore had `venv/` but the project's virtualenv is `.venv/`; add it plus
local-only agent/editor state so a `git add -A` can't sweep them in. Also stop
tracking four .pyc files committed before the __pycache__ ignore rule existed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:57:56 -07:00
michael
dc626faa60 Big transponder support update 2026-07-05 09:53:52 -07:00
michael
950628ef4e feat(sim): ISO14443-A live sim trace consumer (Y1-Y3 + T1)
Y1: Cmd.HF_ISO14443A_SIM_TRACE (0x0339).
Y2: generalize SimSession._trace_reader dispatch to a {cmd: protocol} map so
    14a (proto 1) and 15693 (proto 0) share one reader; the ADC field-strength
    branch is now guarded to 15693.
Y3: rewrite start_14a as a synchronous trace path mirroring start_15693 (raw
    pyserial + the _trace_reader thread). Sends the correct HF_ISO14443A_SIMULATE
    NG payload (same layout as hf.iso14a.sim()), sets FLAG_SIM_TRACE when
    streaming, and takes trace/on_frame/quiet/tagtype/exit_after kwargs. Loading
    tag memory into emulator RAM stays a separate (hardware) step; the trace
    streams every reader<->tag frame regardless. The WTX _relay_loop is retained
    for the future 14a-4 path.
T1: mocked-transport unit tests for the 14a trace path (tests/test_sim_trace_14a.py).

Requires the bumped firmware submodule. 996 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:36:39 -07:00
michael
64cc6794d9 chore(firmware): bump submodule to 14a sim-trace firmware (14655dbb4)
Lands F1 (CMD_HF_ISO14443A_SIM_TRACE 0x0339 + FLAG_SIM_TRACE) and F2+F3
(trace ring + hooks in SimulateIso14443aTagEx). Builds clean for PM3GENERIC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:36:20 -07:00
michael
d2ac64f100 chore(firmware): bump submodule to LED-ported firmware (39c4ef319)
Adds CMD_LED_CONTROL (0x011B): payload_led_control_t + PWM/effect helpers + appmain dispatch, on top of the rebased 5530c1269.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 02:04:37 -07:00
michael
b87f12253d feat(hw): repoint Cmd.LED_CONTROL to 0x011B (firmware LED port), hw.led now works
The firmware fork now provides CMD_LED_CONTROL at 0x011B (payload_led_control_t). hw.led already packs the matching <BBBHH> struct + action codes, so repointing the id makes it functional. Adds Cmd.SET_HF_FIELD_TIMEOUT (0x011A, upstream's use of that slot). README caveat updated: hw.led works with the fork, no-op on stock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 02:04:37 -07:00
michael
21107e8de7 docs: align sniff entries dict shape with data_hex/decoded fields
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 01:52:00 -07:00
michael
fa31c3ad1a docs: document scriptable trace access (on_frame, quiet, entries)
Adds a 'Structured / scriptable access' subsection under Live Sniff & Simulation covering the on_frame callback, quiet mode, the ANSI-free frame dict shape, and push vs pull (entries) usage for both SniffSession and SimSession.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 01:51:40 -07:00
michael
382732b881 feat(trace): scriptable on_frame callback + quiet mode + SimSession entries
SniffSession/SimSession.start_15693 gain on_frame(frame) and quiet kwargs (defaults preserve today's print behavior). Each frame is a plain dict {protocol, direction, timestamp, duration, data, data_hex, decoded[, crc_fail]} with no ANSI. SimSession now accumulates frames (entries property + clear(), mirroring SniffSession) and auto-streams when trace or on_frame is set; printing is gated on trace and not quiet. TraceFormatter.decode() exposes the annotation without color; format()/print() untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 01:50:39 -07:00
michael
3aab155004 docs: rewrite README against current API (live sniff/sim, mfu, inventory)
Full refresh of the stale 2026-03 README. Fixes sub-module names (iso14a not a14, mf not mfc), adds MIFARE Ultralight/NTAG (mfu) and iso15 inventory() with AFI/DSFID filtering, documents live SniffSession/SimSession streaming + tag.sync() live edits + the transponder/sim framework, and the sync-proxy dir()/help()/tab-completion introspection. Every code example verified against the source; hw.led carries a caveat that it needs the pending CMD_LED_CONTROL firmware port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 01:27:10 -07:00
michael
3f42ca7b70 chore(firmware): bump submodule to rebased upstream (5530c1269)
Firmware rebased onto RfidResearchGroup/proxmark3 b8911a29a as 5 clean per-file commits (sim_table+sim_crypto modules, command IDs + iso15 fields, appmain dispatch, 15693 sim handler + streaming sniff, build glue). Replaces the old 33-commit branch based on the 2026-02-20 merge-base. Old firmware main preserved as tag pre-rebase-main-2026-07-05 (35db99fa5).
2026-07-05 00:53:17 -07:00
michael
75bf0e19cb feat(hf): rename mfc -> mf (MIFARE Classic), add mfu (Ultralight/NTAG)
hf.mfc renamed to hf.mf to match the C client's 'hf mf'. New hf.mfu (HFMFUCommands) provides rdbl/wrbl/dump over the dedicated CMD_HF_MIFAREU_READBL/WRITEBL using the firmware mful_readblock_t / mful_writeblock_t structs; a READ returns 4 pages (16 bytes) and the firmware replies them directly. keytype selects auth (0=none, 1=UL-C, 2=EV1/NTAG pwd, 3=UL-AES). Wire formats verified against firmware structs; needs a bench pass for the auth/write-length paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:40:07 -07:00
michael
137ec1e721 feat(iso15): add inventory() with 1/16-slot parsing + AFI/DSFID filtering
inventory() parses both response formats: slots=1 -> single tag dict (or None); slots=16 -> list of tags from the iso15_inventory_response_t anti-collision struct, each CRC15-validated. Optional request-level AFI filter and client-side DSFID filter. One 16-slot round drops colliding slots (full tree-walk resolution is a follow-up); scan() stays the single-tag convenience.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:34:15 -07:00
michael
739e28e113 feat(client): make dir()/help() work through _SyncProxy
The sync REPL proxy forwarded methods via __getattr__, so dir()/tab-completion never saw them and the sync wrapper reported (*args, **kwargs) instead of the real signature. Add __dir__ delegating to the wrapped target, and decorate the async wrapper with functools.wraps so inspect.signature()/help() report the real params + docstring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:20:03 -07:00
michael
a90a01f2aa fix(lf): correct T55xx write/wakeup structs, 2-byte LF tune, fire-and-forget sampling config
lf.tune sends the 2-byte {mode,divisor} the firmware requires (was 3 -> EINVARG/0V). t55.writebl packs the 10-byte {data,pwd,blockno,flags} struct with a single bit-packed flags byte (was 12). t55.wakeup packs 5 bytes {pwd,flags}. t55.config is maintained client-side (firmware has no GET). LF_SAMPLING_SET_CONFIG uses send_ng_no_response (firmware never replies). Pre-existing, independent of the rebase.
2026-07-05 00:10:31 -07:00
michael
820701511c fix(14a): NG struct for sim (+ tagtype, enables MFU/NTAG sim), NG for sniff
14a.sim sent a MIX frame but the handler is NG casting a packed {tagtype,u16 flags,uid[10],exitAfter,rats[20],ulauth...} struct -> whole struct garbled, flags never arrived. Rewrite to send_ng with the exact struct and expose tagtype (2=Ultralight,7=NTAG) so Ultralight/NTAG sim works. 14a.sniff sent MIX (args stripped -> empty asBytes); send NG with the 1-byte param. Pre-existing, independent of rebase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:08:40 -07:00
michael
3d4634a406 fix(hf,hw): fire-and-forget dropfield, full HF_SNIFF struct, standalone payload
dropfield waited on a reply the firmware never sends (bare hf_field_off();break;) -> 2s stall + raise; use send_ng_no_response. hf.sniff under-sent the 10-byte {u32,u32,u8,u8} struct and mis-parsed the uint16 reply. hw.standalone sent an empty payload for a {arg,mlen,mode[10]} struct and blocked; pack it + fire-and-forget. Pre-existing, independent of rebase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:08:40 -07:00
michael
625104ea03 fix(mfc): correct WRITEBL offset, NESTED struct order, CIDENT/SIM payloads; route sniff to 14a
wrbl padded key to 16 (firmware reads block at asBytes+10) -> corrupt writes; pad to 10. nested sent {block,keytype,key,target...} but firmware struct is {block,keytype,target_block,target_keytype,calibrate,key[6]} -> attacked wrong target; reorder + add calibrate + decode reply. cident/sim sent empty/MIX payloads for NG handlers -> send the packed structs. mf.sniff (no firmware handler) reroutes to CMD_HF_ISO14443A_SNIFF. Pre-existing, independent of rebase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:08:40 -07:00
michael
01357e4e78 fix(iso15): rdbl needs OPTION bit + CRC strip; wrbl needs LONG_WAIT
rdbl omitted the ISO15693 OPTION flag (0x40) so the tag returned [flags][data] without the lock byte the parser assumes (shifting data one byte) and included the 2-byte CRC in block_data. wrbl reused the read flags (no LONG_WAIT) so the firmware used the short RX timeout and the ~20ms write ack arrived too late -> false failure.

rdbl: set OPTION (0x62/0x42), strip CRC (data[2:-2]). wrbl: CONNECT|READ_RESPONSE|LONG_WAIT (0x61). Mirrors C client CmdHF15Readblock / hf_15_write_blk. Pre-existing, independent of the rebase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:03:06 -07:00
michael
f2b23f768b fix(iso15): correct transport flags + append CRC host-side (scan/rdbl/wrbl)
ISO15_READ_RESPONSE was 0x10 but the firmware bit is 0x20 (0x10 is HIGH_SPEED, which pm3py lacked), so scan/rdbl/wrbl sent flags 0x19 with READ_RESPONSE never set -> firmware never listened and replied empty. The ARM firmware also ignores ISO15_APPEND_CRC (the C client adds the CRC host-side via AddCrc15), so pm3py's CRC-less frames were never answered by a real tag.

Fix flag values to match firmware include/iso15.h, add crc15 (CRC-16/X-25), and route all iso15 commands through _iso15_payload (appends CRC, sends CONNECT|HIGH_SPEED|READ_RESPONSE=0x31). Payload now byte-matches the C client getUID. Pre-existing bug, independent of the firmware rebase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 22:48:23 -07:00
michael
35f16b7664 fix(hf): tune() must send START/MEASURE/STOP mode byte and parse uint16
CMD_MEASURE_ANTENNA_TUNING_HF requires a 1-byte mode arg (firmware rejects length!=1 with EINVARG) and returns the voltage as uint16, not uint32. tune() sent no arg (empty reply -> 0 mV) and parsed <I. Now sends mode 1/2/3 (START/MEASURE/STOP) and parses <H. Verified vs firmware appmain.c handler; C client reads the same ~15V on this firmware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 22:38:23 -07:00
michael
687c92a7f7 fix(iso15): request 1-slot inventory in scan() so UID parses correctly
scan() sent flags=0x06 (16-slot inventory). Since 16-slot became the firmware default, the device replies with an iso15_slot_result_t struct, which this parser misread as a zeroed UID (response_flags 16, uid 0000...). Set SLOT1 (0x26) to get the simple single-tag [flags][dsfid][uid] response. Use hf 15 reader --all for multi-tag anti-collision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 22:13:50 -07:00
michael
8f0da9640c refactor: de-ALM Ntag5Boost, seed NTA5332 ALM config defaults
Document ALM as analog-only with no command surface, and seed config blocks 0x40-0x44 with the NTA5332 datasheet Rev 3.4 Table 76 defaults (ALM_CONF/ALM_LUT) so a client reading config memory sees the same bytes a real NTA5332 returns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:30:12 -07:00
michael
4be8100cb6 feat: load ndef_text/ndef_uri/ndef_mime in sim REPL mode 2026-03-19 13:09:50 -07:00
michael
cb5d06da14 feat: set_uid() with validation and random generation for 15693 tags
Tag15693.set_uid() now:
- Accepts None (or no args) to generate a random valid UID with the
  correct IC-specific prefix (E0 + mfg + IC type)
- Validates length (must be 8 bytes)
- Warns on prefix mismatch (wrong IC type for product-specific tags)
- Warns on non-NXP manufacturer code
- Warns on missing E0 prefix
2026-03-19 12:56:57 -07:00
michael
06de521d02 fix(fw): don't treat NXP custom commands with OPTION flag as inventory
NXP command 0xB0 with flags 0x26 was misinterpreted as inventory,
corrupting sim state. Custom commands (>= 0xA0) use bit 2 as OPTION.
2026-03-19 12:38:56 -07:00
michael
83c8dd5546 test: comprehensive SniffSession live streaming tests
10 tests covering streaming flags, trace entry parsing, status
transitions, button-toggle lifecycle, BREAK_LOOP, entry accumulation
across pause/resume cycles, and full end-to-end workflow.
2026-03-19 12:34:10 -07:00
michael
e90a907630 feat: rewrite SniffSession with live streaming and REPL auto-load
Replace lightweight async SniffSession with persistent synchronous
session using raw pyserial (mirroring SimSession pattern). Background
reader thread decodes and prints trace frames live as they arrive.

API:
  session = SniffSession.open()
  session.start_15693()          # live streaming (default)
  session.start_15693(live=False) # legacy batch mode
  session.stop()                 # or button press to pause/resume
  session.entries                # all captured frames
  session.clear()

.pythonstartup.py auto-creates session in sniff mode.
2026-03-19 12:34:05 -07:00
michael
580ab2b63f feat(fw): live streaming sniff with button-toggle pause/resume
Add CMD_HF_SNIFF_STREAM (0x0337) and CMD_HF_SNIFF_STATUS (0x0338) for
fire-and-forget trace streaming during sniff. Unified sniff loop uses
the same proven decode path for both streaming and legacy modes —
streaming replaces LogTrace with ring buffer SNIFF_PUSH/SNIFF_FLUSH.

Firmware features:
- Idle flush: sends buffered trace entries over USB when RF is quiet
  for idle_timeout_ms (configurable, default 400ms for 15693)
- Button-toggle: pause/resume sniffing without restarting
- DMA restart on resume for clean decoder state
- BREAK_LOOP support for Python-side session.stop()
- Legacy mode (flags=0) preserved unchanged

Python constants: SNIFF_FLAG_STREAMING, SNIFF_FLAG_BUTTON_TOGGLE,
SNIFF_STATE_STARTED/PAUSED/RESUMED/STOPPED, Cmd.HF_SNIFF_STREAM/STATUS
2026-03-19 12:33:57 -07:00
michael
44f98f137e fix: update firmware submodule — suppress ISODEP spam in 15693 reader
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:50:12 -07:00
michael
874aa688b9 docs: update CLAUDE.md for completed refactor and sim status
- Package structure reflects reality: transponders/ hierarchy,
  sim/ infrastructure files, reader/ scaffold
- Remove worktree references (sim framework merged to master)
- Mark card simulation as working (not "in progress")
- Update firmware submodule (CRC fix for short 15693 frames)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:00:54 -07:00
michael
f294aba9ef fix: pm3 command for mode switching without re-sourcing venv
source activate once, then pm3/pm3 sim/pm3 sniff/pm3 all to launch
REPL with appropriate imports. Uses python -i for reliable startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:05:00 -07:00
michael
c1ab980036 refactor: remove all backward-compat shims from sim/
Deletes 24 shim files from sim/. All test imports now point directly
to canonical transponders/ and trace/ paths. sim/ contains only
infrastructure (15 files): frame, memory, transponder ABC, reader ABC,
medium, sim_session, table_compiler, replay, relay, fuzzer, pm3medium,
mcu_bridge, mcu_protocol, dual_session, __init__.

751 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:56:40 -07:00
202 changed files with 24904 additions and 734 deletions

8
.gitignore vendored
View File

@@ -2,9 +2,17 @@ __pycache__/
*.pyc
*.egg-info/
.pytest_cache/
build/
dist/
venv/
.venv/
.worktrees/
*.pdf
# Local tooling / editor / agent state (not shared)
.claude/
.vscode/
.mcp.json
# Capture data (sniffs, sims, taginfo scans)
data/

2
.gitmodules vendored
View File

@@ -1,4 +1,4 @@
[submodule "firmware"]
path = firmware
url = https://github.com/dangerous-tac0s/proxmark3-pm3py.git
url = ssh://git@git.dngr.us:20022/MikeFaith/proxmark3-pm3py.git
branch = main

View File

@@ -35,6 +35,9 @@ SIM = [
("pm3py.transponders.hf.iso14443a.ndef", "NfcType4Tag"),
("pm3py.transponders.hf.iso15693.base", "Tag15693"),
("pm3py.transponders.hf.iso15693.type5", "NfcType5Tag"),
("pm3py.transponders.hf.iso15693.type5", "ndef_text"),
("pm3py.transponders.hf.iso15693.type5", "ndef_uri"),
("pm3py.transponders.hf.iso15693.type5", "ndef_mime"),
("pm3py.transponders.hf.iso15693.nxp.nxp_icode", "NxpIcodeTag"),
("pm3py.transponders.hf.iso15693.nxp.icode_slix", "IcodeSlixTag"),
("pm3py.transponders.hf.iso15693.nxp.icode_slix2", "IcodeSlix2Tag"),
@@ -95,6 +98,22 @@ for module_name, attr_name in imports:
if loaded:
print(f"[pm3py:{_MODE}] Loaded: {', '.join(loaded.keys())}")
if _MODE == "core":
try:
pm3 = Proxmark3.sync()
print()
print("Synchronous connection established")
print()
except Exception as e:
print(f" (Proxmark3 connection failed: {e})")
if _MODE == "sniff":
try:
session = SniffSession.open()
print("Sniff session ready -- session.start_15693()")
except Exception as e:
print(f" (Sniff session failed: {e})")
if failed:
print(f"\n[pm3py:{_MODE}] Failed:")
for name, (mod, err) in failed.items():

105
CLAUDE.md
View File

@@ -30,6 +30,9 @@ pm3py/
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
@@ -37,25 +40,43 @@ pm3py/
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 framework (~7.8k LOC, 686 tests)
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)
iso14443a.py # Tag14443A, Tag14443A_3/4, Reader14443A
iso15693.py # Tag15693, Reader15693
mifare.py # MifareClassicTag + Crypto1
desfire.py # DesfireTag, DesfireReader
type5.py # NfcType5Tag (NDEF on 15693)
nxp_icode.py # NxpIcodeTag base
icode_slix.py # → icode_slix2 → icode3, icode_dna
ntag5_platform.py # → ntag5_switch, ntag5_link → ntag5_boost
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)
# + lf_base, em4100, hid, t5577, ndef, implants, fuzzer, relay, etc.
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)
transponders/ # tag/transponder models independent of hardware (scaffold)
```
## Client API
@@ -64,7 +85,7 @@ pm3py/
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.hf.mf.rdbl(0) # MIFARE Classic
pm3.lf.t55.readbl(0) # T55xx
pm3.hf.tune() # HF antenna tune
pm3.hf.dropfield() # drop field
@@ -97,9 +118,9 @@ pm3.hf.dropfield() # drop field
PWM-capable: Easy = A,B. RDV4 = A,D.
## Sim framework (worktree `feature/sim-framework`)
## Sim framework
Software-defined transponder/reader simulation framework — 750+ tests. 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. See `.worktrees/sim-framework/docs/SIM_FRAMEWORK_STATUS.md` for full status.
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
@@ -116,15 +137,34 @@ For unaddressed commands (`02 AB 04`), mfg code stays → `02 AB 04`. PREFIX mat
**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 (in progress)
## Python-driven card simulation
Design doc: `docs/PYTHON_SIM_DESIGN.md`. Firmware patch (~320 lines of C) + Python extensions to enable Python-controlled card simulation on unmodified PM3 Easy/RDV4 hardware. Two mechanisms:
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`
@@ -133,3 +173,36 @@ Firmware maintenance: atomic single-file commits for easy rebase against upstrea
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).

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dangerous Things
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

722
README.md
View File

@@ -1,179 +1,715 @@
# pm3py
Pure-Python library for talking to a [Proxmark3](https://github.com/RfidResearchGroup/proxmark3) over its NG wire protocol. Returns structured Python dicts instead of parsing terminal output. Async-first with a sync wrapper for REPL use.
Pure-Python async client for the [Proxmark3](https://github.com/RfidResearchGroup/proxmark3), speaking its NG wire protocol directly over USB serial. Structured Python dicts instead of parsed terminal text, with a sync proxy for REPL use.
**No dependency on the C client.** Speaks the wire protocol directly over USB serial.
- **Pure Python** — no dependency on the C client binary; speaks the wire protocol directly over USB serial.
- **Structured results** — every command returns a Python dict, not scraped terminal output.
- **Async-first, REPL-friendly** — `async`/`await` core with a synchronous proxy (`Proxmark3.sync()`) that supports `dir()`, `help()`, and tab-completion.
- **Live sniff & simulation** — stream decoded reader↔tag frames into your REPL in real time; edit an emulated tag and push it live.
- **Software-defined tags** — pure-Python transponder models (ISO 14443-A, ISO 15693, MIFARE Classic, NTAG/Type-2, NXP ICODE/SLIX2/NTAG5, LF) served from a firmware response table.
- **Interactive raw console** — [`pm3py rawcli`](#pm3py-rawcli--interactive-raw-command-console) drops into a Proxmark-style TUI for raw command entry: a live annotated trace, hex/binary toggle, and function-style transponder commands.
- **Autocompleting client** — [`pm3py client`](#pm3py-client--autocompleting-console-for-the-stock-proxmark3-client) wraps the **stock Proxmark3 client** (so it works with any Proxmark/firmware) with full command-tree completion, press-→ help, and the client's own colored output — and can [spin off a single-file drop-in](#distributing-a-drop-in) for anyone on the stock repo.
## Install
Not on PyPI yet — install from a clone. Use `--recurse-submodules` so the firmware fork
(`firmware/`) comes with it (needed for flashing and the live-sim features):
```bash
git clone --recurse-submodules <repo-url> pm3py
cd pm3py
pip install -e .
pip install -e . # or `pip install .`
```
Requires Python 3.10+ and `pyserial-asyncio`.
Requires **Python 3.10+**; dependencies (`pyserial-asyncio`, `bitarray`, `pycryptodome`,
`prompt_toolkit`, `pexpect`) install automatically. It's pure Python + pyserial, so it also runs on a **Raspberry Pi**
(aarch64) — see [Flashing firmware](#flashing-firmware-pm3flash) for the Pi bring-up.
## Quick Start
pm3py is async-first, but the sync REPL proxy is the easiest way to start. `Proxmark3.sync()` auto-detects the serial port, connects, probes the firmware, and returns a proxy where every command runs synchronously:
```python
from pm3py import Proxmark3
# Sync mode — auto-detects the device
pm3 = Proxmark3.sync()
print(pm3.firmware.version_string)
print(pm3.hw.ping())
print(pm3.hw.version())
pm3.close()
pm3 = Proxmark3.sync() # auto-detect; or Proxmark3.sync("/dev/ttyACM0")
print(pm3.firmware) # version + compatibility, populated on connect
print(pm3.hw.ping()) # {'success': True, 'length': 32, 'data': '00010203...'}
print(pm3.hw.version()["version_string"])
print(pm3.hf.iso14a.scan()) # scan a 14443-A card
pm3.close() # disconnect + close the event loop
```
Or with async:
The sync proxy is REPL-friendly: `dir(pm3)`, `dir(pm3.hf)`, tab-completion, and `help(pm3.hf.iso15.rdbl)` all work and report each command's real signature (the proxy uses `functools.wraps` + `__dir__`, so wrapped methods keep their `__doc__` and parameters instead of showing `(*args, **kwargs)`).
Prefer async? Use the context manager directly:
```python
import asyncio
from pm3py import Proxmark3
async def main():
async with Proxmark3() as pm3:
async with Proxmark3() as pm3: # auto-detect, or Proxmark3("/dev/ttyACM0")
print(await pm3.hw.ping())
print(await pm3.hf.a14.scan())
print(await pm3.hf.iso14a.scan())
asyncio.run(main())
```
## API
All command methods return structured Python dicts, not parsed terminal text. The command tree mirrors the C client:
All commands return Python dicts. The API mirrors the C client's command tree:
| Sub-module | Protocol |
|------------|----------|
| `pm3.hw.*` | Hardware / device control |
| `pm3.lf.*`, `pm3.lf.t55.*` | Low frequency + T55xx |
| `pm3.hf.*` | HF core (tune, search, sniff, dropfield) |
| `pm3.hf.iso14a.*` | ISO 14443-A |
| `pm3.hf.iso15.*` | ISO 15693 |
| `pm3.hf.mf.*` | MIFARE Classic |
| `pm3.hf.mfu.*` | MIFARE Ultralight / NTAG |
## API
### Hardware — `pm3.hw.*`
```python
pm3.hw.ping() # verify connectivity
pm3.hw.version() # firmware version, chip ID
pm3.hw.status() # device status
pm3.hw.capabilities() # compiled features, platform (Easy vs RDV4)
pm3.hw.tune() # full antenna tuning (LF + HF voltages)
pm3.hw.led("green", on=True) # LED control with color names
pm3.hw.led("red", pulse=True, speed=300)# PWM effects (validates hardware support)
pm3.hw.led("all", on=False) # turn off
pm3.hw.dbg(level=2) # set debug level
pm3.hw.break_loop() # stop long-running operations
pm3.hw.reset() # reset device
pm3.hw.ping() # {'success': True, 'length': 32, 'data': '...'}
pm3.hw.ping(length=64) # ping with a custom echo payload length
pm3.hw.version() # {'chip_id': '0x...', 'section_size': ..., 'version_string': '...'}
pm3.hw.status() # {'status': 0}
pm3.hw.capabilities() # {'version', 'baudrate', 'bigbuf_size', 'is_rdv4',
# 'compiled_with_lf', 'compiled_with_iso14443a',
# 'compiled_with_iso15693'}
pm3.hw.tune() # full antenna tuning — nested {'lf': {...}, 'hf': {...}}
pm3.hw.dbg() # get debug level -> {'level': N}
pm3.hw.dbg(level=2) # set debug level 0-4
pm3.hw.led("green", on=True) # LED control by color name
pm3.hw.led("all", off=True) # turn everything off
pm3.hw.break_loop() # stop a long-running firmware operation
pm3.hw.reset() # reset the device (fire-and-forget)
```
LED colors are platform-aware — `"green"` maps to the correct physical LED on both PM3 Easy and RDV4. PWM operations (brightness, pulse, fade) validate hardware capability and raise `PM3Error` if the LED doesn't support it.
`hw.tune()` runs the full LF+HF antenna sweep and returns both bands at once:
```python
t = pm3.hw.tune()
print(t["lf"]["125kHz_V"], t["lf"]["134kHz_V"], t["lf"]["peak_freq_kHz"])
print(t["hf"]["13.56MHz_V"])
```
> **`hw.led` needs our firmware fork** (`CMD_LED_CONTROL` @ `0x011B`). It is not in
> stock upstream firmware — there `0x011A` is `CMD_SET_HF_FIELD_TIMEOUT` and `hw.led`
> is a no-op. With the fork flashed (the `firmware/` submodule) it drives the LEDs as below.
**LED control** is platform-aware — color names map to the correct physical LED on both PM3 Easy and RDV4. Accepts color names (`"green"`, `"red"`, `"orange"`, `"blue"`/`"red2"`, `"all"`), letters (`"a"`-`"d"`), comma-separated combos (`"green,red"`), or a bitmask int. Pick one action kwarg:
```python
pm3.hw.led("red", on=True) # on
pm3.hw.led("a", toggle=True) # toggle
pm3.hw.led("green", blink=True, speed=300, count=10) # blink (all LEDs)
pm3.hw.led("green", pulse=True) # PWM pulse — PWM-capable LEDs only
pm3.hw.led("green", brightness=50) # PWM brightness 0-100
```
PWM effects (`brightness`, `pulse`, `fade`) validate hardware support and raise `PM3Error` if the target LED can't do PWM (Easy: A,B; RDV4: A,D).
Also available: `pm3.hw.tearoff(delay_us=..., on=True)` and `pm3.hw.standalone(mode="14a")` for tear-off timing and standalone modes.
### Low Frequency — `pm3.lf.*`
```python
pm3.lf.tune(divisor=95) # LF antenna voltage at frequency
pm3.lf.read(samples=30000) # capture ADC samples
pm3.lf.config() # get sampling config
pm3.lf.config(divisor=88) # set config (134 kHz)
pm3.lf.search() # capture + return LFSearchResult
pm3.lf.sniff() # alias for read
pm3.lf.sim(gap=0, data=b"...") # simulate from buffer
# T55xx tags
pm3.lf.t55.readbl(block=0)
pm3.lf.t55.writebl(block=1, data=0xDEADBEEF)
pm3.lf.t55.wakeup(password=0x12345678)
pm3.lf.tune() # LF antenna voltage @125kHz (divisor 95)
pm3.lf.tune(divisor=88) # @134kHz — see return shape below
pm3.lf.read(samples=12288) # capture ADC samples into device buffer
pm3.lf.sniff() # alias for read()
pm3.lf.config() # get current sampling config
pm3.lf.config(divisor=88) # set config (134 kHz); fire-and-forget then re-read
pm3.lf.search() # capture + return an LFSearchResult
pm3.lf.sim(gap=0, data=b"...") # simulate an LF tag from the sample buffer
```
`lf.tune(divisor=...)` sends the START then MEASURE steps and returns the antenna voltage:
```python
pm3.lf.tune(divisor=95)
# {'voltage_mV': 24875, 'voltage_V': 24.875, 'frequency_kHz': 125.0, 'divisor': 95}
```
Frequency is `12000 / (divisor + 1)` kHz — `95` = 125 kHz, `88` = 134 kHz.
`lf.search()` returns an `LFSearchResult` with callable next steps:
```python
result = pm3.lf.search()
print(result) # shows available actions
raw = result.download() # download raw ADC samples
result.tune() # check antenna voltage
result.read_t55xx(block=0) # try reading as T55xx
print(result) # prints available actions + sample count
raw = result.download() # raw ADC samples as bytes (1 byte/sample, 0-255)
result.tune() # check antenna voltage (tag present?)
result.read_t55xx(block=0) # try reading it as a T55xx
```
Full tag demodulation (EM410x, HID, AWID, etc.) requires the C client's DSP stack.
**Pure-Python LF demodulation — `pm3py.lf`.** Demod no longer needs the C client's DSP stack:
`pm3py.lf` ports the ASK/FSK/biphase pipeline and decodes the captured samples directly
(EM4100, HID, AWID, FDX-B, and the T55xx config block). Hardware-validated.
```python
from pm3py.lf import identify_lf, read_config
identify_lf(pm3) # -> {'label': 'T5577 (EM4100 20260716FF)', 'chip': ..., 'emitted': {...}}
read_config(pm3) # decode a T55xx block-0 config (modulation, bit-rate, block count)
```
**T55xx tags — `pm3.lf.t55.*`:**
```python
pm3.lf.t55.readbl(block=0) # -> {'status', 'data': hex, 'raw': bytes}
pm3.lf.t55.readbl(block=1, password=0x12345678) # password read
pm3.lf.t55.writebl(block=1, data=0xDEADBEEF) # write a 32-bit block
pm3.lf.t55.writebl(block=1, data=0x00, password=0x12345678)
pm3.lf.t55.wakeup(password=0x12345678) # password wakeup
pm3.lf.t55.config() # downlink timing table (client-side)
```
### High Frequency — `pm3.hf.*`
```python
pm3.hf.tune() # HF antenna voltage
pm3.hf.search() # try 14a then 15693
pm3.hf.sniff() # sniff HF traffic
pm3.hf.dropfield() # turn off field
```
### ISO 14443-A — `pm3.hf.a14.*`
Core HF operations. `tune()` runs the firmware START/MEASURE/STOP state machine internally and reports the antenna voltage; `search()` probes 14443-A then 15693.
```python
pm3.hf.a14.scan() # → {uid, atqa, sak, ats}
pm3.hf.a14.raw(data=b"\x50\x00") # raw APDU
pm3.hf.a14.sniff()
pm3.hf.a14.sim(uid=b"\x01\x02\x03\x04")
pm3.hf.tune() # → {'voltage_mV': 4521, 'voltage_V': 4.521}
pm3.hf.search() # try 14a then 15693 → {'iso14443a': {...}, 'found': True}
pm3.hf.sniff() # sniff raw HF, returns {'status', 'samples'}
pm3.hf.dropfield() # turn the HF field off (fire-and-forget)
```
`search()` returns a dict with an `iso14443a` and/or `iso15693` key when a tag answers, plus `found: bool`. `sniff()` accepts `samples_to_skip`, `triggers_to_skip`, `skip_mode`, `skip_ratio` and returns the captured sample count; for decoded, protocol-aware sniffing (including live streaming) use `SniffSession` instead.
### ISO 14443-A — `pm3.hf.iso14a.*`
```python
pm3.hf.iso14a.scan() # → {'found', 'uid', 'atqa', 'sak', 'ats', ...}
pm3.hf.iso14a.raw(b"\x30\x00") # raw frame → {'status', 'data', 'raw'}
pm3.hf.iso14a.sniff() # sniff 14443-A, returns {'status'}
pm3.hf.iso14a.sim(tagtype=1, uid=b"\x01\x02\x03\x04") # simulate a tag
```
`scan()` selects the card and returns UID/ATQA/SAK plus `ats` and a `select_status` (1=OK+ATS, 2=OK no ATS, 3=proprietary). `raw()` sends a raw frame with the RAW + NO_DISCONNECT flags already set; pass extra `flags` and a `timeout_14a` if needed. `sim()` emulates a tag — `tagtype` picks the model (1=MIFARE Classic 1k, 2=Ultralight, 3=DESFire, 4=ISO14443-4, 7=MFU EV1/NTAG215, 8=MFC 4k, 11=JCOP, 13=ULC, 14=ULAES, ...), `uid` is 4/7/10 raw bytes (empty = take UID from emulator memory), and `exit_after` stops after N reader reads.
For a full software-defined tag served from the firmware response table (NDEF, custom commands, live trace), use `SimSession` with an `NfcType2Tag` — see the simulation section.
### ISO 15693 — `pm3.hf.iso15.*`
```python
pm3.hf.iso15.scan() # → {uid, dsfid}
pm3.hf.iso15.rdbl(block=0) # read block
pm3.hf.iso15.wrbl(block=0, data="00112233")
pm3.hf.iso15.sniff()
pm3.hf.iso15.scan() # quick single-tag → {'found', 'uid', 'dsfid', 'response_flags'}
pm3.hf.iso15.inventory() # 16-slot anti-collision → list of tag dicts
pm3.hf.iso15.rdbl(0) # read block 0
pm3.hf.iso15.wrbl(0, "00112233") # write block 0
pm3.hf.iso15.sniff() # sniff 15693 (blocks until button/timeout)
```
`scan()` is the "is one tag present" convenience and returns a single dict. `inventory()` runs a real anti-collision round:
```python
# 16-slot round (default) → LIST of tags (empty list if none)
pm3.hf.iso15.inventory()
# → [{'uid': 'e0040150...', 'dsfid': 0, 'flags': 0, 'slot': 3}, ...]
# single slot → ONE tag dict (or None)
pm3.hf.iso15.inventory(slots=1)
# → {'uid': 'e0040150...', 'dsfid': 0, 'flags': 0}
# filter by AFI (request-level, only matching tags answer)
pm3.hf.iso15.inventory(afi=0x00)
# filter by DSFID (client-side, on the returned records)
pm3.hf.iso15.inventory(dsfid=0)
```
UIDs come back in display order (already reversed from wire order). Block I/O optionally takes a UID for addressed mode:
```python
pm3.hf.iso15.rdbl(4) # unaddressed read
pm3.hf.iso15.rdbl(4, uid="e004015012345678") # addressed read
# → {'success': True, 'block': 4, 'locked': False, 'data': '00112233', 'raw': b'...'}
pm3.hf.iso15.wrbl(4, "00112233") # unaddressed write (bytes or hex str)
pm3.hf.iso15.wrbl(4, b"\x00\x11\x22\x33", uid="e004015012345678")
# → {'success': True, 'block': 4}
```
`raw_inventory(inv_flags=0x26)` is a diagnostic that returns the unparsed device response (`0x26` = 1 slot, `0x06` = 16 slots). For decoded/live sniffing use `SniffSession`.
### MIFARE Classic — `pm3.hf.mf.*`
> **Renamed:** the MIFARE Classic sub-module used to be `pm3.hf.mfc`. It is now
> `pm3.hf.mf` (the backing module is still `pm3py/core/hf_mfc.py`). Update any
> old scripts.
Keys are 6 bytes, given as a hex string or `bytes`. `key_type` is `0` for
key A (default) or `1` for key B.
```python
pm3.hf.mf.rdbl(block=0) # read block (default key FF..FF)
pm3.hf.mf.rdbl(block=4, key="A0A1A2A3A4A5", key_type=1) # key B
pm3.hf.mf.wrbl(block=4, key="FFFFFFFFFFFF", data="00" * 16)
pm3.hf.mf.rdsc(sector=0) # read entire sector
pm3.hf.mf.chk(block=0, keys=["FFFFFFFFFFFF", "A0A1A2A3A4A5"])
pm3.hf.mf.sniff()
pm3.hf.mf.sim(uid="01020304", size="1k")
pm3.hf.mf.nested(block=0, key="FFFFFFFFFFFF")
pm3.hf.mf.cident() # identify magic card type
pm3 = Proxmark3.sync()
# Read one block (default key FFFFFFFFFFFF, key A)
pm3.hf.mf.rdbl(0)
# → {'success': True, 'block': 0, 'data': '0403...', 'raw': b'\x04\x03...'}
pm3.hf.mf.rdbl(4, key="A0A1A2A3A4A5", key_type=1) # key B
# Write a block (data must be 16 bytes)
pm3.hf.mf.wrbl(4, data="00112233445566778899AABBCCDDEEFF",
key="FFFFFFFFFFFF", key_type=0)
# → {'success': True, 'block': 4}
# Read a whole sector (sectors 031 = 4 blocks, 3239 = 16 blocks)
pm3.hf.mf.rdsc(0) # → {'sector': 0, 'first_block': 0, 'num_blocks': 4, 'blocks': {...}}
# Key-check a block against a list of candidate keys
pm3.hf.mf.chk(0, keys=["FFFFFFFFFFFF", "A0A1A2A3A4A5", "D3F7D3F7D3F7"])
# → {'found': True, 'key': 'ffffffffffff', 'key_type': 0} (or {'found': False})
```
### Raw Commands
**Nested attack** — recover an unknown key using one known key on the card:
For anything not wrapped:
```python
pm3.hf.mf.nested(block=0, key="FFFFFFFFFFFF", key_type=0,
target_block=4, target_key_type=1, calibrate=True)
# → {'success': True, 'is_ok': 0, 'block': 4, 'key_type': 1,
# 'cuid': '...', 'nt_a': '...', 'ks_a': '...', 'nt_b': '...', 'ks_b': '...'}
```
**Magic-card identification** — detect gen1/gen2 "magic" cards:
```python
pm3.hf.mf.cident()
# → {'status': 0, 'magic': 0, 'is_magic': False}
```
**Sniff / simulate:**
```python
pm3.hf.mf.sniff() # routed through the ISO14443-A sniffer
# Simulate a card. size ∈ {"mini","1k","2k","4k"}; UID is 4, 7, or 10 bytes.
pm3.hf.mf.sim(uid="01020304", size="1k")
pm3.hf.mf.sim(uid="04AABBCCDDEE80", size="4k",
atqa="0044", sak=0x18, exit_after=1, interactive=True)
```
For full software-defined MIFARE Classic simulation (response table served from
firmware, live trace), see the sim framework section and `MifareClassicTag`.
### MIFARE Ultralight / NTAG — `pm3.hf.mfu.*`
New sub-module for ISO14443-3A tags with 4-byte pages (MIFARE Ultralight,
UL-C, UL-EV1, UL-AES, and the NTAG21x family). A `READ` returns 4 consecutive
pages (16 bytes) at once; a `WRITE` sets a single 4-byte page.
`keytype` selects the auth scheme (pass the matching `key`):
| keytype | scheme | key length |
|---------|--------|-----------|
| `0` | none (default) | — |
| `1` | UL-C (3DES) | 16 bytes |
| `2` | UL-EV1 / NTAG password | 4 bytes |
| `3` | UL-AES | 16 bytes |
```python
pm3 = Proxmark3.sync()
# Read starting at page 4 → 16 bytes (pages 4,5,6,7)
pm3.hf.mfu.rdbl(4)
# → {'success': True, 'block': 4, 'data': '01020304...', 'raw': b'\x01\x02...'}
# Password-authenticated read (UL-EV1 / NTAG, 4-byte pwd)
pm3.hf.mfu.rdbl(4, key="FFFFFFFF", keytype=2)
# Write one 4-byte page
pm3.hf.mfu.wrbl(4, data="DEADBEEF")
# → {'success': True, 'block': 4}
# Dump pages (each underlying READ pulls 4 pages; stops early on failure)
pm3.hf.mfu.dump(start=0, pages=16)
# → {'success': True, 'pages': 16, 'data': '...', 'raw': b'...'}
```
For software-defined NTAG/Type-2 simulation (NDEF, served from a firmware
response table with live trace), use `NfcType2Tag` with `SimSession` — see the
sim framework section.
## Live Sniff & Simulation
pm3py can drive the Proxmark3 as a **live sniffer** and as a **software-defined card simulator**, with reader↔tag traffic streamed into your REPL in real time. Both use custom firmware (the `firmware/` submodule / `proxmark3-pm3py`) and open the serial port directly, so they run from the plain sync REPL — no asyncio needed.
### Live sniff — `SniffSession`
`SniffSession` starts a firmware sniff and streams decoded frames to the console from a background thread. The Proxmark3 button **toggles pause/resume** while streaming, so you can walk a card up to the field, pause, inspect, and resume without touching the keyboard. Captured frames accumulate across pause/resume cycles in `session.entries`.
```python
from pm3py.sniff.session import SniffSession
s = SniffSession.open() # opens /dev/ttyACM0 by default
s.start_15693(live=True) # streams ISO 15693 trace live; PM3 button pauses/resumes
# ... present a tag to the antenna; decoded reader/tag frames print as they arrive ...
s.stop() # end the sniff (sends BREAK_LOOP)
print(len(s.entries)) # all captured frames across pause/resume cycles
print(s.state) # 'sniffing' | 'paused' | 'stopped'
s.entries[0] # {'protocol','direction','duration','timestamp','data','data_hex','decoded'}
s.close() # stop (if needed) and close the serial port
```
- `SniffSession.open(port="/dev/ttyACM0", baudrate=115200)` — open the port.
- `start_15693(live=True, idle_timeout_ms=400)` — live streaming with button-toggle. Pass `live=False` for the legacy BigBuf batch path (for batch decoding you can also use `pm3.hf.iso15.sniff()`).
- `s.state`, `s.entries`, `s.clear()` — inspect and reset accumulated capture.
- `s.stop()` / `s.close()` — halt streaming / release the port.
Status transitions (Started / Paused / Resumed / Stopped) print inline as the button is pressed.
### Live sim — `SimSession`
`SimSession` compiles a Python tag model into a firmware **response table**, uploads it, and starts the tag simulation. The firmware answers standard commands (inventory, read, write) autonomously at wire speed. With `trace=True`, every reader↔tag frame is decoded and streamed live from a background thread. You can then **edit the tag in the REPL and push the changes live** with `tag.sync()` — no restart of the sim required.
```python
from pm3py.sim import SimSession, IcodeSlix2Tag, ndef_uri
# Build a tag model (uid=None auto-generates a valid E0 04 02.. SLIX2 UID)
tag = IcodeSlix2Tag(uid=None, ndef_message=ndef_uri("https://dngr.us"))
s = SimSession.open() # opens /dev/ttyACM0 by default
s.start_15693(tag, compile=True, trace=True) # upload table, start sim, stream frames live
# ... a phone / reader now reads the emulated tag; frames print as they arrive ...
# Live edit: change the NDEF payload and push it to the firmware emulator memory
tag.set_ndef(ndef_uri("https://example.org/new"))
tag.sync() # firmware now serves the updated content
s.stop() # stop the sim (sends BREAK_LOOP)
s.close() # stop (if needed) and close the port
```
- `SimSession.open(port="/dev/ttyACM0", baudrate=115200)` — open the port for a synchronous sim.
- `start_15693(tag, compile=True, trace=False)` — compile+upload the response table and start the 15693 sim; `trace=True` streams decoded reader↔tag traffic (and reports RF field strength via the optional `on_field_strength` callback).
- `tag.sync()` — push in-REPL edits (UID, memory, NDEF, access conditions) to firmware emulator memory. It does a smart sync: EML memory push for data changes, response-table recompile for access-condition changes. The tag must be bound to a live session (`start_15693` binds it automatically).
- `s.stop()` / `s.close()` — halt the sim / release the port.
A 14443-A sim path (`await s.start_14a(tag)`, WTX relay) also exists but is **async and experimental** — it requires the async `PM3Transport` (not the synchronous `SimSession.open()` port above) and is still under development.
### Structured / scriptable access
For scripting you usually want the frames as **data**, not printed colored text. Both
`start_15693(...)` methods take two optional kwargs (defaults preserve the console
behavior above):
- **`on_frame=callback`** — invoked per decoded frame (from the reader thread) with a
plain dict. Passing it also auto-enables trace streaming on the sim.
- **`quiet=True`** — don't print frames to the console (the callback and the `entries`
accumulator still fill).
Every frame is an ANSI-free dict:
```python
{'protocol': int, 'direction': int, # 0 = reader→tag, 1 = tag→reader
'timestamp': int|None, 'duration': int|None,
'data': bytes, 'data_hex': str,
'decoded': str|None} # e.g. 'INVENTORY', 'READ BLOCK 4'
```
*(SimSession frames also carry `'crc_fail': bool`; sniff frames carry `timestamp`/`duration`.)*
**Push model** — react to frames as they arrive, no printing:
```python
from pm3py.sniff.session import SniffSession
reads = []
s = SniffSession.open()
s.start_15693(on_frame=lambda f: reads.append(f["decoded"]), quiet=True)
# ... run a reader against a tag ...
s.stop(); s.close()
print(reads) # ['INVENTORY', 'READ BLOCK 0', ...] — pure data, no ANSI
```
**Pull model** — run quiet, then read the accumulator afterward:
```python
from pm3py.sim import SimSession, IcodeSlix2Tag
s = SimSession.open()
s.start_15693(IcodeSlix2Tag(uid=None), trace=True, quiet=True) # capture, no printing
# ... phone reads the tag ...
s.stop()
for f in s.entries: # list of frame dicts (a copy)
print(f["direction"], f["data_hex"], f["decoded"])
s.clear(); s.close()
```
`SimSession.entries` mirrors `SniffSession.entries`; both have `.clear()`.
### Card simulation
The simulation framework (`pm3py/sim/` infrastructure, `pm3py/transponders/` tag models) provides pure-Python transponder models served from the firmware response table. Import everything from `pm3py.sim`.
**ISO 15693 (`start_15693`)**
- `NfcType5Tag` — generic NFC Forum Type 5 tag with NDEF (`ndef_text`, `ndef_uri`, `ndef_mime`).
- `IcodeSlix2Tag` (and `IcodeSlixTag`, `Icode3Tag`, `IcodeDnaTag`, `NxpIcodeTag`) — NXP ICODE family with passwords, privacy mode, EAS/AFI, originality signature.
- `Ntag5SwitchTag`, `Ntag5LinkTag`, `Ntag5BoostTag` (base `Ntag5PlatformTag`) — NXP NTAG 5 dual-interface family.
**ISO 14443-A (`start_14a`, async/experimental)**
- `NfcType2Tag` — NFC Forum Type 2 (MIFARE Ultralight / NTAG-style) with NDEF.
- `NfcType4Tag`, `MifareClassicTag`, `DesfireTag` — Type 4, MIFARE Classic (Crypto1), and DESFire models.
```python
from pm3py.sim import SimSession, NfcType5Tag, ndef_uri
# uid=None auto-generates a valid E0-prefixed 15693 UID
tag = NfcType5Tag(uid=None, ndef_message=ndef_uri("https://dngr.us"))
s = SimSession.open()
s.start_15693(tag, trace=True) # emulate an NFC Type 5 tag to a 15693 reader
s.stop(); s.close()
```
NDEF helpers `ndef_text(text, lang="en")`, `ndef_uri(uri)`, and `ndef_mime(mime_type, data)` build the message bytes passed as `ndef_message=` (or via `tag.set_ndef(...)` for live edits). `uid=None` on the 15693 models auto-generates a valid `E0`-prefixed UID with the correct manufacturer/IC-type bytes so phones identify the IC correctly.
## Raw Commands
For anything not yet wrapped, drop down to the raw NG/MIX frame senders. Both are
regular client methods, so they work through the sync REPL proxy or `await` in async
code:
```python
from pm3py import Cmd
resp = pm3.send_ng(Cmd.STATUS) # NG frame
resp = pm3.send_mix(Cmd.HF_ISO14443A_READER, arg0=0x0103) # MIX frame
# NG frame: cmd + raw payload bytes
resp = pm3.send_ng(Cmd.STATUS)
resp = pm3.send_ng(Cmd.CAPABILITIES)
# MIX frame: cmd + three uint64 args + optional payload
resp = pm3.send_mix(Cmd.HF_ISO14443A_READER, arg0=0x0103)
# resp is a PM3Response: resp.cmd, resp.status, resp.data (bytes)
print(resp.status, resp.data.hex(), len(resp.data))
```
Signatures: `send_ng(cmd, payload=b"", timeout=2.0)` and
`send_mix(cmd, arg0=0, arg1=0, arg2=0, payload=b"", timeout=2.0)`. Both return a
`PM3Response` (fields: `cmd`, `status`, `reason`, `ng`, `data`, `oldarg`). Use these
only for commands the firmware replies to — fire-and-forget commands (e.g.
`HF_DROPFIELD`) send no response and would block until the timeout; call the wrapped
method (`pm3.hf.dropfield()`) for those.
> **REPL discovery:** the sync proxy returned by `Proxmark3.sync()` implements
> `__dir__` and uses `functools.wraps` on every wrapped method, so `dir(pm3)`,
> tab-completion, and `help(pm3.hf.iso15.rdbl)` all surface the real sub-modules,
> method names, and signatures (e.g. `rdbl(block, uid=None)`) instead of an opaque
> `(*args, **kwargs)` wrapper.
## `pm3py rawcli` — interactive raw-command console
`pm3py rawcli` opens an interactive, Proxmark-style console for driving a connected device at the
raw level — a live annotated trace, flexible hex/binary entry, and function-style transponder
commands. It's a **separate tool from the Python REPL**: its own command grammar, not Python.
```bash
pm3py rawcli # auto-detect the device (runs offline if none attached)
```
```text
[raw / 0x] identify # probe the field; sets HF/LF + transponder, keeps the link
NTAG213 (HF / 14a) UID 04A1B2C3
[raw / hf / NTAG213 / 0x] READ(4) # function-style command from the tag's catalog
[Rdr] Reader → Tag: 30 04 READ page 4
[Rdr] Tag → Reader: 01 02 03 04 …
[raw / hf / NTAG213 / 0x] 30 04 # …or send raw bytes directly
[raw / hf / NTAG213 / 0b] 30 00000100 # per-byte base: hex 30 + a binary byte → sends 0x30 0x04
[raw / hf / NTAG213 / 0x] tlv 00 03 09 D1 01 05 54 02 65 6E 48 69 FE # decode a TLV / NDEF block
```
- **Breadcrumb prompt** `[raw / hf|lf / <transponder> / 0x|0b]` reflects the field, the identified
tag, and the current numeric entry mode.
- **Per-byte base entry** — type hex (`30 04`) or binary; **Ctrl-/** toggles `0x`/`0b` and digits
auto-group into bytes (a byte seals at 2 hex / 8 binary). Base is tracked **per byte**: the
toggle only changes the base of the *next* byte, so `30` then toggle then `00000100` reads as
`30 00000100` and sends `0x30 0x04`. A `0x`/`0b` prefix overrides for one token; function-call
args are base-10 (`READ(4)`, `READ(0x2B)`). Mixing a command with raw bytes on one line is an
error, not a transmit.
- **`identify`** probes 14a → 15693 → LF and keeps the tag selected; **`transponder`** / **`close`**
manage the connection.
- **Function-style commands** from the identified tag's catalog: NTAG/Ultralight (`READ`,
`FAST_READ`, `GET_VERSION`, `WRITE`, …), **MIFARE Classic** (`CHK(block)` to find the sector
key, then authenticated `READ(block[, key][, A|B])` / `WRITE`), ISO 15693, and LF T5577
(`READ`/`WRITE`/`DETECT`). `help` lists them, `help READ` shows one.
- **Completion + memory map** — command names, opcodes matched by the hex/binary value you type,
and the identified tag's **datasheet memory map** all surface in the completion menu, rendered
in whichever base you're entering. Page/block roles are named (UID, CC/OTP, user memory,
CFG0/CFG1, PWD/PACK on NTAG21x/Ultralight; manufacturer block and sector trailers — Key A /
access bits / Key B — on MIFARE Classic).
- **Live annotated trace** — every exchange prints the reader↔tag frames with decoded annotations.
- **TLV / NDEF** — `tlv <hex>` prints a structured multi-line breakdown; a multi-line editor
composes long TLVs.
## `pm3py client` — autocompleting console for the stock Proxmark3 client
Where `rawcli` speaks pm3py's own protocol stack, `pm3py client` wraps the **standard Proxmark3 C
client** — so it works with any Proxmark3 and any firmware the stock client supports. You get the
real client's exact behavior and its native colored output, with autocompletion and inline help
layered over its full command tree.
```bash
pm3py client # auto-detect the device and the client
pm3py client --driver pexpect # force the binary backend (no SWIG build needed)
pm3py client --port /dev/ttyACM0 --commands /path/to/commands.json
```
```text
[client / usb] pm3py --> hf mf rd⇥ # Tab completes to `hf mf rdbl`, then descends to its options
[client / usb] pm3py --> hf mf rdbl --blk 0 -k FFFFFFFFFFFF
[+] 0 | 04 A1 B2 C3 D4 08 04 00 62 63 64 65 66 67 68 69 | # the client's own colored output
[client / usb] pm3py --> hf 14a in⇥ # → on the highlighted `info` opens its full help in the bar
```
- **Full command-tree completion** — completes each level (`hf``hf mf``hf mf rdbl`) and a
command's option flags (`--blk`, `-k`), each with its description as the tooltip. **Tab** commits
the highlighted item and descends to the next level.
- **Press-→ help** — with a completion highlighted, **→** expands its full help (description, usage,
options, notes) in the bottom bar; **←** / **Esc** / **Ctrl-g** backs out. Otherwise the bar shows
a one-line hint for the current command.
- **The client's own output** — commands run through the real client and print its native colored
output verbatim; the breadcrumb `[client / usb]` reflects the live connection.
- **Two backends** (`--driver auto`, the default):
- **SWIG** — the in-process `_pm3` bindings (`console()` + captured output); needs the client's
`experimental_lib` extension built, and a connected device.
- **pexpect** — spawns the `proxmark3` binary and reads its prompts; no build required, and runs
offline. Used automatically when `_pm3` isn't importable, or forced with `--driver pexpect`.
- **Command tree from the client** — the menu is built from the checkout's `doc/commands.json` (the
same set the client itself exposes), auto-located from the repo; override with `--commands`.
### Distributing a drop-in
`pm3py client --pack` spins off a **self-contained** copy for people who use the stock Iceman/RRG
Proxmark3 repo but not pm3py:
```bash
pm3py client --pack pm3cli.py # one readable file — needs `pip install prompt_toolkit pexpect`
pm3py client --pack pm3cli.pyz # a zipapp with those deps bundled — runs on bare `python3`
```
Drop the file into (or beside) a Proxmark3 checkout and run it (`python3 pm3cli.py`). It finds the
checkout's `doc/commands.json` and client automatically — walking up from where it's dropped, or via
`$PM3_REPO` — auto-detects `/dev/ttyACM*`, and gives the same console. The file is generated from
`pm3py/cli/clientcli/` (the tested source of truth), so it never drifts.
## Firmware Compatibility
On connect, pm3py pings the device and fetches the firmware version. Results are in `pm3.firmware`:
On connect, pm3py pings the device and fetches the firmware version. The result is on
`pm3.firmware`:
```python
pm3 = Proxmark3.sync()
print(pm3.firmware.compatible) # True if NG protocol works
print(pm3.firmware.version_string) # firmware version
print(pm3.firmware.compatible) # True if the NG protocol handshake worked
print(pm3.firmware.version_string) # firmware version string
print(pm3.firmware.chip_id) # e.g. "0x270B0A40"
print(pm3.firmware.warnings) # any issues detected
print(pm3.firmware.warnings) # any issues detected during probe
```
Core reader/sniff/HW commands work against **stock RfidResearchGroup/proxmark3**
firmware. The **live streaming** features (streaming sniff and the response-table /
trace card simulation) require our firmware fork, tracked as the `firmware/` git
submodule (`proxmark3-pm3py`, rebased onto upstream RfidResearchGroup/proxmark3).
Those add the `CMD_HF_SNIFF_STREAM` live-frame path and the BigBuf response-table +
WTX relay used by `SimSession`. Everything else degrades gracefully on stock firmware.
To put the fork on your device, use [`pm3flash`](#flashing-firmware-pm3flash) (below).
## Flashing firmware (`pm3flash`)
The live features (streaming sniff, response-table/trace card sim, `hw.led`) need the
**firmware fork** (`proxmark3-pm3py`, the `firmware/` submodule). pm3py flashes it for you
over USB — a pure-Python flasher, no C `pm3-flash`/DFU/JTAG. Each pm3py release is pinned to
a firmware build, and `pm3.firmware.matches_pin` tells you whether your device runs it.
```bash
pm3flash # detect the device; flash the pinned firmware if it differs
pm3flash --image fullimage.elf # flash a specific prebuilt image
pm3flash --build PM3GENERIC # build from the submodule (Easy), then flash
pm3flash --force # flash even if the device already matches the pin
```
`pm3flash` auto-detects the port, compares the device firmware to the pin, then (fullimage
only) flashes and verifies. It's **bootrom-safe** — it never writes the bootloader, so a bad
flash is recoverable, not a brick.
Image resolution order: `--image`, then `$PM3PY_FIRMWARE`, then a local build at
`firmware/armsrc/obj/fullimage.elf`. Build one with:
```bash
make -C firmware PLATFORM=PM3GENERIC armsrc/all # PM3 Easy / generic
make -C firmware PLATFORM=PM3RDV4 armsrc/all # RDV4
```
The firmware is an ARM **cross-compile** (needs `gcc-arm-none-eabi` + `make`; the FPGA
bitstream is committed, so no FPGA toolchain), and the `fullimage.elf` it produces is the
same regardless of build host. **Choose `PLATFORM` yourself** — pm3py can't infer it, because
a device's `is_rdv4` flag reports the firmware it's running, not the physical board.
**On a Raspberry Pi:** `pip install` pm3py on the Pi (works on aarch64), then either
cross-build on another Linux box and copy the image over —
```bash
make -C firmware PLATFORM=PM3GENERIC armsrc/all # dev box
scp firmware/armsrc/obj/fullimage.elf pi@rpi:~/
pm3flash --image ~/fullimage.elf # on the Pi
```
— or build on the Pi itself (with `gcc-arm-none-eabi` + `make`): `pm3flash --build PM3GENERIC`.
**Programmatic** (the CLI wraps `pm3.flasher`):
```python
pm3 = Proxmark3.sync()
pm3.firmware.matches_pin # False -> not on the pinned build
pm3.firmware.expected_firmware # the pinned firmware id
pm3.flasher.flash("firmware/armsrc/obj/fullimage.elf") # enter bootloader, write, reset, verify
```
## Architecture
```
pm3py/
├── protocol.py # Wire protocol constants, CRC-16/A, command IDs, error codes
├── transport.py # Frame encode/decode, async serial I/O, PM3Transport
├── client.py # Proxmark3 class, sync proxy, firmware probe
├── hw.py # hw.* commands
├── lf.py # lf.* commands + T55xx
├── hf.py # hf.* commands (tune, search, sniff)
├── hf_14a.py # hf.14a.* (ISO 14443-A)
├── hf_15.py # hf.15.* (ISO 15693)
── hf_mf.py # hf.mf.* (MIFARE Classic)
├── core/ # wire protocol + device command modules
│ ├── protocol.py # constants, CRC-16/A, Cmd enum, PM3Status
│ ├── transport.py # NG/MIX frame encode/decode, PM3Transport (async serial)
│ ├── client.py # Proxmark3, _SyncProxy, FirmwareInfo
├── hw.py # hw.* (ping, version, tune, LEDs, dbg, reset)
├── hf.py # hf.* core (tune, search, sniff, dropfield)
├── hf_iso14a.py # hf.iso14a.* (ISO 14443-A)
├── hf_iso15.py # hf.iso15.* (ISO 15693)
│ ├── hf_mfc.py # hf.mf.* (MIFARE Classic)
│ ├── hf_mfu.py # hf.mfu.* (MIFARE Ultralight / NTAG)
│ └── lf.py # lf.* + lf.t55.* (T55xx) + LFSearchResult
├── trace/ # firmware trace parsing + annotation (shared by sniff & sim)
│ ├── trace.py # parse_tracelog
│ ├── decode_iso14a.py / decode_iso15.py # per-protocol decoders
│ ├── ndef.py # NDEF TLV/record decode
│ └── format.py # ANSI color trace formatting
├── cli/ # `pm3py` console-script: rawcli (raw-command TUI) + clientcli (`pm3py client`)
├── sniff/ # SniffSession — protocol detection, live streaming lifecycle
├── sim/ # card-sim infrastructure: SimSession, response-table compiler,
│ # Medium/Reader models, MCU bridge, replay/relay/fuzzer
└── transponders/ # pure-Python tag models
├── hf/iso14443a/ # Tag14443A, MifareClassic, DESFire, NfcType2/4
├── hf/iso15693/ # Tag15693, NfcType5, ICODE/SLIX2/DNA/NTAG5
└── lf/ # EM4100, HID, T5577
```
- **Transport layer** handles serial I/O, CRC, NG/MIX frame encoding/decoding
- **Command modules** send structured commands and parse responses into dicts
- **USB connections** skip CRC (matching the C client behavior)
- **FPC UART connections** use CRC-16/A with byte-swapped wire format
- **`core/`** is the wire stack: `transport.py` handles serial I/O and NG/MIX frame
encode/decode; command modules send structured commands and parse responses into
dicts. USB connections skip CRC (matching the C client); FPC UART uses CRC-16/A with
byte-swapped wire encoding.
- **`trace/`** parses the firmware trace log and annotates it (per-protocol decoders +
NDEF), used by both sniff and sim.
- **`sniff/`** orchestrates protocol detection and the live `CMD_HF_SNIFF_STREAM`
streaming loop.
- **`sim/`** + **`transponders/`** implement software-defined tags served from a
firmware BigBuf response table, with a WTX relay for real-time Layer-4 crypto.
## Tests
@@ -182,4 +718,8 @@ cd pm3py
python -m pytest tests/ -v
```
All tests use mock transports — no hardware required.
All tests use mock transports — **no hardware required**.
## License
MIT — see [LICENSE](LICENSE).

View File

@@ -1,21 +1,19 @@
# pm3py venv activation with mode-based auto-imports.
# Usage: source activate — core (Proxmark3, Cmd, etc.)
# source activate sim — + SimSession, transponder models
# source activate sniff — + SniffSession, trace decoders
# source activate all — everything
# pm3py venv activation.
# Usage: source activate
#
# Then: python — REPL with auto-imports
# ipython — IPython with auto-imports (if installed)
# Then: pm3 — REPL with core imports (Proxmark3, Cmd)
# pm3 sim — + SimSession, all transponder models
# pm3 sniff — + SniffSession, trace decoders
# pm3 all — everything
_PM3PY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Activate the venv
source "$(dirname "${BASH_SOURCE[0]}")/.venv/bin/activate"
source "${_PM3PY_ROOT}/.venv/bin/activate"
# Set mode and PYTHONSTARTUP
export PM3PY_MODE="${1:-core}"
export PYTHONSTARTUP="$(dirname "${BASH_SOURCE[0]}")/.pythonstartup.py"
# pm3 command — launches REPL with mode
pm3() {
PM3PY_MODE="${1:-core}" python -i "${_PM3PY_ROOT}/.pythonstartup.py"
}
# Update prompt to show mode
PS1="(pm3py:${PM3PY_MODE}) ${_OLD_VIRTUAL_PS1:-\$ }"
export PS1
echo "pm3py env ready (mode: ${PM3PY_MODE}). Run 'python' for REPL with auto-imports."
echo "pm3py env ready. Commands: pm3, pm3 sim, pm3 sniff, pm3 all"

780
docs/NTAG5_SECURITY.md Normal file
View File

@@ -0,0 +1,780 @@
# NTAG5 Link (NTP5332) Security Configuration Reference
This document captures key technical details for implementing ISO 29167-10 AES-128 authentication on NTAG5 Link chips.
## Configuration Memory Map
### Device Security Configuration (DEV_SEC_CONFIG)
**Address:** Block 0x3F (NFC) / 0x103F (I2C)
| Byte | Name | Description |
| ---- | --------------- | --------------------------- |
| 0 | DEV_SEC_CONFIG | Security mode and lock bits |
| 1 | SRAM_CONF_PROT | SRAM and config protection |
| 2 | PP_AREA_1 (LSB) | Protection pointer area 1 |
| 3 | PP_AREA_1 (MSB) | Protection pointer area 1 |
**DEV_SEC_CONFIG Bit Definition:**
| Bits | Name | Value | Description |
| ---- | ------------- | ----- | ------------------------ |
| 7-5 | Security Lock | 010b | Locked (cannot modify) |
| | | 101b | Writable (default) |
| 4 | RFU | 00b | Reserved |
| 3-0 | NFC_SEC_MODE | 0010b | AES mode (NTP5332 only) |
| | | 0101b | Plain password (default) |
**Example Values:**
- `0xA5` = Writable + Plain password mode (factory default)
- `0xA2` = Writable + AES mode
### NFC Global Crypto Header (NFC_GCH)
**Address:** Block 0x0C (NFC) / 0x100C (I2C), **Byte 1** (other bytes are RFU)
**What it controls:** NFC_GCH defines the status/access enforcement for passwords (in password mode), keys, protection pointer & conditions, key headers, key privileges, crypto configuration header, and EAS/AFI protection.
**IMPORTANT:** NFC_GCH is **one-way programmable** (lower → higher values only) and **irreversible**.
**Allowed Values (AES Mode):** All other values are invalid.
| Value | Status | Description |
| ----- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0x81 | Deactivated (default) | Protection pointer/conditions not activated; user memory R/W possible without prior mutual auth; PP/PPC can be modified via PROTECT PAGE; keys & privileges are readable/writable via READ/WRITE CONFIG according to each key's KH status; EAS/AFI protection not activated |
| 0x87 | Deactivated & privileges locked | Same as 0x81, but Key Privileges are locked (cannot be modified). EAS/AFI still not protected |
| 0xC1 | Access right activated | Protection pointer/conditions enabled; user-memory R/W protection enforced per configuration; keys/privileges still readable/writable according to KH status; EAS/AFI protection enabled |
| 0xC7 | Access right activated & privileges locked | Same as 0xC1, but Key Privileges are locked |
| 0xE7 | Activated (final) | Protection enabled; all Key Headers/Privileges/Keys are locked (cannot be modified); EAS/AFI protection enabled |
**Allowed Values (Plain Password Mode):**
| Value | Status | Description |
| ----- | ------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| 0x81 | Writable (default) | Passwords readable/writable via READ/WRITE CONFIG |
| 0xE7 | Locked | Passwords not readable/writable via READ/WRITE CONFIG (note: separate LOCK PASSWORD needed to lock passwords permanently) |
### NFC Crypto Configuration Header (NFC_CCH)
**Address:** Block 0x0D (NFC) / 0x100D (I2C), **Byte 1** (other bytes are RFU)
**What it controls:** NFC_CCH controls whether the NFC Authentication Limit can be changed freely or only after authentication. This is the "global knob" for Authentication Limit behavior (not general access-right enforcement).
**Allowed Values:** All other values are invalid.
| Value | Status | Description |
| ----- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| 0x81 | Unlocked (default) | Authentication limit can be modified freely |
| 0xE7 | Locked | Authentication limit can only be modified after mutual auth with a key that has the Crypto Config privilege (bit 5 of NFC_KPx) |
### NFC Key Headers (NFC_KHx)
**Addresses:** Key headers live in blocks 0x10/0x12/0x14/0x16, specifically in **Byte 1** of each block (other bytes are RFU).
| Key | Block (NFC) | Block (I2C) | Byte Position |
| ------- | ----------- | ----------- | ------------- |
| NFC_KH0 | 0x10 | 0x1010 | Byte 1 |
| NFC_KH1 | 0x12 | 0x1012 | Byte 1 |
| NFC_KH2 | 0x14 | 0x1014 | Byte 1 |
| NFC_KH3 | 0x16 | 0x1016 | Byte 1 |
**Key Header Values:**
| Value | Status | Description |
| ----- | -------------------- | ------------------------------------------------------------- |
| 0x81 | Not active (default) | Key can be read/written but CANNOT be used for authentication |
| 0xE7 | Active and locked | Key is active and locked - can be used for authentication |
| 0xFF | Disabled | Key slot is disabled (NXP recommends disabling unused keys) |
**IMPORTANT:**
- Keys with header 0x81 cannot be used for authentication
- The header must be set to 0xE7 to activate the key
- Once 0xE7 is set, the key value cannot be modified
- Key headers are **one-way** (lower→higher values only) and **irreversible**
### NFC Key Privileges (NFC_KPx)
**Addresses:** Key privileges live in blocks 0x11/0x13/0x15/0x17, specifically in **Byte 0** of each block (other bytes are RFU).
| Key | Block (NFC) | Block (I2C) | Byte Position |
| ------- | ----------- | ----------- | ------------- |
| NFC_KP0 | 0x11 | 0x1011 | Byte 0 |
| NFC_KP1 | 0x13 | 0x1013 | Byte 0 |
| NFC_KP2 | 0x15 | 0x1015 | Byte 0 |
| NFC_KP3 | 0x17 | 0x1017 | Byte 0 |
**NFC_KPx Bit Meanings (AES mode):**
Each NFC_KPx is a 1-byte bitmask. Bit = 1 grants that capability after successful mutual authentication using KeyID = x.
| Bit | Name | Privilege Granted |
| --- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| 7 | Restricted AREA_1 Write | Write access to restricted user memory AREA_1 |
| 6 | Restricted AREA_1 Read | Read access to restricted user memory AREA_1 |
| 5 | Crypto Config | Preset of Authentication Limit |
| 4 | EAS/AFI | Access to write-alike EAS/AFI commands: PROTECT EAS/AFI, SET EAS, RESET EAS, LOCK EAS, WRITE EAS ID, WRITE AFI, LOCK AFI |
| 3 | Destroy | Access to DESTROY functionality |
| 2 | Privacy | Enable/disable PRIVACY mode |
| 1 | Write | Write access to the protected user memory area |
| 0 | Read | Read access to the protected user memory area |
**Common Privilege Values:**
- `0x03` = Read + Write access to protected memory
- `0x07` = Read + Write + Privacy control
- `0xFF` = All privileges enabled
### AES Key Storage
**Key Addresses (per NTP53x2 datasheet page 16):**
| Key | Blocks (NFC) | Blocks (I2C) | Size |
| ---- | ------------ | ------------- | ------------------- |
| KEY0 | 0x20-0x23 | 0x1020-0x1023 | 16 bytes (4 blocks) |
| KEY1 | 0x24-0x27 | 0x1024-0x1027 | 16 bytes |
| KEY2 | 0x28-0x2B | 0x1028-0x102B | 16 bytes |
| KEY3 | 0x2C-0x2F | 0x102C-0x102F | 16 bytes |
**NOTE:** Addresses 0x1E-0x1F are RFU (reserved), NOT key storage!
**Default Key Value:** Unknown - likely not all zeros. Factory default may vary.
**Key Write Requirements:**
- Must enable AES mode (DEV_SEC_CONFIG = 0xA2) before writing keys
- Keys can be written and read back while header is 0x81 (not active)
- Once a key header is set to 0xE7, the key storage becomes unreadable
- Always verify key values by reading back before activating headers
## Safe Programming Order
This order prevents locking yourself out when provisioning AES mode on an NTP5332.
### Step 1: Keep NFC_GCH at 0x81 (Deactivated)
While NFC_GCH = 0x81:
- Protection settings aren't enforced yet
- Protection pointer/conditions can be modified via PROTECT PAGE without prior mutual auth
- Keys and privileges are freely readable/writable
**WARNING:** NFC_GCH only programs one-way (lower→higher) and is irreversible. Don't jump to C1/C7/E7 until you've tested.
### Step 2: Set Up Memory Protection Layout
Configure these while GCH is still 0x81:
- Protection Pointer Address
- Protection Pointer Condition
- Any EAS/AFI protection conditions you plan to use
### Step 3: Write Key Privileges and Keys
While each NFC_KHx is still 0x81 (Not active):
- Write NFC_KPx (Key Privileges) to define what each key can authorize
- Write the 128-bit key values to storage blocks (0x20-0x2F)
- Key and privileges are readable/writable via READ/WRITE CONFIG
- Key is not active for authentication yet
**Key Storage Addresses (per datasheet page 16):**
- KEY0: blocks 0x20-0x23
- KEY1: blocks 0x24-0x27
- KEY2: blocks 0x28-0x2B
- KEY3: blocks 0x2C-0x2F
### Step 4: Activate/Lock Only the Keys You Need
For each key you intend to use:
- Set NFC_KHx → 0xE7 (Active and locked)
- Key becomes usable for authentication
- Key material can no longer be read/written via READ/WRITE CONFIG
For unused keys (NXP recommendation):
- Set NFC_KHx → 0xFF (Disabled)
- Prevents unused key slots from being exploited
**WARNING:** Key headers are one-way (lower→higher) and irreversible.
### Step 5: Enable Enforcement
Set NFC_GCH → 0xC1 (Access right activated):
- Read/write protection becomes enforced according to your settings
- EAS/AFI protection becomes enforced
- Keys can still be modified if their headers are still 0x81
### Step 6: Test Everything
Verify your protection configuration:
- Try protected reads/writes WITHOUT mutual auth (should be denied)
- Mutual-auth with each KeyID and confirm only the privileges in that key's KPx work
- Test all access patterns you need
### Step 7: (Optional) Freeze Configuration
Only after thorough testing:
- NFC_GCH → 0xC7: Locks privileges, keeps enforcement, keys still modifiable per their KH status
- NFC_GCH → 0xE7: "Finalize" state - enforcement on, all key headers/privileges/keys locked forever
### Step 8: (Optional) Lock Authentication Limit
Only when you're sure about auth-limit behavior:
- Set NFC_CCH → 0xE7 to lock the authentication-limit setting
- After this, auth limit can only be modified with a key that has Crypto Config privilege (bit 5)
### Footgun to Avoid
**Authentication Limit:** If you enable the Authentication Limit feature (block 0x0E), hitting the terminal failure count causes **permanent, irreversible lockout** of that key slot. Set a reasonable limit or leave at 0 (unlimited) during development.
## Legacy Key Provisioning (Simple Version)
For simple use cases (single key, no protection pointer):
1. **Ensure AES Mode is Enabled**
- Read DEV_SEC_CONFIG at 0x3F
- If bits 3-0 != 0010b, write new value with AES mode enabled
- Power cycle the chip (remove and replace on reader)
2. **Write Key Value**
- Write 16 bytes to KEY0 blocks 0x20-0x23
- Key can only be written when NFC_KH0 is 0x81
3. **Activate Key Header**
- Write 0xE7 to block 0x10 byte 1
- This is IRREVERSIBLE - key is locked after this
4. **(Optional) Activate NFC_GCH**
- Write 0xC1 or 0xE7 to enable protection pointer enforcement
- 0xE7 locks all crypto configuration permanently
## Authentication Protocol
### COMPLETE MAM AUTHENTICATION (VERIFIED WORKING 2026-02-13)
**NTAG5 Link uses UNDOCUMENTED AuthMethod values for MAM:**
| Value | Method | Result |
| ----- | --------------- | -------------------------------------------------- |
| 0x00 | TAM1 (Tag Auth) | SUCCESS - 18 byte response |
| 0x02 | **MAM1** | SUCCESS - 24 byte response |
| 0x06 | **MAM2** | SUCCESS - empty response (authentication complete) |
| 0x80 | MAM1 (per ISO) | Error 0x0F (NOT SUPPORTED) |
| 0x90 | MAM2 (per ISO) | Error 0x0F (NOT SUPPORTED) |
**CRITICAL BYTE ORDERING RULES:**
All data to/from the chip must be byte-reversed:
- IChallenge sent to chip: **REVERSED**
- TChallenge_high from response: **REVERSED**
- IResponse sent to chip: **REVERSED**
### MAM1 (AuthMethod = 0x02)
**Command Format:**
```
FLAGS(0x22) | CMD(0x35) | UID(8) | CSI(0x00) | AuthMethod(0x02) | KeyID | IChallenge[::-1]
```
- IChallenge: 10 bytes, must be **REVERSED** before sending
**Response Structure (24 bytes):**
| Offset | Length | Content |
| ------ | ------ | ---------------------------------------------- |
| 0 | 1 | Flags: 0x04 |
| 1 | 1 | Header: 0xA7 |
| 2-7 | 6 | TChallenge[79:32] (plaintext, needs reversal!) |
| 8-23 | 16 | Encrypted block |
**Decryption Procedure:**
1. Extract TChallenge_high_raw = bytes 2-7 (6 bytes)
2. Extract encrypted block = bytes 8-23 (16 bytes)
3. **REVERSE** encrypted block, then AES-ECB decrypt
4. Parse decrypted: `C_MAM1(2) || TChallenge[31:0](4) || IChallenge(10)`
5. **REVERSE** TChallenge_high_raw to get actual TChallenge_high
6. TChallenge[79:0] = TChallenge_high + TChallenge_low
**Verified Constants:**
- C_MAM1 = 0xDA83
- IChallenge in decrypted = original IChallenge (chip de-reverses what it receives)
### MAM2 (AuthMethod = 0x06)
**Command Format (NO KeyID!):**
```
FLAGS(0x22) | CMD(0x35) | UID(8) | CSI(0x00) | AuthMethod(0x06) | IResponse[::-1]
```
- IResponse: 16 bytes, must be **REVERSED** before sending
**IResponse Computation:**
```python
# Build plaintext (16 bytes)
C_MAM2_PURPOSE = bytes([0xDA, 0x80]) # C_MAM2[11:0] || Purpose[3:0]
IChallenge_31_0 = original_ichallenge[6:10] # Last 4 bytes of ORIGINAL
TChallenge_79_0 = tc_high_reversed + tc_low # From MAM1 response
plaintext = C_MAM2_PURPOSE + IChallenge_31_0 + TChallenge_79_0
# Compute IResponse
iresponse_to_send = AES_DEC(key, plaintext)[::-1] # Decrypt then REVERSE
```
**Expected Response:** Empty (just flags/header, no data) = SUCCESS
### Complete MAM Example (Python)
```python
from Crypto.Cipher import AES
def perform_mam(hcard, proto, uid_lsb, key, key_id, ichallenge):
cipher = AES.new(key, AES.MODE_ECB)
# MAM1: Send IChallenge REVERSED
mam1_cmd = bytes([0x22, 0x35]) + uid_lsb + bytes([0x00, 0x02, key_id]) + ichallenge[::-1]
mam1_resp = send_command(hcard, proto, mam1_cmd)
# Parse MAM1 response
tc_high_raw = mam1_resp[2:8] # Needs reversal!
encrypted = mam1_resp[8:24]
decrypted = cipher.decrypt(encrypted[::-1])
assert decrypted[0:2] == bytes([0xDA, 0x83]) # C_MAM1
tc_high = tc_high_raw[::-1] # REVERSE!
tc_low = decrypted[2:6]
tc_full = tc_high + tc_low
# MAM2: Build IResponse
C_MAM2_PURPOSE = bytes([0xDA, 0x80])
ich_31_0 = ichallenge[6:10] # Original, not reversed
plaintext = C_MAM2_PURPOSE + ich_31_0 + tc_full
iresponse = cipher.decrypt(plaintext)[::-1] # Decrypt then REVERSE
# MAM2: No KeyID in command!
mam2_cmd = bytes([0x22, 0x35]) + uid_lsb + bytes([0x00, 0x06]) + iresponse
mam2_resp = send_command(hcard, proto, mam2_cmd)
return mam2_resp is empty or success
```
**Verified Working (2026-02-13):** All 4 key slots (KEY0-KEY3) authenticate successfully using this protocol.
**Standard MAM1/MAM2 (0x80/0x90) fail** with Error 0x0F despite the chip's GET NXP SYSTEM INFORMATION reporting MUTUAL AUTH = 1. Use AuthMethod 0x02/0x06 instead.
---
### ISO 29167-10 Standard MAM (AuthMethod 0x80/0x90) - NOT SUPPORTED
**NOTE:** The standard ISO 29167-10 AuthMethod values (0x80 for MAM1, 0x90 for MAM2) are **NOT supported** by NTAG5 Link hardware, despite documentation suggesting otherwise.
Use the undocumented AuthMethod values instead:
- **AuthMethod 0x02** for MAM1
- **AuthMethod 0x06** for MAM2 (no KeyID in command)
See "Complete MAM Authentication" section above for the working protocol.
## TAM1 Authentication (Tag Authentication Method 1)
TAM1 is a one-way authentication where the reader verifies the tag's identity. It uses the CHALLENGE (0x39) and READBUFFER (0x3A) commands instead of the full mutual authentication.
### TAM1 Protocol Flow
1. **Reader sends CHALLENGE** - Contains KeyID and random IChallenge
2. **Tag computes AES encryption** - No response sent
3. **Reader sends READBUFFER** - Requests the crypto result
4. **Tag returns TResponse** - 16-byte encrypted block
5. **Reader decrypts and verifies** - Checks C_TAM1 constant and IChallenge
### TAM1 Message Format
**CHALLENGE Command (0x39):**
```
Flags | 0x39 | CSI | AuthMethod/RFU | KeyID | IChallenge (10 bytes)
```
- Flags: 0x02 (high data rate, NOT addressed)
- CSI: 0x00 (AES-128 Crypto Suite Identifier)
- AuthMethod/RFU: 0x00 (TAM1 = 00b, CustomData = 0, RFU = 00000b)
- KeyID: 0x00-0x03 (KEY0-KEY3)
- IChallenge: 10 bytes random challenge
**READBUFFER Command (0x3A):**
```
Flags | 0x3A
```
- Flags: 0x02 (high data rate, NOT addressed)
**TResponse Format (16 bytes):**
```
AES-ECB-ENC(Key, C_TAM1 || TRnd || IChallenge)
```
- C_TAM1: 0x96C5 (2 bytes) - constant per ISO 29167-10
- TRnd: 4 bytes random from tag
- IChallenge: 10 bytes echoed from CHALLENGE command
### TAM1 Verification
To verify TAM1:
1. **Reverse the TResponse byte order** (ACR1552 returns data in LSB-first order)
2. Decrypt TResponse with the expected key using AES-128-ECB
3. Check bytes 0-1 equal 0x96C5 (C_TAM1 constant)
4. **Reverse the echoed IChallenge** (bytes 6-15) before comparing
5. Check reversed bytes 6-15 equal the IChallenge you sent
6. If both match, the tag is authentic
**CRITICAL: ACR1552 Byte Ordering**
The ACR1552 returns TResponse in reversed byte order due to ISO 15693's LSB-first transmission.
Both the entire TResponse and the echoed IChallenge within the decrypted data must be reversed.
```python
# Example verification (Python)
tresponse_raw = resp[1:17] # Skip flags byte
tresponse = tresponse_raw[::-1] # Reverse entire TResponse
cipher = AES.new(key, AES.MODE_ECB)
decrypted = cipher.decrypt(tresponse)
c_tam1 = decrypted[0:2] # Should be 96C5
trnd = decrypted[2:6] # 4 bytes random from tag
echoed_raw = decrypted[6:16] # Echoed IChallenge (reversed)
echoed = echoed_raw[::-1] # Reverse to get original order
if c_tam1 == bytes([0x96, 0xC5]) and echoed == ichallenge:
# Authentication successful
```
### ACR1552 Complete APDU Format for TAM1
The ACR1552 uses pseudo-APDUs with TLV wrapping for transparent mode communication.
**PCSC APDU Structure:**
```
FF C2 00 01 Lc [TLV Data] 00
```
- CLA: 0xFF (pseudo-APDU)
- INS: 0xC2 (transparent exchange)
- P1: 0x00
- P2: 0x01 (exchange function)
- Lc: Length of TLV data
- Le: 0x00
**TLV Data Structure:**
```
5F46 04 [timeout_4bytes] Timeout in microseconds (big-endian)
FF6E 03 03 01 0F FWTI (Frame Waiting Time Integer)
95 xx [iso15693_command] ISO 15693 command payload
```
**Response TLV Structure:**
```
C0 03 [status_3bytes] Error/status TLV (SW1 SW2 at bytes 1-2)
92 01 xx Framing TLV
96 02 xx xx Status TLV
97 xx [iso15693_response] Response data TLV (includes flags byte)
```
- SW = 0x9000: Success
- SW = 0x6401: Execution error (no response from ICC)
### Complete APDU Examples (Working TAM1 Flow)
**Example 1: GET SYSTEM INFO (required before CHALLENGE)**
```
TX: ffc20001115f4604000f4240ff6e0303010f9502022b00
├─ FF C2 00 01 11 PCSC header (Lc=17)
├─ 5F46 04 000F4240 Timeout = 1,000,000 µs (1 second)
├─ FF6E 03 030100F FWTI
├─ 95 02 022B ISO15693: Flags=02, Cmd=2B (GET SYSTEM INFO)
└─ 00 Le
RX: c00300900092010096020000970f000f02e88c59580104e00000ff0301
├─ C0 03 009000 Status OK (SW=9000)
├─ 92 01 00 Framing OK
├─ 96 02 0000 Status OK
└─ 97 0F 00... Response: Flags=00 + System Info (14 bytes)
```
**Example 2: CHALLENGE (KeyID=3, IChallenge=33ccc451ad4d9a6e9cd6)**
```
TX: ffc200011e5f4604000f4240ff6e0303010f950f023900000333ccc451ad4d9a6e9cd600
├─ FF C2 00 01 1E PCSC header (Lc=30)
├─ 5F46 04 000F4240 Timeout = 1 second
├─ FF6E 03 03010F FWTI
├─ 95 0F 02 39 00 00 03 33ccc451ad4d9a6e9cd6
│ ├─ 02 Flags (high data rate)
│ ├─ 39 CHALLENGE command
│ ├─ 00 CSI (AES-128)
│ ├─ 00 AuthMethod=TAM1
│ ├─ 03 KeyID=3
│ └─ 33ccc451ad4d9a6e9cd6 IChallenge (10 bytes)
└─ 00 Le
RX: c003036401
└─ C0 03 036401 Status: SW=6401 (no response from ICC)
This is EXPECTED - CHALLENGE has no response
```
**Example 3: READBUFFER**
```
TX: ffc20001115f4604000f4240ff6e0303010f9502023a00
├─ FF C2 00 01 11 PCSC header (Lc=17)
├─ 5F46 04 000F4240 Timeout = 1 second
├─ FF6E 03 03010F FWTI
├─ 95 02 02 3A ISO15693: Flags=02, Cmd=3A (READBUFFER)
└─ 00 Le
RX: c00300900092010096020000971102238333363a3142b26328d1bf618362e4
├─ C0 03 009000 Status OK
├─ 92 01 00 Framing OK
├─ 96 02 0000 Status OK
└─ 97 11 02 238333363a3142b26328d1bf618362e4
├─ 02 Flags (success, no error)
└─ 238333...e4 TResponse (16 bytes)
```
### Critical Timing Requirement
**IMPORTANT:** The chip requires time to compute the AES encryption after receiving CHALLENGE.
| Scenario | READBUFFER Result |
| ----------------------------------------------------- | ------------------------- |
| READBUFFER sent immediately after CHALLENGE | Often fails (no response) |
| GET SYSTEM INFO sent between CHALLENGE and READBUFFER | Works (implicit delay) |
| 1 second delay between CHALLENGE and READBUFFER | Works reliably |
**Recommended approach:** Either:
1. Send any command (e.g., GET SYSTEM INFO) after CHALLENGE before READBUFFER, OR
2. Add a minimum 50-100ms delay after CHALLENGE before sending READBUFFER
The crypto calculation typically takes ~4-8ms, but reader/driver overhead can cause timing issues if READBUFFER is sent too quickly.
### READBUFFER Failure Example (Insufficient Delay)
When CHALLENGE is sent immediately after connection with no prior commands and READBUFFER follows too quickly:
```
TX: ffc200011e5f4604000f4240ff6e0303010f950f0239000003[IChallenge]00
RX: c003036401 (SW=6401, expected - no response for CHALLENGE)
TX: ffc20001115f4604000f4240ff6e0303010f9502023a00
RX: c003036401 (SW=6401, FAILURE - chip not ready)
```
The second `c003036401` response indicates "no response from ICC" - the chip hasn't finished computing the crypto result yet. Adding a delay or sending an intermediate command resolves this.
### TAM1 vs State Machine
Per the datasheet, CHALLENGE can only be executed in READY state with non-addressed mode. However, testing shows:
- CHALLENGE works even after GET SYSTEM INFO (which puts chip in SELECTED state)
- The critical factor is the **timing delay**, not the chip state
- Sending GET SYSTEM INFO first provides the necessary delay for crypto completion
## Reader-Specific Notes
### ACS ACR1552
- Uses transparent mode with TLV-wrapped commands
- Supports ISO15693 natively
- Switch protocol command: `8F 02 02 03` (ISO15693 Layer 3)
- Timeout TLV: `5F46` with 4-byte timeout in microseconds
- Commands work correctly for basic operations
- CHALLENGE returns SW=6401 (no response from ICC) - this is expected
- READBUFFER returns SW=9000 with 17-byte response (flags + 16-byte TResponse)
- Authentication returns error 0x0F when key is not properly configured
**CRITICAL: Byte Order Reversal Required**
- TResponse data is returned in LSB-first (reversed) byte order
- Must reverse entire TResponse before AES decryption
- Echoed IChallenge in decrypted data is also reversed
- See TAM1 Verification section for correct processing order
- AUTHENTICATE (0x35) command returns "no ICC response" - use CHALLENGE/READBUFFER instead
### HID Omnikey 5022 CL
- Uses transceive APDU: `FF 68 0E 03`
- 10-byte header: TxRxFlags | ValidBits | Timeout(4) | RFU(4)
- TxRxFlags: 0x05 for ISO15693 (CRC TX + CRC RX enabled)
- Response format: STS(1) | RxB(1) | Card_Response(n) | SW(2)
- Direct transceive works WITHOUT entering transparent session first
- ISO15693 transparent session may not work reliably - use direct transceive
**AUTHENTICATE (0x35) for TAM1 - CONFIRMED WORKING EXAMPLE:**
TAM1 command format (AuthMethod = 0x00):
```
Flags(22) | Cmd(35) | UID(8) | CSI(00) | AuthMethod(00) | KeyID | IChallenge(10)
```
**Verified Example (2026-02-13):**
Input parameters:
- Chip UID: E0040158F08BE802 (MSB) / 02E88BF0580104E0 (LSB)
- KEY0: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
- IChallenge: 112233445566778899AA
Command sent to reader:
```
FF680E03210500004C4B4000000000223502E88BF0580104E0000000112233445566778899AA00
```
| Bytes | Value | Meaning |
| -------------------- | ---------- | -------------------- |
| FF680E03 | Header | Omnikey transceive |
| 21 | Lc=33 | Data length |
| 05 | TxRxFlags | ISO15693 CRC |
| 00 | ValidBits | Whole bytes |
| 004C4B40 | Timeout | 5 seconds |
| 00000000 | RFU | Reserved |
| 22 | Flags | Addressed, high rate |
| 35 | Cmd | AUTHENTICATE |
| 02E88BF0580104E0 | UID | LSB order |
| 00 | CSI | AES-128 |
| 00 | AuthMethod | TAM1 |
| 00 | KeyID | KEY0 |
| 112233445566778899AA | IChallenge | 10 bytes |
| 00 | Le | Response length |
Response received:
```
000006A7F2E114AFC63A0F6A7170A4F1692DD29F9000
```
| Bytes | Value | Meaning |
| -------------------------------- | --------- | ------------------ |
| 0000 | STS+RxB | Transceive OK |
| 06 | Flags | Success |
| A7 | Header | Barker code (skip) |
| F2E114AFC63A0F6A7170A4F1692DD29F | TResponse | 16 bytes encrypted |
| 9000 | SW | Success |
**Decryption process:**
1. Extract TResponse: `F2E114AFC63A0F6A7170A4F1692DD29F`
2. REVERSE bytes: `9FD22D69F1A470716A0F3AC6AF14E1F2`
3. AES-ECB decrypt with KEY0: `96C51B6F50BBAA998877665544332211`
4. Parse result:
- C_TAM1: `96C5` (correct constant)
- TRnd: `1B6F50BB` (tag random)
- Echoed: `AA998877665544332211` (IChallenge reversed)
**VERIFICATION: SUCCESS** - C_TAM1=96C5, IChallenge matches (reversed)
```python
# Omnikey 5022 TAM1 verification (AuthMethod=0x00)
card_resp = response[2:-2] # Strip STS, RxB, and SW
data_after_flags = card_resp[1:] # Skip flags byte (0x06)
tresponse = data_after_flags[1:17] # Skip header byte (0xA7), take 16 bytes
tresponse = tresponse[::-1] # Reverse byte order
cipher = AES.new(key, AES.MODE_ECB)
decrypted = cipher.decrypt(tresponse)
c_tam1 = decrypted[0:2] # Should be 96C5
trnd = decrypted[2:6] # 4-byte tag random
echoed = decrypted[6:16] # REVERSED - compare against ichallenge[::-1]
if c_tam1 == bytes([0x96, 0xC5]) and echoed == ichallenge[::-1]:
# TAM1 authentication successful
```
## Error Codes
### ISO15693 Error Codes (from flag byte with error bit set)
| Code | Description |
| ---- | ---------------------- |
| 0x01 | Command not supported |
| 0x02 | Command not recognized |
| 0x03 | Option not supported |
| 0x0F | Unknown error |
| 0x10 | Block not available |
| 0x11 | Block already locked |
| 0x12 | Block locked |
| 0x13 | Programming failed |
| 0x14 | Locking failed |
**Error 0x0F during authentication typically means:**
- Key header not activated (still 0x81 instead of 0xE7)
- Wrong key slot used
- Chip not in AES mode
- Malformed MAM1 message
## READ CONFIG Command Requirements
**IMPORTANT:** READ CONFIG (0xC0) must use **unaddressed mode** (flags=0x02).
**Correct Format:**
```
Flags | Cmd | MfgCode | BlockAddr | NumBlocks
0x02 | 0xC0 | 0x04 | addr | 0x00
```
Example: `bytes([0x02, 0xC0, 0x04, 0x3F, 0x00])` to read DEV_SEC_CONFIG
**Wrong Format (does not work):**
```
Flags | Cmd | MfgCode | UID (8 bytes) | BlockAddr
0x22 | 0xC0 | 0x04 | uid_lsb | addr
```
Using addressed mode (flags=0x22 with UID) returns no response from the chip.
## Common Issues
1. **"Unknown error" (0x0F) on AUTHENTICATE**
- Check DEV_SEC_CONFIG is in AES mode (bits 3-0 = 0010b)
- Check Key Header is 0xE7 (active and locked)
- Power cycle chip after mode changes
2. **Key Header reads as 0x00**
- Incorrect address used - Key headers are at 0x10, 0x12, 0x14, 0x16
- Block 0x0E is NFC_AUTH_LIMIT, not KEY0 header
3. **Cannot write key after activation**
- Once key header is 0xE7, key is permanently locked
- Must set key value BEFORE activating header
4. **Mode change doesn't take effect**
- Configuration changes require power cycle (POR)
- Remove card from reader field and place again
5. **Keys must be written in AES mode**
- Enable AES mode (DEV_SEC_CONFIG = 0xA2) before writing key values
- Power cycle after enabling AES mode, then write keys
- Keys written while in password mode may not work correctly for authentication
6. **TAM1/MAM1 authentication fails despite correct key**
- Key values verified by read-back but authentication still fails
- This is a known issue under investigation with NXP (open support ticket)
- Ensure chip is in AES mode before writing keys
## Troubleshooting Checklist
Before attempting authentication:
1. **Verify AES Mode:**
```
Read config block 0x3F
DEV_SEC_CONFIG byte 0, bits 3-0 should be 0010b
Example: 0xA2 = AES mode enabled, writable
```
2. **Verify Key Header is Active:**
```
Read config block 0x10 (KEY0), 0x12 (KEY1), 0x14 (KEY2), or 0x16 (KEY3)
Byte 1 should be 0xE7 for the key you want to use
0x81 = not active (cannot authenticate)
```
3. **Verify Key Value is Known:**
```
If key header was 0x81 before you configured it:
- Was AES mode enabled BEFORE writing key values?
- Did read-back verification match what you wrote?
- After activating header to 0xE7, key blocks should return errors (protected)
```
4. **Check Auth Failure Counter:**
```
Read config block 0x0E
NFC_AUTH_LIMIT (bits 9-0) sets max failures before lockout
If counter reaches limit, key slot is permanently blocked
```
5. **Test with Correct Command Format:**
```
AUTHENTICATE: 0x22 0x35 <UID_LSB_8bytes> 0x00 0x80 <key_slot> <challenge_10bytes>
- FLAGS = 0x22 (high data rate, addressed mode) - REQUIRED
- CSI = 0x00 (AES-128)
- MAM1_Header = 0x80
```

258
docs/TRANSPONDERS.md Normal file
View File

@@ -0,0 +1,258 @@
# pm3py Simulated Transponders — Reference & Comparison
Every transponder model that `pm3py` can simulate, organized by RF frequency and
protocol, with feature-comparison tables. Import any model from `pm3py.sim`:
```python
from pm3py.sim import NTAG216, ST25DV16K, OptigaAuthenticateNBT, SimSession
```
Memory figures are **user memory** (excludes UID/lock/config/OTP pages) per the
manufacturer datasheet. "Faithful" models reproduce the documented command set,
UID prefix, and access rules; a ✓ in a feature column means the model implements
that behaviour (not merely that the silicon has it).
Legend: **T2/T4/T5** = NFC Forum Tag type · **A/B/V** = ISO 14443-A / 14443-B /
15693 (NFC-V) · **DI** = dual interface (RF + wired I²C/SPI) · **EH** = energy
harvesting · **—** = not applicable / not present.
---
## 1. Master list — by frequency and protocol
| Model(s) | Vendor | Freq | Protocol | NFC type | Module |
|---|---|---|---|---|---|
| MIFARE Ultralight / C / EV1 (MF0UL11/21) | NXP | 13.56 MHz | ISO 14443-A | T2 | `iso14443a/nxp/ultralight` |
| NTAG210/212/213/215/216 | NXP | 13.56 MHz | ISO 14443-A | T2 | `iso14443a/nxp/ntag21x` |
| NTAG I²C plus (NT3H2111/2211) | NXP | 13.56 MHz | ISO 14443-A | T2 + I²C | `iso14443a/nxp/ntag_i2c` |
| MIFARE Classic 1K/4K | NXP | 13.56 MHz | ISO 14443-A | — (Crypto1) | `iso14443a/nxp/mifare_classic` |
| MIFARE DESFire EV1/2/3 | NXP | 13.56 MHz | ISO 14443-A | T4 | `iso14443a/nxp/desfire` |
| ST25TN512/01K | ST | 13.56 MHz | ISO 14443-A | T2 | `iso14443a/st/st25tn` |
| ST25TA512B/02KB/16K/64K | ST | 13.56 MHz | ISO 14443-A | T4 | `iso14443a/st/st25ta` |
| OPTIGA Authenticate NBT | Infineon | 13.56 MHz | ISO 14443-A | T4 + I²C | `iso14443a/infineon/optiga_nbt` |
| ST25TB512-AC/AT, 02K, 04K | ST | 13.56 MHz | ISO 14443-B (SRIX) | — | `iso14443b/st/st25tb` |
| RF430CL330H | TI | 13.56 MHz | ISO 14443-B | T4 + I²C | `iso14443b/ti/rf430cl330h` |
| ICODE SLIX / SLIX2 / 3 / DNA | NXP | 13.56 MHz | ISO 15693 | T5 | `iso15693/nxp/icode_*` |
| NTAG 5 Switch / Link / Boost | NXP | 13.56 MHz | ISO 15693 | T5 (+I²C on Link/Boost) | `iso15693/nxp/ntag5_*` |
| ST25TV512C/02KC | ST | 13.56 MHz | ISO 15693 | T5 | `iso15693/st/st25tv` |
| ST25DV04K/16K/64K | ST | 13.56 MHz | ISO 15693 | T5 + I²C | `iso15693/st/st25dv` |
| my-d vicinity SRF55V02P/10P | Infineon | 13.56 MHz | ISO 15693 | T5 | `iso15693/infineon/myd_vicinity` |
| Tag-it HF-I Plus | TI | 13.56 MHz | ISO 15693 | T5 | `iso15693/ti/tagit` |
| RF430FRL152/153/154H | TI | 13.56 MHz | ISO 15693 + sensor | T5 + I²C/SPI | `iso15693/ti/rf430frl` |
| EM4100 | EM | 125 kHz | LF ASK | — | `lf/em/em4100` |
| HID Prox | HID | 125 kHz | LF FSK (Wiegand) | — | `lf/hid/hid` |
| T5577 | Atmel | 125 kHz | LF (multi) | — | `lf/atmel/t5577` |
Generic bases you can subclass: `NfcType2Tag` / `NfcType4Tag` (14443-A),
`NfcType5Tag`/`Tag15693` (15693), `Tag14443B` (SRIX) / `Tag14443B_4` (standard
Type B), `TagLF`.
---
## 2. HF · ISO 14443-A · NFC Forum Type 2 (Ultralight / NTAG)
| Model | User mem | Pages | GET_VERSION | FAST_READ | PWD_AUTH | Counter | Signature | Extra |
|---|---|---|---|---|---|---|---|---|
| MIFARE Ultralight (MF0ICU1) | 48 B | 16 | — | — | — | — | — | OTP, COMPAT_WRITE |
| MIFARE Ultralight C (MF0ICU2) | 144 B | 48 | — | — | — | 16-bit | — | **3DES AUTHENTICATE** |
| Ultralight EV1 MF0UL11 | 48 B | 20 | ✓ | ✓ | 32-bit | 3 × 24-bit | ✓ | INCR_CNT, tearing |
| Ultralight EV1 MF0UL21 | 128 B | 41 | ✓ | ✓ | 32-bit | 3 × 24-bit | ✓ | INCR_CNT, tearing |
| NTAG210 | 48 B | 20 | ✓ | ✓ | 32-bit | — | ✓ | — |
| NTAG212 | 128 B | 41 | ✓ | ✓ | 32-bit | — | ✓ | — |
| NTAG213 | 144 B | 45 | ✓ | ✓ | 32-bit | 1 × 24-bit NFC | ✓ | mirror |
| NTAG215 | 504 B | 135 | ✓ | ✓ | 32-bit | 1 × 24-bit NFC | ✓ | mirror |
| NTAG216 | 888 B | 231 | ✓ | ✓ | 32-bit | 1 × 24-bit NFC | ✓ | mirror |
| NTAG I²C plus 1K (NT3H2111) | 888 B | — | ✓ | ✓ | 32-bit | — | ✓ | **SECTOR_SELECT**, 64 B SRAM, DI |
| NTAG I²C plus 2K (NT3H2211) | 1912 B | — | ✓ | ✓ | 32-bit | — | ✓ | 2 sectors, FAST_WRITE, DI |
| ST25TN512 | 64 B | 64 | — | — | — | — | TruST25 | Kill password |
| ST25TN01K | 160 B | 64 | — | — | — | — | TruST25 | Kill password |
All: ATQA `0x0044`, SAK `0x00`, 7-byte UID. NXP UID prefix `04…`; ST prefix `02…`.
NFC counter on NTAG213/215/216 auto-increments once per power cycle when enabled
(`ACCESS.NFC_CNT_EN`). Ultralight EV1 counters are read/increment (one-way) with
`CHECK_TEARING_EVENT`.
---
## 3. HF · ISO 14443-A · NFC Forum Type 4 (ISO-DEP / APDU)
| Model | NDEF | Extra files | Password | Crypto auth | Counter | Extra |
|---|---|---|---|---|---|---|
| ST25TA512B | 64 B | System | 128-bit R/W | — | — | — |
| ST25TA02KB | 256 B | System | 128-bit R/W | — | 20-bit event | TruST25, GPO (-D/-P) |
| ST25TA16K | 2 KB | System | 128-bit R/W | — | — | — |
| ST25TA64K | 8 KB | System | 128-bit R/W | — | — | *constants extrapolated* |
| OPTIGA Authenticate NBT | 4 KB | 4×1 KB proprietary + FAP | 32-bit (per-file, FAP) | **ECDSA P-256** + AES-CMAC COTT | — | DI (I²C), life-cycle |
All ISO-DEP with per-model ATS. ST25TA UID prefix `02…`; OPTIGA prefix `05…`,
ATS historical bytes = `"NBT2000"`. OPTIGA read/write access is gated by the
File Access Policy (per file, per interface); AUTHENTICATE TAG (INS `0x88`)
signs a challenge with the device's NIST P-256 key.
*MIFARE DESFire (EV1/2/3) is also Type 4 but uses the proprietary DESFire
command set + DES/3DES/AES auth — see `iso14443a/nxp/desfire`.*
---
## 4. HF · ISO 14443-B
| Model | Anticollision | Blocks × size | User | Counters | OTP area | Extra |
|---|---|---|---|---|---|---|
| ST25TB512-AC | SRIX slot-marker | 16 × 4 B | 36 B | 2 × count-down | blocks 0-4 | resettable OTP |
| ST25TB512-AT | SRIX slot-marker | 16 × 4 B | 36 B | 2 × count-down | — | plain EEPROM 0-4 |
| ST25TB02K | SRIX slot-marker | 64 × 4 B | 228 B | 2 × count-down | blocks 0-4 | reload counter |
| ST25TB04K | SRIX slot-marker | 128 × 4 B | 484 B | 2 × count-down | blocks 0-4 | reload counter |
| RF430CL330H | **standard REQB/ATTRIB** | 3 KB SRAM | 3 KB NDEF | — | — | NFC **Type 4**, DI (I²C/SPI) |
ST25TB use the simplified SRIX/SRI anticollision (Initiate/Pcall16/Slot_marker
/Select-by-Chip_ID), 64-bit UID prefix `D0 02 …`, decrement-only counters at
blocks 5/6. RF430CL330H uses full ISO 14443-3 Type B (REQB→ATQB→ATTRIB) and then
Type 4 NDEF APDUs; its NDEF SRAM is volatile (host-filled over I²C/SPI).
---
## 5. HF · ISO 15693 (NFC-V / NFC Forum Type 5)
| Model | User mem | Blocks | UID prefix | Password | Privacy | Signature | Extra |
|---|---|---|---|---|---|---|---|
| ICODE SLIX | 112 B | 28 × 4 B | `E0 04 01` | EAS/AFI | — | ✓ | EAS |
| ICODE SLIX2 | 320 B | 80 × 4 B | `E0 04 02` | 4 × 32-bit | privacy | ✓ | DESTROY, protect-page |
| ICODE 3 | 304 B | 76 × 4 B | `E0 04 01` | 4 × 32-bit | privacy | ✓ | — |
| ICODE DNA | 256 B | 64 × 4 B | `E0 04 01` | 4 × 32-bit | privacy | ✓ | **AES-128** mutual auth |
| NTAG 5 Switch (NTP5210) | 512 B | 128 × 4 B | `E0 04 01` | password | privacy | ✓ | PWM/GPIO, EH, ED — **no I²C**, no AES |
| NTAG 5 Link (NTP53x2) | 2 KB | 512 × 4 B | `E0 04 01` | password | privacy | ✓ | **DI (I²C)**, 256 B SRAM, EH, ED, AES (NTP5332) |
| NTAG 5 Boost (NTA5332) | 2 KB | 512 × 4 B | `E0 04 01` | password | privacy | ✓ | **DI (I²C)**, SRAM, EH, AES always, **ALM**³ |
| ST25TV512C | 64 B | 16 × 4 B | `E0 02 08` | 64/32-bit areas | Kill/Silent/Discreet | TruST25 | tamper (-T) |
| ST25TV02KC | 320 B | 80 × 4 B | `E0 02 08` | 64/32-bit areas | Kill/Silent/Discreet | TruST25 | tamper (-T) |
| ST25DV04K | 512 B | 128 × 4 B | `E0 02 24` | 3 × 64-bit | — | — | **EH**, mailbox/FTM, DI |
| ST25DV16K | 2 KB | 512 × 4 B | `E0 02 26` | 3 × 64-bit | — | — | **EH**, 4 areas, GPO, DI |
| ST25DV64K | 8 KB | 2048 × 4 B | `E0 02 26` | 3 × 64-bit | — | — | **EH**, mailbox, DI |
| my-d vicinity SRF55V02P | 224 B | 56 × 4 B | `E0 05 40` | — | — | — | value counters, page mode¹ |
| my-d vicinity SRF55V10P | 992 B | 248 × 4 B | `E0 05 00` | — | — | — | value counters, page mode¹ |
| Tag-it HF-I Plus | 256 B | 64 × 4 B | `E0 07 …` | — | — | — | Write_2/Lock_2 (0xA2/0xA3) |
| RF430FRL152/3/4H | 256 B² | 64 × 4 B² | `E0 07 A2` | — | — | — | **ADC + temp sensor**, DI |
¹ my-d vicinity has **no GET SYSTEM INFO** (0x2B) — identify via the Chip-ID UID
byte. ² RF430FRL block map + sensor commands are defined by ROM firmware
(SLAU603, not modelled); the model provides identity + standard 15693 I/O.
³ **ALM** = Active Load Modulation, unique to the NTAG 5 Boost (NTA5332) among
the NTAG 5 family — a physical RF feature (no command-layer effect; at the APDU
layer Boost is a Link NTP5332 with AES permanently enabled).
---
## 6. LF · 125 kHz
| Model | Data | Encoding | Writable | Notes |
|---|---|---|---|---|
| EM4100 | 40-bit ID | ASK / Manchester | read-only | classic access-control tag |
| HID Prox | 26/35/… bit Wiegand | FSK | read-only | facility + card number |
| T5577 | 4×32-bit blocks + config | ASK/FSK/PSK (multi) | ✓ | emulates EM4100/HID/etc. via config |
---
## 7. Specialized feature comparison
### 7.1 Energy harvesting (EH output for external circuits)
| Model | Protocol | EH mechanism |
|---|---|---|
| ST25DV04K/16K/64K | 15693 | V_EH analog output pin; `EH_CTRL_Dyn` register (EH_EN/EH_ON) |
| NTAG I²C plus | 14443-A | field-powered I²C host supply (V_OUT) |
| **NTAG 5 — all variants (Switch/Link/Boost)** | 15693 | energy-harvesting output + event-detection (ED) pin, shared via the NTAG 5 platform |
| RF430FRL15xH | 15693 | passive (field-powered) or battery-assisted operation |
### 7.2 Dual interface — RF + wired host (I²C / SPI bridge)
| Model | RF | Wired | Shared-memory / bridge feature |
|---|---|---|---|
| NTAG I²C plus (NT3H2111/2211) | 14443-A T2 | I²C | 64-byte SRAM, pass-through, SECTOR_SELECT |
| ST25DV04K/16K/64K | 15693 T5 | I²C | 256-byte mailbox (Fast Transfer Mode) |
| OPTIGA Authenticate NBT | 14443-A T4 | I²C (GP T=1) | NFC↔I²C pass-through, IRQ |
| RF430CL330H | 14443-B T4 | I²C / SPI | 3 KB NDEF SRAM, host writes NDEF |
| NTAG 5 Link (NTP53x2) | 15693 T5 | I²C | 256 B SRAM, arbiter, pass-through/mirror/PHDC |
| NTAG 5 Boost (NTA5332) | 15693 T5 | I²C | = Link NTP5332 + AES always on + ALM |
| RF430FRL15xH | 15693 | I²C / SPI | MSP430 host access to FRAM |
*(NTAG 5 **Switch** (NTP5210) is single-interface — RF + PWM/GPIO only, no I²C.)*
### 7.3 Cryptographic authentication
| Model | Protocol | Scheme |
|---|---|---|
| MIFARE Classic 1K/4K | 14443-A | Crypto1 (48-bit, per-sector keys A/B) |
| MIFARE Ultralight C | 14443-A | 2-key **3DES** challenge-response (AUTHENTICATE 0x1A) |
| MIFARE DESFire | 14443-A T4 | DES / 3DES / **AES-128** |
| ICODE DNA | 15693 | **AES-128** tag + mutual auth |
| NTAG 5 Link (NTP5332) / Boost (NTA5332) | 15693 | **AES-128** TAM/MAM (Switch has none; Link NTP5312 is password-only) |
| OPTIGA Authenticate NBT | 14443-A T4 | **ECDSA P-256** (offline) + AES-128-CMAC COTT (online) |
### 7.4 Password protection (symmetric secret / verify)
| Model | Protocol | Password |
|---|---|---|
| NTAG21x, Ultralight EV1 | 14443-A | 32-bit PWD_AUTH (+16-bit PACK), AUTH0 page gate |
| ST25TA family | 14443-A T4 | 128-bit read + write passwords (VERIFY) |
| OPTIGA NBT | 14443-A T4 | 32-bit per-file passwords via File Access Policy |
| ICODE SLIX2 / 3 / DNA | 15693 | 4 × 32-bit (read/write/privacy/destroy) |
| ST25TV | 15693 | 64-bit (single-area) or 2 × 32-bit (dual-area) + config pwd |
| ST25DV | 15693 | 3 × 64-bit RF passwords + area protection |
| ST25TN | 14443-A | 32-bit Kill password only |
### 7.5 Counters
| Model | Counter(s) | Type |
|---|---|---|
| NTAG213/215/216 | 1 × 24-bit | NFC read counter (auto-increment) |
| Ultralight EV1 | 3 × 24-bit | one-way, increment + tearing check |
| Ultralight C | 1 × 16-bit | one-way |
| ST25TA02KB | 20-bit | event counter (anti-tearing) |
| my-d vicinity | per-page | value counters (decrement-only, 0…65535) |
| ST25TB (all) | 2 × 32-bit | count-down (decrement-only) |
### 7.6 Tamper detection, privacy & kill
| Model | Feature |
|---|---|
| ST25TV02KC-T | tamper loop (TD0/TD1), seal/unseal/reseal event memorization |
| ST25TV | Kill (permanent), Silent + Discreet (reversible), NUID masking |
| ST25TN | Kill (permanent RF mute) |
| ICODE SLIX2 / 3 / DNA | privacy mode, DESTROY (permanent) |
| ST25DV | RF-disable / RF-sleep dynamic registers |
### 7.7 Originality / authenticity signatures
| Signature scheme | Models |
|---|---|
| NXP originality (32-byte ECC, READ_SIG) | NTAG21x, Ultralight EV1, NTAG I²C, ICODE SLIX/SLIX2/3/DNA, NTAG5 |
| ST TruST25 digital signature | ST25TN, ST25TA (02KB), ST25TV |
| Certificate-based (X.509 + ECDSA) | OPTIGA Authenticate NBT |
### 7.8 Sensors
| Model | Sensor |
|---|---|
| RF430FRL152/153/154H | 14-bit sigma-delta ADC + internal temperature sensor (over 15693, via ROM firmware) |
---
## 8. Implant presets
Pre-configured instances for the common dual/single-frequency implants
(`from pm3py.sim import xEM, xNT, xM1, FlexDF, NExT`):
| Preset | Backing model | Notes |
|---|---|---|
| `xEM` | T5577 (EM4100 mode) | LF |
| `xNT` | NTAG216 | HF, NDEF |
| `xM1` | MIFARE Classic 1K | HF |
| `FlexDF` | DESFire EV2 | HF |
| `NExT` | T5577 + NTAG216 | dual-frequency (LF + HF) |
| `MagicMifareClassicTag` | gen1a / gen2 Classic | writable block 0 |
---
*Generated from the `pm3py.sim` model set. Memory/feature values follow the
manufacturer datasheets; where a datasheet was not supplied (ST25TA64K,
RF430FRL firmware command set) the model is marked accordingly in its module
docstring.*

View File

@@ -0,0 +1,134 @@
# Trace Formatter Design
## Problem
The sim session trace output is a raw `print()` with no color, no command decoding, no wrapping, and no leading newline (so it sometimes renders on the REPL prompt). The PM3 C client has rich colored, decoded trace output — we want that for our Python sim traces.
## Design
### New module: `pm3py/sim/trace_fmt.py`
A `TraceFormatter` class that owns all rendering logic. `_trace_reader` in `sim_session.py` delegates to it instead of calling `print()` directly.
### Modes
| Mode | Tag | Color |
|------|-----|-------|
| Sim (we are the tag) | `[Sim]` | Magenta (`\033[35m`) |
| Reader (we are the reader) | `[Rdr]` | Cyan (`\033[36m`) |
| Sniff (passive observer) | `[Snf]` | Default/white |
### Direction colors
| Direction | Color |
|-----------|-------|
| Reader -> Tag (command) | Cyan (`\033[36m`) |
| Tag -> Reader (response) | Yellow (`\033[33m`) |
### Additional colors
| Element | Color |
|---------|-------|
| Command annotation | Green (`\033[32m`) |
| Hex bytes | Dim (`\033[2m`) |
Colors suppressed when `sys.stdout.isatty()` is False.
### Layout
```
\n[Sim] Reader -> Tag: 26 01 00 INVENTORY
^^^^ ^^^^^^^^^^^^^^ ^^^^^^^^ ^^^^^^^^^
mode prefix(colored) hex(dim) annotation(green)
```
Prefix column = fixed width (e.g., 24 chars). This is the indent for continuation lines.
Hex bytes are space-separated (`de ad be ef`, not `deadbeef`).
### Wrapping
When hex + annotation fit on one line: annotation appears inline after hex.
When hex overflows terminal width: hex wraps at prefix column, annotation goes on its own line below (also at prefix column).
```
\n[Sim] Tag -> Reader: 00 de ad be ef 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 fe a1
READ MULTIPLE BLOCK [0x00, 13 blocks] OK
```
### Terminal width handling
- Cache `os.get_terminal_size()` on init
- Register `signal.SIGWINCH` handler (Unix) to update cached width
- Fallback: re-read every 10 lines if signal registration fails
- Default 80 columns if no TTY
### ISO 15693 command decode table
Extracted from command byte (byte index 1 of frame):
| Cmd | Name | Extra annotation |
|-----|------|-----------------|
| 0x01 | INVENTORY | mask_len if present |
| 0x02 | STAY QUIET | -- |
| 0x20 | READ SINGLE BLOCK | block number |
| 0x21 | WRITE SINGLE BLOCK | block number + data length |
| 0x23 | READ MULTIPLE BLOCK | start block, count |
| 0x26 | RESET TO READY | -- |
| 0x2B | GET SYSTEM INFO | -- |
| 0x2C | GET MULTIPLE BLOCK SECURITY | -- |
| 0xA1 | NXP READ CONFIG | reg index |
| 0xA2 | NXP WRITE CONFIG | reg index, value |
| 0xB2 | NXP GET RANDOM | -- |
| 0xB3 | NXP SET PASSWORD | pwd_id |
Response decoding: flags byte 0x00 = OK, 0x01 = ERROR + error code. Inventory responses show UID. Read responses show data.
### ISO 14443-A command decode table
| Byte(s) | Name |
|---------|------|
| 0x26 | REQA |
| 0x52 | WUPA |
| 0x50 00 | HLTA |
| 0x93 20 | ANTICOL CL1 |
| 0x93 70 | SELECT CL1 |
| 0x95 20/70 | ANTICOL/SELECT CL2 |
| 0x97 20/70 | ANTICOL/SELECT CL3 |
| 0xE0 | RATS |
| 0x02/0x03 | I-BLOCK |
| 0xA2/0xA3 | R-ACK |
| 0xB2/0xB3 | R-NAK |
| 0xC2/0xF2 | S(DESELECT)/S(WTX) |
Response decoding: ATQA as `ATQA xx xx`, SAK as `SAK xx`, ATS as `ATS [len]`.
### API
```python
class TraceFormatter:
def __init__(self, mode: str = "sim"):
"""mode: "sim", "reader", or "sniff" """
def format(self, direction: int, payload: bytes) -> str:
"""Returns fully formatted, colored, wrapped string with leading \\n."""
def print(self, direction: int, payload: bytes) -> None:
"""format() + print to stdout."""
```
Decoder functions: `decode_15693(direction, payload) -> str | None` and `decode_14443a(direction, payload) -> str | None`.
### Integration
- `sim_session.py`: instantiate `TraceFormatter("sim")` in `start_15693()`, call `self._formatter.print()` in `_trace_reader()`
- 14443-A trace path: decode table built now, wired up when firmware trace path lands
- No changes to `replay.py` or `InteractiveReader.dump_trace()`
### Out of scope
- Replay/recorder trace formatting
- InteractiveReader dump formatting
- Protocol decode beyond 15693 + 14443-A

View File

@@ -0,0 +1,998 @@
# Trace Formatter Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace raw `print()` trace output with colored, decoded, column-wrapped trace rendering for sim sessions.
**Architecture:** New `trace_fmt.py` module with `TraceFormatter` class + protocol decode functions. `sim_session.py` delegates to it. Terminal width detected dynamically with SIGWINCH support.
**Tech Stack:** Python stdlib only (os, signal, sys, struct, shutil). No external dependencies.
---
### Task 1: ISO 15693 request decoder
**Files:**
- Create: `pm3py/sim/trace_fmt.py`
- Test: `tests/test_sim_trace_fmt.py`
**Step 1: Write failing tests for 15693 request decoding**
```python
"""Tests for pm3py.sim.trace_fmt — trace formatting and protocol decoding."""
import os
import pytest
from pm3py.sim.trace_fmt import decode_15693
class TestDecode15693Request:
"""Decode reader->tag (direction=0) 15693 commands."""
def test_inventory(self):
# flags=0x26, cmd=0x01, mask_len=0
payload = bytes([0x26, 0x01, 0x00])
result = decode_15693(0, payload)
assert result == "INVENTORY"
def test_inventory_with_mask(self):
payload = bytes([0x26, 0x01, 0x08, 0xAB])
result = decode_15693(0, payload)
assert result == "INVENTORY mask=8"
def test_stay_quiet(self):
payload = bytes([0x22, 0x02]) + b"\x01" * 8
result = decode_15693(0, payload)
assert result == "STAY QUIET"
def test_read_single_block_addressed(self):
# flags=0x22 (addressed), cmd=0x20, uid(8), block=0x03
payload = bytes([0x22, 0x20]) + b"\x01" * 8 + bytes([0x03])
result = decode_15693(0, payload)
assert result == "READ SINGLE BLOCK #3"
def test_read_single_block_unaddressed(self):
# flags=0x02 (no address flag), cmd=0x20, block=0x00
payload = bytes([0x02, 0x20, 0x00])
result = decode_15693(0, payload)
assert result == "READ SINGLE BLOCK #0"
def test_write_single_block(self):
payload = bytes([0x22, 0x21]) + b"\x01" * 8 + bytes([0x05]) + b"\xDE\xAD\xBE\xEF"
result = decode_15693(0, payload)
assert result == "WRITE SINGLE BLOCK #5 [4B]"
def test_read_multiple_block(self):
# flags=0x22, cmd=0x23, uid(8), start=0x00, count=0x0D
payload = bytes([0x22, 0x23]) + b"\x01" * 8 + bytes([0x00, 0x0D])
result = decode_15693(0, payload)
assert result == "READ MULTIPLE BLOCK #0+13"
def test_reset_to_ready(self):
payload = bytes([0x22, 0x26]) + b"\x01" * 8
result = decode_15693(0, payload)
assert result == "RESET TO READY"
def test_get_system_info(self):
payload = bytes([0x22, 0x2B]) + b"\x01" * 8
result = decode_15693(0, payload)
assert result == "GET SYSTEM INFO"
def test_get_multiple_block_security(self):
payload = bytes([0x22, 0x2C]) + b"\x01" * 8 + bytes([0x00, 0x3F])
result = decode_15693(0, payload)
assert result == "GET MULTIPLE BLOCK SECURITY #0+63"
def test_nxp_read_config(self):
# flags=0x22, cmd=0xA1, mfg=0x04, reg=0x02
payload = bytes([0x22, 0xA1, 0x04, 0x02])
result = decode_15693(0, payload)
assert result == "NXP READ CONFIG reg=2"
def test_nxp_write_config(self):
payload = bytes([0x22, 0xA2, 0x04, 0x03, 0xFF])
result = decode_15693(0, payload)
assert result == "NXP WRITE CONFIG reg=3 val=0xFF"
def test_nxp_get_random(self):
payload = bytes([0x22, 0xB2, 0x04])
result = decode_15693(0, payload)
assert result == "NXP GET RANDOM"
def test_nxp_set_password(self):
payload = bytes([0x22, 0xB3, 0x04, 0x01]) + b"\x00" * 4
result = decode_15693(0, payload)
assert result == "NXP SET PASSWORD id=1"
def test_unknown_command(self):
payload = bytes([0x22, 0xFF])
result = decode_15693(0, payload)
assert result is None
def test_too_short(self):
result = decode_15693(0, bytes([0x26]))
assert result is None
```
**Step 2: Run tests to verify they fail**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestDecode15693Request -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'pm3py.sim.trace_fmt'`
**Step 3: Implement 15693 request decoder**
Create `pm3py/sim/trace_fmt.py`:
```python
"""Trace formatter — colored, decoded, column-wrapped trace output."""
from __future__ import annotations
# ---- ISO 15693 flags ----
_15693_FLAG_INVENTORY = 0x04
_15693_FLAG_ADDRESS = 0x20
# ---- ISO 15693 command names ----
_15693_CMDS = {
0x01: "INVENTORY",
0x02: "STAY QUIET",
0x20: "READ SINGLE BLOCK",
0x21: "WRITE SINGLE BLOCK",
0x23: "READ MULTIPLE BLOCK",
0x26: "RESET TO READY",
0x2B: "GET SYSTEM INFO",
0x2C: "GET MULTIPLE BLOCK SECURITY",
}
# NXP custom commands (manufacturer code 0x04)
_15693_NXP_CMDS = {
0xA1: "NXP READ CONFIG",
0xA2: "NXP WRITE CONFIG",
0xB2: "NXP GET RANDOM",
0xB3: "NXP SET PASSWORD",
}
def _15693_block_offset(flags: int) -> int:
"""Return the byte offset of the block number field after flags+cmd."""
is_inventory = bool(flags & _15693_FLAG_INVENTORY)
if not is_inventory and (flags & _15693_FLAG_ADDRESS):
return 10 # flags(1) + cmd(1) + uid(8)
return 2 # flags(1) + cmd(1)
def decode_15693(direction: int, payload: bytes) -> str | None:
"""Decode an ISO 15693 frame into a human-readable annotation.
Args:
direction: 0 = reader->tag, 1 = tag->reader
payload: raw frame bytes
Returns:
Annotation string or None if unrecognized.
"""
if direction == 0:
return _decode_15693_request(payload)
else:
return _decode_15693_response(payload)
def _decode_15693_request(payload: bytes) -> str | None:
if len(payload) < 2:
return None
flags = payload[0]
cmd = payload[1]
# Standard commands
name = _15693_CMDS.get(cmd)
if name is not None:
return _annotate_15693_request(name, cmd, flags, payload)
# NXP custom commands (check manufacturer code)
name = _15693_NXP_CMDS.get(cmd)
if name is not None:
return _annotate_nxp_request(name, cmd, payload)
return None
def _annotate_15693_request(name: str, cmd: int, flags: int, payload: bytes) -> str:
blk_off = _15693_block_offset(flags)
if cmd == 0x01: # INVENTORY
if len(payload) > 2 and payload[2] > 0:
return f"{name} mask={payload[2]}"
return name
if cmd in (0x20, 0x21): # READ/WRITE SINGLE BLOCK
if len(payload) > blk_off:
block = payload[blk_off]
if cmd == 0x21:
data_len = len(payload) - blk_off - 1
return f"{name} #{block} [{data_len}B]"
return f"{name} #{block}"
return name
if cmd in (0x23, 0x2C): # READ MULTIPLE / GET MULTIPLE BLOCK SECURITY
if len(payload) > blk_off + 1:
start = payload[blk_off]
count = payload[blk_off + 1]
return f"{name} #{start}+{count}"
return name
return name
def _annotate_nxp_request(name: str, cmd: int, payload: bytes) -> str:
if cmd == 0xA1 and len(payload) >= 4: # READ CONFIG
return f"{name} reg={payload[3]}"
if cmd == 0xA2 and len(payload) >= 5: # WRITE CONFIG
return f"{name} reg={payload[3]} val=0x{payload[4]:02X}"
if cmd == 0xB3 and len(payload) >= 4: # SET PASSWORD
return f"{name} id={payload[3]}"
return name
def _decode_15693_response(payload: bytes) -> str | None:
# Placeholder — implemented in Task 2
return None
```
**Step 4: Run tests to verify they pass**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestDecode15693Request -v`
Expected: all PASS
**Step 5: Commit**
```bash
cd /home/work/pm3py/.worktrees/sim-framework
git add pm3py/sim/trace_fmt.py tests/test_sim_trace_fmt.py
git commit --no-gpg-sign -m "feat(trace): add ISO 15693 request decoder"
```
---
### Task 2: ISO 15693 response decoder
**Files:**
- Modify: `pm3py/sim/trace_fmt.py` — replace `_decode_15693_response` placeholder
- Test: `tests/test_sim_trace_fmt.py`
**Step 1: Write failing tests**
Append to `tests/test_sim_trace_fmt.py`:
```python
class TestDecode15693Response:
"""Decode tag->reader (direction=1) 15693 responses."""
def test_ok_empty(self):
# Just flags=0x00, no data (e.g., write ack)
result = decode_15693(1, bytes([0x00]))
assert result == "OK"
def test_inventory_response(self):
# flags=0x00, dsfid=0x00, uid (8 bytes LSB-first)
uid_lsb = bytes([0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x04, 0xE0])
payload = bytes([0x00, 0x00]) + uid_lsb
result = decode_15693(1, payload)
assert result == "OK INVENTORY UID=E004010101010101"
def test_error_response(self):
payload = bytes([0x01, 0x0F])
result = decode_15693(1, payload)
assert result == "ERROR 0x0F"
def test_error_block_not_available(self):
payload = bytes([0x01, 0x10])
result = decode_15693(1, payload)
assert result == "ERROR 0x10 block not available"
def test_data_response(self):
# flags=0x00 + 4 data bytes (read single block response)
payload = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF])
result = decode_15693(1, payload)
assert result == "OK [4B]"
def test_too_short(self):
result = decode_15693(1, b"")
assert result is None
```
**Step 2: Run to verify failure**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestDecode15693Response -v`
Expected: FAIL — responses decode as `None`
**Step 3: Implement response decoder**
Replace `_decode_15693_response` in `trace_fmt.py`:
```python
_15693_ERRORS = {
0x01: "not supported",
0x02: "not recognized",
0x0F: "unknown error",
0x10: "block not available",
0x11: "block already locked",
0x12: "block locked",
0x13: "block not written",
0x14: "block not locked",
}
def _decode_15693_response(payload: bytes) -> str | None:
if len(payload) < 1:
return None
flags = payload[0]
if flags & 0x01: # Error
if len(payload) >= 2:
code = payload[1]
desc = _15693_ERRORS.get(code, "")
if desc:
return f"ERROR 0x{code:02X} {desc}"
return f"ERROR 0x{code:02X}"
return "ERROR"
# Success — try to identify the response type
data = payload[1:]
if len(data) == 0:
return "OK"
# Inventory response: dsfid(1) + uid(8) = 9 bytes
if len(data) == 9:
uid_msb = bytes(reversed(data[1:9]))
return f"OK INVENTORY UID={uid_msb.hex().upper()}"
# Generic data response
return f"OK [{len(data)}B]"
```
**Step 4: Run tests**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestDecode15693Response -v`
Expected: all PASS
**Step 5: Commit**
```bash
cd /home/work/pm3py/.worktrees/sim-framework
git add pm3py/sim/trace_fmt.py tests/test_sim_trace_fmt.py
git commit --no-gpg-sign -m "feat(trace): add ISO 15693 response decoder"
```
---
### Task 3: ISO 14443-A decoder
**Files:**
- Modify: `pm3py/sim/trace_fmt.py`
- Test: `tests/test_sim_trace_fmt.py`
**Step 1: Write failing tests**
Append to test file:
```python
from pm3py.sim.trace_fmt import decode_14443a
class TestDecode14443aRequest:
"""Decode reader->tag 14443-A commands."""
def test_reqa(self):
assert decode_14443a(0, bytes([0x26])) == "REQA"
def test_wupa(self):
assert decode_14443a(0, bytes([0x52])) == "WUPA"
def test_hlta(self):
assert decode_14443a(0, bytes([0x50, 0x00])) == "HLTA"
def test_anticol_cl1(self):
assert decode_14443a(0, bytes([0x93, 0x20])) == "ANTICOL CL1"
def test_select_cl1(self):
payload = bytes([0x93, 0x70]) + b"\x01\x02\x03\x04\x04"
assert decode_14443a(0, payload) == "SELECT CL1"
def test_anticol_cl2(self):
assert decode_14443a(0, bytes([0x95, 0x20])) == "ANTICOL CL2"
def test_select_cl2(self):
payload = bytes([0x95, 0x70]) + b"\x01\x02\x03\x04\x04"
assert decode_14443a(0, payload) == "SELECT CL2"
def test_anticol_cl3(self):
assert decode_14443a(0, bytes([0x97, 0x20])) == "ANTICOL CL3"
def test_select_cl3(self):
payload = bytes([0x97, 0x70]) + b"\x01\x02\x03\x04\x04"
assert decode_14443a(0, payload) == "SELECT CL3"
def test_rats(self):
assert decode_14443a(0, bytes([0xE0, 0x50])) == "RATS"
def test_iblock_even(self):
assert decode_14443a(0, bytes([0x02, 0x00, 0xA4])) == "I-BLOCK(0)"
def test_iblock_odd(self):
assert decode_14443a(0, bytes([0x03, 0x00, 0xA4])) == "I-BLOCK(1)"
def test_rack(self):
assert decode_14443a(0, bytes([0xA2])) == "R-ACK(0)"
assert decode_14443a(0, bytes([0xA3])) == "R-ACK(1)"
def test_rnak(self):
assert decode_14443a(0, bytes([0xB2])) == "R-NAK(0)"
assert decode_14443a(0, bytes([0xB3])) == "R-NAK(1)"
def test_deselect(self):
assert decode_14443a(0, bytes([0xC2])) == "S(DESELECT)"
def test_wtx(self):
assert decode_14443a(0, bytes([0xF2, 0x01])) == "S(WTX)"
def test_unknown(self):
assert decode_14443a(0, bytes([0xFF])) is None
def test_empty(self):
assert decode_14443a(0, b"") is None
class TestDecode14443aResponse:
"""Decode tag->reader 14443-A responses."""
def test_atqa(self):
# 2-byte response to REQA/WUPA
assert decode_14443a(1, bytes([0x04, 0x00])) == "ATQA 04 00"
def test_sak(self):
# 1-byte response to SELECT
assert decode_14443a(1, bytes([0x20])) == "SAK 20"
def test_ats(self):
# ATS: first byte is length
ats = bytes([0x05, 0x78, 0x80, 0x70, 0x02])
assert decode_14443a(1, ats) == "ATS [5]"
def test_iblock_response(self):
assert decode_14443a(1, bytes([0x02, 0x90, 0x00])) == "I-BLOCK(0)"
def test_empty(self):
assert decode_14443a(1, b"") is None
```
**Step 2: Run to verify failure**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestDecode14443aRequest tests/test_sim_trace_fmt.py::TestDecode14443aResponse -v`
Expected: FAIL — `ImportError: cannot import name 'decode_14443a'`
**Step 3: Implement 14443-A decoder**
Add to `trace_fmt.py`:
```python
# ---- ISO 14443-A constants ----
_14A_CL_MAP = {0x93: "CL1", 0x95: "CL2", 0x97: "CL3"}
def decode_14443a(direction: int, payload: bytes) -> str | None:
"""Decode an ISO 14443-A frame into a human-readable annotation."""
if not payload:
return None
if direction == 0:
return _decode_14443a_request(payload)
else:
return _decode_14443a_response(payload)
def _decode_14443a_request(payload: bytes) -> str | None:
b0 = payload[0]
# Short frames (single byte)
if b0 == 0x26:
return "REQA"
if b0 == 0x52:
return "WUPA"
# HLTA
if b0 == 0x50 and len(payload) >= 2:
return "HLTA"
# Anticollision / Select
if b0 in _14A_CL_MAP and len(payload) >= 2:
cl = _14A_CL_MAP[b0]
nvb = payload[1]
if nvb == 0x20:
return f"ANTICOL {cl}"
if nvb == 0x70:
return f"SELECT {cl}"
return f"ANTICOL {cl} nvb={nvb:02X}"
# RATS
if b0 == 0xE0:
return "RATS"
# ISO-DEP I-block
if b0 & 0xE2 == 0x02:
bn = b0 & 0x01
return f"I-BLOCK({bn})"
# R-ACK
if b0 & 0xF6 == 0xA2:
bn = b0 & 0x01
return f"R-ACK({bn})"
# R-NAK
if b0 & 0xF6 == 0xB2:
bn = b0 & 0x01
return f"R-NAK({bn})"
# S(DESELECT)
if b0 == 0xC2:
return "S(DESELECT)"
# S(WTX)
if b0 == 0xF2:
return "S(WTX)"
return None
def _decode_14443a_response(payload: bytes) -> str | None:
if not payload:
return None
b0 = payload[0]
# I-block response
if b0 & 0xE2 == 0x02:
bn = b0 & 0x01
return f"I-BLOCK({bn})"
# ATQA (2 bytes)
if len(payload) == 2 and b0 & 0xF0 == 0x00:
return f"ATQA {payload[0]:02X} {payload[1]:02X}"
# SAK (1 byte)
if len(payload) == 1:
return f"SAK {b0:02X}"
# ATS (first byte = length, length >= 2)
if len(payload) >= 2 and payload[0] == len(payload):
return f"ATS [{len(payload)}]"
return None
```
**Step 4: Run tests**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py -v`
Expected: all PASS (15693 + 14443-A)
**Step 5: Commit**
```bash
cd /home/work/pm3py/.worktrees/sim-framework
git add pm3py/sim/trace_fmt.py tests/test_sim_trace_fmt.py
git commit --no-gpg-sign -m "feat(trace): add ISO 14443-A decoder"
```
---
### Task 4: TraceFormatter — color, wrapping, terminal width
**Files:**
- Modify: `pm3py/sim/trace_fmt.py`
- Test: `tests/test_sim_trace_fmt.py`
**Step 1: Write failing tests**
Append to test file:
```python
from pm3py.sim.trace_fmt import TraceFormatter
class TestTraceFormatterBasic:
"""Test formatting output (colors stripped for assertion)."""
def _strip_ansi(self, s: str) -> str:
import re
return re.sub(r'\033\[[0-9;]*m', '', s)
def test_starts_with_newline(self):
fmt = TraceFormatter(mode="sim")
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
assert result.startswith("\n")
def test_sim_mode_tag(self):
fmt = TraceFormatter(mode="sim")
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
assert "[Sim]" in result
def test_reader_mode_tag(self):
fmt = TraceFormatter(mode="reader")
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
assert "[Rdr]" in result
def test_sniff_mode_tag(self):
fmt = TraceFormatter(mode="sniff")
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
assert "[Snf]" in result
def test_reader_to_tag_arrow(self):
fmt = TraceFormatter(mode="sim")
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
assert "Reader \u2192 Tag:" in result
def test_tag_to_reader_arrow(self):
fmt = TraceFormatter(mode="sim")
result = self._strip_ansi(fmt.format(1, bytes([0x00])))
assert "Tag \u2192 Reader:" in result
def test_hex_space_separated(self):
fmt = TraceFormatter(mode="sim")
result = self._strip_ansi(fmt.format(0, bytes([0x22, 0x20, 0x03])))
assert "22 20 03" in result
def test_annotation_present(self):
fmt = TraceFormatter(mode="sim")
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
assert "INVENTORY" in result
def test_no_annotation_for_unknown(self):
fmt = TraceFormatter(mode="sim")
result = self._strip_ansi(fmt.format(0, bytes([0x22, 0xFF])))
assert "22 FF" in result or "22 ff" in result.lower()
class TestTraceFormatterWrapping:
"""Test column-aligned wrapping for long payloads."""
def _strip_ansi(self, s: str) -> str:
import re
return re.sub(r'\033\[[0-9;]*m', '', s)
def test_short_payload_inline_annotation(self):
"""Short payload: annotation on same line as hex."""
fmt = TraceFormatter(mode="sim", width=80)
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
lines = result.strip().split("\n")
assert len(lines) == 1
assert "INVENTORY" in lines[0]
assert "26 01 00" in lines[0]
def test_long_payload_wraps(self):
"""Long payload wraps at prefix column."""
fmt = TraceFormatter(mode="sim", width=60)
long_payload = bytes([0x00]) + bytes(40)
result = self._strip_ansi(fmt.format(1, long_payload))
lines = result.strip().split("\n")
assert len(lines) > 1
# Continuation lines should be indented to prefix column
first_hex_col = lines[0].index("00")
for line in lines[1:]:
if line.strip():
leading = len(line) - len(line.lstrip())
assert leading >= first_hex_col - 1
def test_wrapped_annotation_on_own_line(self):
"""When hex wraps, annotation goes on its own line."""
fmt = TraceFormatter(mode="sim", width=50)
# Inventory response with UID — long enough to wrap at width=50
uid_lsb = bytes([0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x04, 0xE0])
payload = bytes([0x00, 0x00]) + uid_lsb
result = self._strip_ansi(fmt.format(1, payload))
lines = result.strip().split("\n")
# Annotation should be on a separate line
annotation_lines = [l for l in lines if "INVENTORY" in l]
hex_lines = [l for l in lines if "00 01" in l.lower() or "04 e0" in l.lower()]
if len(hex_lines) > 1:
# Wrapped — annotation must be on its own line
assert len(annotation_lines) == 1
assert annotation_lines[0] not in hex_lines
class TestTraceFormatterNoColor:
"""Colors suppressed when is_tty=False."""
def test_no_ansi_when_not_tty(self):
fmt = TraceFormatter(mode="sim", is_tty=False)
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
assert "\033[" not in result
```
**Step 2: Run to verify failure**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestTraceFormatterBasic tests/test_sim_trace_fmt.py::TestTraceFormatterWrapping tests/test_sim_trace_fmt.py::TestTraceFormatterNoColor -v`
Expected: FAIL — `ImportError: cannot import name 'TraceFormatter'`
**Step 3: Implement TraceFormatter**
Add to `trace_fmt.py`:
```python
import os
import signal
import sys
# ---- ANSI colors ----
_C_CYAN = "\033[36m"
_C_YELLOW = "\033[33m"
_C_MAGENTA = "\033[35m"
_C_GREEN = "\033[32m"
_C_DIM = "\033[2m"
_C_RESET = "\033[0m"
_MODE_TAGS = {
"sim": ("[Sim]", _C_MAGENTA),
"reader": ("[Rdr]", _C_CYAN),
"sniff": ("[Snf]", ""),
}
class TraceFormatter:
"""Colored, decoded, column-wrapped trace output.
Args:
mode: "sim", "reader", or "sniff"
protocol: "15693" or "14443a" (selects decoder)
width: override terminal width (None = auto-detect)
is_tty: override TTY detection (None = auto-detect)
"""
def __init__(self, mode: str = "sim", protocol: str = "15693",
width: int | None = None, is_tty: bool | None = None):
self._mode = mode
self._protocol = protocol
self._width = width or self._detect_width()
self._is_tty = is_tty if is_tty is not None else sys.stdout.isatty()
self._line_count = 0
# Try to register SIGWINCH for dynamic resize
if width is None:
try:
signal.signal(signal.SIGWINCH, self._on_resize)
except (OSError, ValueError):
pass # not main thread or not Unix
def _detect_width(self) -> int:
try:
return os.get_terminal_size().columns
except (OSError, ValueError):
return 80
def _on_resize(self, signum, frame):
self._width = self._detect_width()
def _color(self, code: str, text: str) -> str:
if not self._is_tty or not code:
return text
return f"{code}{text}{_C_RESET}"
def format(self, direction: int, payload: bytes) -> str:
"""Format a trace line with colors, decoding, and wrapping."""
# Re-check width periodically if no SIGWINCH
self._line_count += 1
if self._line_count % 10 == 0:
try:
self._width = self._detect_width()
except Exception:
pass
# Mode tag
mode_tag, mode_color = _MODE_TAGS.get(self._mode, ("[???]", ""))
# Direction
if direction == 0:
arrow = "Reader \u2192 Tag:"
dir_color = _C_CYAN
else:
arrow = "Tag \u2192 Reader:"
dir_color = _C_YELLOW
# Build prefix (uncolored for width calc)
prefix_plain = f"{mode_tag} {arrow} "
prefix_len = len(prefix_plain)
# Colored prefix
prefix_colored = self._color(mode_color, mode_tag) + " " + self._color(dir_color, arrow) + " "
# Hex bytes (space-separated, uppercase)
hex_str = " ".join(f"{b:02X}" for b in payload)
# Decode annotation
if self._protocol == "15693":
annotation = decode_15693(direction, payload)
elif self._protocol == "14443a":
annotation = decode_14443a(direction, payload)
else:
annotation = None
# Layout: determine if everything fits on one line
avail = self._width - prefix_len
if annotation:
one_line = f"{hex_str} {annotation}"
else:
one_line = hex_str
if len(one_line) <= avail:
# Single line
hex_colored = self._color(_C_DIM, hex_str)
if annotation:
ann_colored = self._color(_C_GREEN, annotation)
line = f"{prefix_colored}{hex_colored} {ann_colored}"
else:
line = f"{prefix_colored}{hex_colored}"
return f"\n{line}"
# Multi-line: wrap hex, annotation on its own line
pad = " " * prefix_len
hex_lines = self._wrap_hex(hex_str, avail)
parts = [f"{prefix_colored}{self._color(_C_DIM, hex_lines[0])}"]
for hl in hex_lines[1:]:
parts.append(f"{pad}{self._color(_C_DIM, hl)}")
if annotation:
parts.append(f"{pad}{self._color(_C_GREEN, annotation)}")
return "\n" + "\n".join(parts)
def _wrap_hex(self, hex_str: str, avail: int) -> list[str]:
"""Wrap space-separated hex string into lines of at most `avail` chars."""
tokens = hex_str.split(" ")
lines = []
current = ""
for tok in tokens:
candidate = f"{current} {tok}" if current else tok
if len(candidate) <= avail:
current = candidate
else:
if current:
lines.append(current)
current = tok
if current:
lines.append(current)
return lines or [""]
def print(self, direction: int, payload: bytes) -> None:
"""Format and print a trace line to stdout."""
sys.stdout.write(self.format(direction, payload))
sys.stdout.flush()
```
**Step 4: Run tests**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py -v`
Expected: all PASS
**Step 5: Commit**
```bash
cd /home/work/pm3py/.worktrees/sim-framework
git add pm3py/sim/trace_fmt.py tests/test_sim_trace_fmt.py
git commit --no-gpg-sign -m "feat(trace): add TraceFormatter with color, wrapping, terminal resize"
```
---
### Task 5: Wire TraceFormatter into SimSession
**Files:**
- Modify: `pm3py/sim/sim_session.py:1-2,126-175,177-224`
**Step 1: Write failing test**
Append to `tests/test_sim_trace_fmt.py`:
```python
from unittest.mock import MagicMock, patch
import struct
from pm3py.sim.sim_session import SimSession, CMD_HF_ISO15693_SIM_TRACE
from pm3py.transport import encode_ng_frame, RESP_PREAMBLE_MAGIC, RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE
class TestSimSessionTrace:
"""Verify SimSession uses TraceFormatter instead of raw print."""
def _make_trace_frame(self, direction: int, payload: bytes) -> bytes:
"""Build a fake firmware trace response frame."""
from pm3py.transport import RESP_POSTAMBLE_MAGIC
data = bytes([direction]) + payload
length = len(data) | 0x8000 # NG bit set
header = struct.pack("<IHHI",
RESP_PREAMBLE_MAGIC,
length,
0, # status
CMD_HF_ISO15693_SIM_TRACE)
postamble = struct.pack("<H", RESP_POSTAMBLE_MAGIC)
return header + data + postamble
@patch("pm3py.sim.sim_session.TraceFormatter")
def test_trace_reader_uses_formatter(self, MockFormatter):
"""_trace_reader should call formatter.print() not raw print()."""
mock_fmt = MockFormatter.return_value
session = SimSession()
session._formatter = mock_fmt
session._active = True
# Build a trace frame for: reader->tag inventory
frame = self._make_trace_frame(0, bytes([0x26, 0x01, 0x00]))
# Mock serial that returns frame then stops
mock_serial = MagicMock()
mock_serial.in_waiting = len(frame)
call_count = 0
def read_side_effect(n):
nonlocal call_count
call_count += 1
if call_count == 1:
return frame
session._active = False
return b""
mock_serial.read.side_effect = read_side_effect
session._trace_reader(mock_serial)
mock_fmt.print.assert_called_once_with(0, bytes([0x26, 0x01, 0x00]))
```
**Step 2: Run to verify failure**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestSimSessionTrace -v`
Expected: FAIL — `ImportError` or `AttributeError` (no `TraceFormatter` import in sim_session)
**Step 3: Modify sim_session.py**
Add import at top of `sim_session.py`:
```python
from .trace_fmt import TraceFormatter
```
In `start_15693()`, replace the trace thread creation (around line 167-171) to instantiate the formatter:
```python
if trace:
self._formatter = TraceFormatter(mode="sim", protocol="15693")
self._trace_thread = threading.Thread(
target=self._trace_reader, args=(ser,), daemon=True
)
self._trace_thread.start()
```
In `_trace_reader()`, replace line 216-217:
```python
arrow = "Reader → Tag" if direction == 0 else "Tag → Reader"
print(f"[Trace] {arrow}: {payload.hex()}")
```
With:
```python
self._formatter.print(direction, payload)
```
**Step 4: Run all tests**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py -v`
Expected: all PASS
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/ -v`
Expected: all PASS (no regressions)
**Step 5: Commit**
```bash
cd /home/work/pm3py/.worktrees/sim-framework
git add pm3py/sim/sim_session.py pm3py/sim/trace_fmt.py tests/test_sim_trace_fmt.py
git commit --no-gpg-sign -m "feat(trace): wire TraceFormatter into SimSession"
```

View File

@@ -0,0 +1,677 @@
# NDEF Trace Decode Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Detect and decode NDEF content in ISO 15693 trace output — both sim trace and sniff — showing human-readable NDEF records in the annotation column, with overflow on the next line.
**Architecture:** Add an `decode_ndef()` function that parses NDEF TLV + records from block data. Wire it into `decode_15693_response()` so READ SINGLE BLOCK and READ MULTIPLE BLOCKS responses that contain NDEF data show decoded content. CC (block 0) gets its own annotation. Overflow annotations wrap to a continuation line at the annotation column.
**Tech Stack:** Python, pytest, existing hf_15.py decoder + sim trace_fmt.py
**Working directory:** `/home/work/pm3py/.worktrees/sim-framework/`
**Test command:** `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py tests/test_sim_trace_fmt.py -v`
---
### Context for implementer
The trace formatter produces lines like:
```
[Sim] Tag → Reader: 00 E1 40 0D 01 C1 C0 OK [4B]
[Sim] Tag → Reader: 00 03 0B D1 01 07 54 02 65 6E 74 65 73 74 FE 00 ... FD 02 OK [60B]
```
The response decoder (`decode_15693_response`) currently sees all success responses as either inventory (9B data), empty, or generic `OK [NB]`. We want to detect and decode:
1. **CC block** (block 0): `E1 40 XX YY``CC v1.0 MLEN=XX MBREAD=Y`
2. **NDEF TLV**: `03 LL <message> FE` → decode the NDEF records inside
3. **NDEF records**: Text → `NDEF Text "en" "hello"`, URI → `NDEF URI https://example.com`, Media → `NDEF MIME text/plain [5B]`
When the annotation is longer than the available space, it wraps to a continuation line right-justified at the annotation column (same pattern the hex wrapping already uses).
**Tricky parts:**
- The decoder only sees the response payload (after flags byte), with no context about WHICH command produced it. It must detect CC/NDEF purely from the data pattern.
- READ MULTIPLE BLOCKS responses can contain block 0 (CC) followed by NDEF data. The CC is in the first 4 bytes, NDEF TLV starts at byte 4.
- READ SINGLE BLOCK #0 response is just 4 bytes of CC data.
- We need both `hf_15.py` (sniff decoder) and `sim/trace_fmt.py` (sim decoder) to share the NDEF decode logic. Put the shared code in `hf_15.py` since that's where the 15693 decoders live; the sim trace_fmt calls into it via `decode_15693`.
**NFC Forum Type 5 CC format (4 bytes):**
- Byte 0: 0xE1 = NDEF magic
- Byte 1: version (upper nibble = major, lower = minor) + access bits
- Byte 2: MLEN (data area size / 8)
- Byte 3: feature flags (bit 0 = MBREAD)
**NDEF TLV format:**
- Type 0x03 = NDEF message, 0xFE = terminator, 0x00 = NULL (skip)
- Length: 1 byte if < 0xFF, else 0xFF + 2 bytes big-endian
- Value: raw NDEF message bytes
**NDEF record format:**
- Byte 0: flags (MB=0x80, ME=0x40, CF=0x20, SR=0x10, IL=0x08, TNF=0x07)
- Byte 1: TYPE_LENGTH
- If SR: byte 2 = PAYLOAD_LENGTH (1 byte), else bytes 2-5 = PAYLOAD_LENGTH (4 bytes BE)
- If IL: next byte = ID_LENGTH
- TYPE (TYPE_LENGTH bytes)
- ID (ID_LENGTH bytes, if IL set)
- PAYLOAD (PAYLOAD_LENGTH bytes)
**Well-known RTD types:**
- "T" (0x54): Text record payload[0] = lang_len, payload[1:1+lang_len] = lang, rest = text
- "U" (0x55): URI record payload[0] = prefix code, rest = URI suffix
---
### Task 1: NDEF TLV + record decoder
**Files:**
- Modify: `pm3py/hf_15.py`
- Modify: `tests/test_hf_15.py`
**Step 1: Write failing tests**
Append to `tests/test_hf_15.py`:
```python
from pm3py.hf_15 import decode_ndef_annotation
# ---- NDEF decode ----
class TestDecodeNdefAnnotation:
def test_cc_block(self):
"""4-byte CC recognized and decoded."""
data = bytes([0xE1, 0x40, 0x0D, 0x01])
ann = decode_ndef_annotation(data)
assert ann is not None
assert "CC" in ann
assert "v1.0" in ann
assert "MBREAD" in ann
def test_cc_no_mbread(self):
data = bytes([0xE1, 0x40, 0x10, 0x00])
ann = decode_ndef_annotation(data)
assert "CC" in ann
assert "MBREAD" not in ann
def test_ndef_text_record(self):
"""NDEF TLV with Text record."""
# TLV: 03 0B <ndef> FE
# Record: D1 01 07 54 02 65 6E 74 65 73 74
# MB+ME+SR, TNF=well-known, type="T", lang="en", text="test"
msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E,
0x74, 0x65, 0x73, 0x74])
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
ann = decode_ndef_annotation(tlv)
assert ann is not None
assert '"test"' in ann
def test_ndef_uri_record(self):
"""NDEF TLV with URI record."""
# URI: https://example.com → prefix 0x04 + "example.com"
uri_payload = bytes([0x04]) + b"example.com"
rec = bytes([0xD1, 0x01, len(uri_payload), 0x55]) + uri_payload
tlv = bytes([0x03, len(rec)]) + rec + bytes([0xFE])
ann = decode_ndef_annotation(tlv)
assert "https://example.com" in ann
def test_ndef_mime_record(self):
"""NDEF TLV with MIME record."""
mime_type = b"text/plain"
payload = b"hello"
rec = bytes([0xD2, len(mime_type), len(payload)]) + mime_type + payload
tlv = bytes([0x03, len(rec)]) + rec + bytes([0xFE])
ann = decode_ndef_annotation(tlv)
assert "text/plain" in ann
def test_no_ndef_magic(self):
"""Random data without NDEF TLV returns None."""
ann = decode_ndef_annotation(bytes([0x00, 0x00, 0x00, 0x00]))
assert ann is None
def test_cc_plus_ndef(self):
"""Data starting with CC followed by NDEF TLV (READ MULTIPLE from block 0)."""
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E,
0x74, 0x65, 0x73, 0x74])
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
data = cc + tlv + bytes(50) # pad like real response
ann = decode_ndef_annotation(data)
assert "CC" in ann
assert '"test"' in ann
def test_empty_ndef(self):
"""NDEF TLV with zero length."""
tlv = bytes([0x03, 0x00, 0xFE])
ann = decode_ndef_annotation(tlv)
assert ann is not None
assert "empty" in ann.lower() or "0B" in ann or "NDEF" in ann
def test_long_text_truncated(self):
"""Long text is truncated in annotation."""
long_text = "A" * 200
msg = ndef_text(long_text)
tlv = bytes([0x03, 0xFF, (len(msg) >> 8) & 0xFF, len(msg) & 0xFF]) + msg + bytes([0xFE])
ann = decode_ndef_annotation(tlv)
assert ann is not None
assert "..." in ann # truncated
```
**Step 2: Run tests to see them fail**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestDecodeNdefAnnotation -v`
Expected: FAIL `ImportError: cannot import name 'decode_ndef_annotation'`
**Step 3: Implement decode_ndef_annotation**
Add to `pm3py/hf_15.py`, after the existing `URI_PREFIXES` dict (copy it from type5.py or import it), before the `_15693_CMDS` dict:
```python
# ---- NDEF decode (for trace annotation) ----
# NFC Type 5 CC magic
_NDEF_MAGIC = 0xE1
# URI prefix codes (NFC Forum RTD URI)
_URI_PREFIXES = {
0x00: "",
0x01: "http://www.",
0x02: "https://www.",
0x03: "http://",
0x04: "https://",
0x05: "tel:",
0x06: "mailto:",
}
# TNF values
_TNF_EMPTY = 0x00
_TNF_WELL_KNOWN = 0x01
_TNF_MEDIA = 0x02
_TNF_URI = 0x03
_TNF_EXTERNAL = 0x04
_MAX_ANN_TEXT = 40 # truncate decoded text in annotations
def decode_ndef_annotation(data: bytes) -> str | None:
"""Decode NDEF content from block data for trace annotation.
Handles:
- CC block (4 bytes starting with 0xE1)
- NDEF TLV (starting with 0x03)
- CC + NDEF TLV (READ MULTIPLE from block 0)
Returns annotation string or None if not NDEF.
"""
if len(data) < 4:
return None
parts = []
offset = 0
# Check for CC at start
if data[0] == _NDEF_MAGIC:
ver_major = (data[1] >> 6) & 0x03
ver_minor = (data[1] >> 4) & 0x03
mlen = data[2]
features = data[3]
cc_str = f"CC v{ver_major}.{ver_minor} MLEN={mlen}"
if features & 0x01:
cc_str += " MBREAD"
parts.append(cc_str)
offset = 4
if offset >= len(data):
return cc_str
# Scan for NDEF TLV
while offset < len(data):
tlv_type = data[offset]
offset += 1
if tlv_type == 0x00: # NULL TLV — skip
continue
if tlv_type == 0xFE: # Terminator
break
if tlv_type != 0x03: # Not NDEF — stop
break
# Parse TLV length
if offset >= len(data):
break
tlv_len = data[offset]
offset += 1
if tlv_len == 0xFF:
if offset + 2 > len(data):
break
tlv_len = (data[offset] << 8) | data[offset + 1]
offset += 2
if tlv_len == 0:
parts.append("NDEF empty")
continue
# Parse NDEF message (one or more records)
msg_end = min(offset + tlv_len, len(data))
records = _parse_ndef_records(data[offset:msg_end])
for rec in records:
parts.append(rec)
offset = msg_end
return " | ".join(parts) if parts else None
def _parse_ndef_records(msg: bytes) -> list[str]:
"""Parse NDEF records from raw message bytes. Returns list of annotation strings."""
records = []
offset = 0
while offset < len(msg):
if offset + 3 > len(msg):
break
flags = msg[offset]
tnf = flags & 0x07
sr = bool(flags & 0x10)
il = bool(flags & 0x08)
type_len = msg[offset + 1]
offset += 2
# Payload length
if sr:
if offset >= len(msg):
break
payload_len = msg[offset]
offset += 1
else:
if offset + 4 > len(msg):
break
payload_len = int.from_bytes(msg[offset:offset + 4], "big")
offset += 4
# ID length
id_len = 0
if il:
if offset >= len(msg):
break
id_len = msg[offset]
offset += 1
# Type
if offset + type_len > len(msg):
break
rec_type = msg[offset:offset + type_len]
offset += type_len
# ID (skip)
offset += id_len
# Payload
if offset + payload_len > len(msg):
payload = msg[offset:]
else:
payload = msg[offset:offset + payload_len]
offset += payload_len
# Decode based on TNF + type
ann = _decode_ndef_record(tnf, rec_type, payload)
records.append(ann)
return records
def _decode_ndef_record(tnf: int, rec_type: bytes, payload: bytes) -> str:
"""Decode a single NDEF record into an annotation string."""
if tnf == _TNF_WELL_KNOWN:
if rec_type == b"T" and len(payload) >= 1:
lang_len = payload[0] & 0x3F
lang = payload[1:1 + lang_len].decode("ascii", errors="replace")
text = payload[1 + lang_len:].decode("utf-8", errors="replace")
if len(text) > _MAX_ANN_TEXT:
text = text[:_MAX_ANN_TEXT] + "..."
return f'NDEF Text "{lang}" "{text}"'
if rec_type == b"U" and len(payload) >= 1:
prefix = _URI_PREFIXES.get(payload[0], "")
suffix = payload[1:].decode("utf-8", errors="replace")
uri = prefix + suffix
if len(uri) > _MAX_ANN_TEXT:
uri = uri[:_MAX_ANN_TEXT] + "..."
return f"NDEF URI {uri}"
return f"NDEF WK type={rec_type!r} [{len(payload)}B]"
if tnf == _TNF_MEDIA:
mime = rec_type.decode("ascii", errors="replace")
if len(mime) > 30:
mime = mime[:30] + "..."
return f"NDEF MIME {mime} [{len(payload)}B]"
if tnf == _TNF_EXTERNAL:
ext_type = rec_type.decode("ascii", errors="replace")
return f"NDEF EXT {ext_type} [{len(payload)}B]"
if tnf == _TNF_EMPTY:
return "NDEF empty"
return f"NDEF TNF={tnf} [{len(payload)}B]"
```
**Step 4: Run tests**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestDecodeNdefAnnotation -v`
Expected: All pass
**Step 5: Commit**
```bash
cd /home/work/pm3py/.worktrees/sim-framework
git add pm3py/hf_15.py tests/test_hf_15.py
git commit --no-gpg-sign -m "feat: NDEF TLV + record decoder for trace annotations"
```
---
### Task 2: Wire NDEF decode into response annotations
**Files:**
- Modify: `pm3py/hf_15.py`
- Modify: `tests/test_hf_15.py`
**Step 1: Write failing tests**
Append to `tests/test_hf_15.py`:
```python
class TestResponseNdefDecode:
def test_read_single_block_0_cc(self):
"""READ SINGLE BLOCK #0 response shows CC annotation."""
# Response: flags(1) + block_data(4)
data = bytes([0x00, 0xE1, 0x40, 0x0D, 0x01])
ann = decode_15693_response(data)
assert "CC" in ann
assert "MBREAD" in ann
def test_read_multiple_blocks_ndef(self):
"""READ MULTIPLE BLOCKS response with NDEF shows decoded text."""
# Response: flags(1) + blocks data
msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E,
0x74, 0x65, 0x73, 0x74])
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
data = bytes([0x00]) + tlv + bytes(50)
ann = decode_15693_response(data)
assert '"test"' in ann
def test_read_multiple_with_cc_and_ndef(self):
"""READ MULTIPLE from block 0: CC + NDEF both decoded."""
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E,
0x74, 0x65, 0x73, 0x74])
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
data = bytes([0x00]) + cc + tlv + bytes(40)
ann = decode_15693_response(data)
assert "CC" in ann
assert '"test"' in ann
def test_generic_data_no_ndef(self):
"""Non-NDEF data still shows OK [NB]."""
data = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF])
ann = decode_15693_response(data)
assert "OK [4B]" in ann
def test_inventory_not_affected(self):
"""Inventory response still decoded as inventory, not NDEF."""
data = bytes([0x00, 0x00]) + bytes(8)
ann = decode_15693_response(data)
assert "INVENTORY" in ann
```
**Step 2: Run tests to see them fail**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestResponseNdefDecode -v`
Expected: FAIL `"CC"` not in `"OK [4B]"`
**Step 3: Update decode_15693_response**
Replace the generic response handler section in `decode_15693_response` (the part after error check, starting at `data = payload[1:]`):
```python
def decode_15693_response(payload: bytes) -> str | None:
"""Decode an ISO 15693 response frame into annotation."""
if len(payload) < 1:
return None
flags = payload[0]
if flags & 0x01: # Error
if len(payload) >= 2:
code = payload[1]
desc = _15693_ERRORS.get(code, "")
return f"ERROR 0x{code:02X} {desc}" if desc else f"ERROR 0x{code:02X}"
return "ERROR"
data = payload[1:]
if len(data) == 0:
return "OK"
# Inventory response: dsfid(1) + uid(8) = 9 bytes
if len(data) == 9:
uid_msb = bytes(reversed(data[1:9]))
return f"OK INVENTORY UID={uid_msb.hex().upper()}"
# Try NDEF decode (CC block, NDEF TLV, or both)
ndef_ann = decode_ndef_annotation(data)
if ndef_ann:
return f"OK {ndef_ann}"
return f"OK [{len(data)}B]"
```
**Step 4: Run tests**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py -v`
Expected: All pass (including existing tests verify `test_decode_response_ok_data` still passes since it uses non-NDEF data)
**Step 5: Commit**
```bash
cd /home/work/pm3py/.worktrees/sim-framework
git add pm3py/hf_15.py tests/test_hf_15.py
git commit --no-gpg-sign -m "feat: wire NDEF decode into ISO 15693 response annotations"
```
---
### Task 3: Annotation overflow wrapping
**Files:**
- Modify: `pm3py/hf_15.py` (sniff formatter)
- Modify: `pm3py/sim/trace_fmt.py` (sim formatter)
- Modify: `tests/test_hf_15.py`
- Modify: `tests/test_sim_trace_fmt.py`
The current formatters already wrap hex to multiple lines when it's too wide. But annotations are either right-justified on the last hex line (short) or on their own line (wrapped hex). NDEF annotations can be long (e.g. `OK CC v1.0 MLEN=13 MBREAD | NDEF Text "en" "test"`). When the annotation overflows the available width, it should wrap to the next line at the annotation column.
**Step 1: Write failing tests**
In `tests/test_hf_15.py`, append:
```python
class TestSniffLineAnnotationOverflow:
def test_long_annotation_wraps(self):
"""Long NDEF annotation wraps to continuation line."""
# Build a response with CC + NDEF text that produces a long annotation
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
long_text = "A" * 30
from pm3py.sim.type5 import ndef_text
msg = ndef_text(long_text)
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
payload = bytes([0x00]) + cc + tlv
# Add CRC
payload += bytes([0xAA, 0xBB])
entry = {"is_response": True, "data": payload,
"timestamp": 0, "duration": 0, "parity": b""}
line = format_sniff_line(entry, width=80, is_tty=False)
lines = line.split("\n")
# Annotation should be on a separate continuation line
ann_lines = [l for l in lines if "NDEF" in l]
assert len(ann_lines) >= 1
def test_short_annotation_no_wrap(self):
"""Short annotation stays on same line as hex."""
data = bytes([0x00, 0xE1, 0x40, 0x0D, 0x01, 0xAA, 0xBB])
entry = {"is_response": True, "data": data,
"timestamp": 0, "duration": 0, "parity": b""}
line = format_sniff_line(entry, width=120, is_tty=False)
assert "CC" in line
# Should be single-line (plus possible hex wrap)
non_empty = [l for l in line.split("\n") if l.strip()]
assert len(non_empty) <= 2 # hex + annotation on same or next line
```
In `tests/test_sim_trace_fmt.py`, append to `TestTraceFormatterWrapping`:
```python
def test_long_annotation_wraps_to_next_line(self):
"""Long NDEF annotation wraps to continuation line."""
# Build response with CC + NDEF that gives a long annotation
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
from pm3py.sim.type5 import ndef_text
msg = ndef_text("A" * 30)
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
payload = bytes([0x00]) + cc + tlv
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=80)
result = self._strip_ansi(fmt.format(1, payload))
lines = result.strip().split("\n")
ann_lines = [l for l in lines if "NDEF" in l]
assert len(ann_lines) >= 1
```
**Step 2: Run tests to see them fail**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestSniffLineAnnotationOverflow tests/test_sim_trace_fmt.py::TestTraceFormatterWrapping::test_long_annotation_wraps_to_next_line -v`
Expected: Might pass or fail depending on current wrapping behavior the annotation might already go on its own line if hex is wrapped. Run to check.
**Step 3: Update annotation wrapping in both formatters**
In `format_sniff_line()` in `hf_15.py`, replace the annotation handling in the multi-line section (the `if annotation:` block near the end):
```python
if annotation:
# Wrap annotation if it exceeds available width
ann_lines = _wrap_annotation(annotation, avail)
for al in ann_lines:
ann_pad = max(avail - len(al), 0)
parts.append(f"{pad}{' ' * ann_pad}{_color(ann_color, al, is_tty)}")
```
And add the helper (shared between both formatters):
```python
def _wrap_annotation(annotation: str, avail: int) -> list[str]:
"""Wrap annotation text to fit within avail columns.
Splits on ' | ' boundaries first, then by word if still too long.
"""
if len(annotation) <= avail:
return [annotation]
# Try splitting on pipe separator (NDEF uses " | " between parts)
if " | " in annotation:
parts = annotation.split(" | ")
lines = []
current = parts[0]
for part in parts[1:]:
candidate = f"{current} | {part}"
if len(candidate) <= avail:
current = candidate
else:
lines.append(current)
current = part
lines.append(current)
return lines
# Fall back to truncation
return [annotation[:avail - 3] + "..."]
```
In `trace_fmt.py`, apply the same pattern to the `TraceFormatter.format()` method's multi-line annotation section. Import `_wrap_annotation` from `hf_15` or duplicate the logic inline:
In the multi-line section of `TraceFormatter.format()`, replace:
```python
if annotation:
ann_pad = avail - len(annotation)
ann_pad = max(ann_pad, 0)
parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, annotation)}")
```
With:
```python
if annotation:
ann_lines = self._wrap_annotation(annotation, avail)
for al in ann_lines:
ann_pad = max(avail - len(al), 0)
parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, al)}")
```
And add to `TraceFormatter`:
```python
def _wrap_annotation(self, annotation: str, avail: int) -> list[str]:
"""Wrap annotation to fit within avail columns."""
if len(annotation) <= avail:
return [annotation]
if " | " in annotation:
parts = annotation.split(" | ")
lines = []
current = parts[0]
for part in parts[1:]:
candidate = f"{current} | {part}"
if len(candidate) <= avail:
current = candidate
else:
lines.append(current)
current = part
lines.append(current)
return lines
return [annotation[:avail - 3] + "..."]
```
**Step 4: Run all tests**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py tests/test_sim_trace_fmt.py -v`
Expected: All pass
**Step 5: Commit**
```bash
cd /home/work/pm3py/.worktrees/sim-framework
git add pm3py/hf_15.py pm3py/sim/trace_fmt.py tests/test_hf_15.py tests/test_sim_trace_fmt.py
git commit --no-gpg-sign -m "feat: annotation overflow wrapping for NDEF decode in trace output"
```
---
### Task 4: Copy hf_15.py to main branch + final test
**Files:**
- Copy: `pm3py/hf_15.py` `/home/work/pm3py/pm3py/hf_15.py`
- Copy: `tests/test_hf_15.py` `/home/work/pm3py/tests/test_hf_15.py`
**Step 1: Copy files**
```bash
cp /home/work/pm3py/.worktrees/sim-framework/pm3py/hf_15.py /home/work/pm3py/pm3py/hf_15.py
cp /home/work/pm3py/.worktrees/sim-framework/tests/test_hf_15.py /home/work/pm3py/tests/test_hf_15.py
```
**Step 2: Run main branch tests**
Run: `cd /home/work/pm3py && python -m pytest tests/ -v`
Expected: All pass
**Step 3: Run worktree full test suite**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/ -v`
Expected: All pass

View File

@@ -0,0 +1,333 @@
# Live Sniff Streaming — Design
## Overview
Rewrite sniff workflow from batch (sniff → button stop → download → decode) to live
streaming with button-toggle pause/resume. User types `session.start_15693()` in the
REPL, sees trace output in real-time, toggles sniffing with the PM3 button, and
accumulates entries across pause/resume cycles.
Three changes:
1. **Auto-load**`.pythonstartup.py` creates a `session` object in sniff mode
2. **Live streaming** — firmware flushes trace entries over USB on RF idle
3. **Button-toggle** — firmware pause/resume within a persistent sniff session
## Protocol
### New command IDs (`pm3_cmd.h`)
| Command | Value | Direction | Purpose |
|---------|-------|-----------|---------|
| `CMD_HF_SNIFF_STREAM` | 0x0337 | FW→Host | Fire-and-forget trace entry (like `SIM_TRACE`) |
| `CMD_HF_SNIFF_STATUS` | 0x0338 | FW→Host | State change: started / paused / resumed / stopped |
### Sniff command payload
Sent with existing `CMD_HF_ISO15693_SNIFF` (and future `CMD_HF_ISO14443A_SNIFF`).
Legacy behavior preserved when `flags=0`.
```c
struct sniff_params_t {
uint16_t idle_timeout_ms; // flush threshold (default 400ms for 15693)
uint8_t flags; // bit 0: live streaming (1=stream, 0=legacy BigBuf)
// bit 1: button-toggle (1=pause/resume, 0=exit on press)
} PACKED;
```
### Stream trace payload (`CMD_HF_SNIFF_STREAM`)
```c
struct sniff_trace_t {
uint8_t protocol; // 0=15693, 1=14443A
uint8_t direction; // 0=reader->tag, 1=tag->reader
uint16_t duration; // frame duration ticks
uint32_t timestamp; // us since sniff start
uint8_t data[]; // frame bytes (variable length)
} PACKED;
```
### Status payload (`CMD_HF_SNIFF_STATUS`)
```c
struct sniff_status_t {
uint8_t state; // 0=started, 1=paused, 2=resumed, 3=stopped
uint16_t count; // total frames captured so far
} PACKED;
```
## Firmware changes
### `iso15693.c` — `SniffIso15693()`
Current function signature gains parameters from the new payload. When
`flags & SNIFF_FLAG_STREAMING` is unset, existing behavior is unchanged.
#### Streaming + button-toggle flow
```
SniffIso15693(idle_timeout_ms, flags):
if (!(flags & SNIFF_FLAG_STREAMING)):
// existing behavior, unchanged
return
// --- streaming mode ---
ring_buf[TRACE_RING_SIZE] // 8-entry ring (same pattern as sim)
trace_rd = trace_wr = 0
state = SNIFF_ACTIVE
frame_count = 0
reply_ng(CMD_HF_SNIFF_STATUS, {state=0, count=0}) // "started"
while (state != SNIFF_STOPPED):
// --- button toggle ---
if (BUTTON_PRESS()):
debounce(200ms)
if (state == SNIFF_ACTIVE):
TRACE_FLUSH()
state = SNIFF_PAUSED
reply_ng(CMD_HF_SNIFF_STATUS, {state=1, count=frame_count})
LED_C_OFF()
else:
state = SNIFF_ACTIVE
reply_ng(CMD_HF_SNIFF_STATUS, {state=2, count=frame_count})
LED_C_ON()
// --- paused: spin ---
if (state == SNIFF_PAUSED):
WDT_HIT()
// check for BREAK_LOOP (below)
goto check_break
// --- existing DMA decode logic ---
// on decoded frame:
TRACE_PUSH(direction, data, len, timestamp, duration)
frame_count++
last_activity = GetTickCount()
// --- idle flush ---
if (trace_wr != trace_rd &&
(GetTickCount() - last_activity) > idle_timeout_ms):
TRACE_FLUSH()
check_break:
// --- Python stop ---
if (data_available()):
receive_ng(&rx)
if (rx.cmd == CMD_BREAK_LOOP):
TRACE_FLUSH()
state = SNIFF_STOPPED
reply_ng(CMD_HF_SNIFF_STATUS, {state=3, count=frame_count})
```
#### TRACE_PUSH / TRACE_FLUSH macros
Reuse the sim pattern. Each `TRACE_FLUSH` entry sent as:
```c
reply_ng(CMD_HF_SNIFF_STREAM, PM3_SUCCESS, &sniff_trace_t, sizeof(hdr) + data_len);
```
Payload is `sniff_trace_t` with protocol=0 (15693), direction, duration, timestamp,
and the raw frame bytes.
#### LED feedback
| State | LED |
|-------|-----|
| Sniffing active | Green ON (Easy=A, RDV4=B) |
| Frame captured | Orange blink (Easy=C, RDV4=A) |
| Paused | Green OFF |
#### Ring buffer sizing
8 entries. At 15693 speeds (~10-20 frames/sec during active reader polling),
8 entries buffer ~400-800ms of traffic. Flush on idle empties before overflow.
### `pm3_cmd.h` — New definitions
```c
#define CMD_HF_SNIFF_STREAM 0x0337
#define CMD_HF_SNIFF_STATUS 0x0338
#define SNIFF_FLAG_STREAMING 0x01
#define SNIFF_FLAG_BUTTON_TOGGLE 0x02
#define SNIFF_STATE_STARTED 0
#define SNIFF_STATE_PAUSED 1
#define SNIFF_STATE_RESUMED 2
#define SNIFF_STATE_STOPPED 3
```
## Python changes
### `sniff/session.py` — `SniffSession` rewrite
Persistent session with background reader thread, mirroring `SimSession`.
```python
class SniffSession:
def __init__(self, ser):
self._ser = ser # raw pyserial (like SimSession)
self._active = False
self._entries = [] # accumulated parsed trace entries
self._reader_thread = None
self._formatter = TraceFormatter()
self._state = "idle" # idle / sniffing / paused / stopped
@classmethod
def open(cls, port=None, baudrate=115200):
"""Auto-detect PM3 port and open serial connection."""
if port is None:
port = find_pm3_port()
ser = serial.Serial(port, baudrate, timeout=0.1)
return cls(ser)
def start_15693(self, live=True, idle_timeout_ms=400):
"""Start 15693 sniff session.
live=True: streaming mode with button-toggle pause/resume (default)
live=False: legacy BigBuf mode, button exits
"""
self._protocol = 0
flags = 0
if live:
flags |= SNIFF_FLAG_STREAMING | SNIFF_FLAG_BUTTON_TOGGLE
payload = struct.pack("<HB", idle_timeout_ms, flags)
self._send_ng(Cmd.HF_ISO15693_SNIFF, payload)
self._active = True
if live:
self._reader_thread = threading.Thread(
target=self._stream_reader, daemon=True)
self._reader_thread.start()
else:
# Legacy: block, download, decode (existing behavior)
self._legacy_sniff()
def _stream_reader(self):
"""Background thread: read and print trace frames live."""
buf = bytearray()
while self._active:
chunk = self._ser.read(self._ser.in_waiting or 1)
buf.extend(chunk)
while self._try_decode_frame(buf):
pass
def _try_decode_frame(self, buf):
"""Decode one frame from buffer. Returns True if frame consumed."""
# Scan for response magic 0x62334d50
# CMD_HF_SNIFF_STREAM → parse, append to self._entries, print live
# CMD_HF_SNIFF_STATUS → update self._state, print status line
# CMD_HF_ISO15693_SNIFF (final) → self._active = False
...
def stop(self):
"""End sniff session from Python (sends BREAK_LOOP)."""
self._send_ng(Cmd.BREAK_LOOP, b"")
if self._reader_thread:
self._reader_thread.join(timeout=2.0)
self._active = False
self._state = "stopped"
def close(self):
"""Stop and close serial port."""
if self._active:
self.stop()
self._ser.close()
@property
def entries(self):
"""All captured trace entries across pause/resume cycles."""
return list(self._entries)
def clear(self):
"""Clear accumulated entries."""
self._entries.clear()
```
#### Status output
```
[Snf] >> Started
[Snf] Reader -> Tag: 26 01 00 INVENTORY
[Snf] Tag -> Reader: 00 E0 04 ... INVENTORY RESPONSE
[Snf] || Paused (12 frames)
[Snf] >> Resumed (12 frames)
[Snf] [] Stopped (28 frames)
```
### `.pythonstartup.py` — Auto-load session
In `sniff` mode (and `all` mode), auto-create session:
```python
if _MODE in ("sniff", "all"):
from pm3py.sniff.session import SniffSession
# ... other sniff imports ...
try:
session = SniffSession.open()
print("Sniff session ready -- session.start_15693()")
except Exception as e:
print(f"Sniff session: {e}")
```
No `pm3` client created in sniff mode — `session` owns the serial port exclusively,
same pattern as sim mode.
## Protocol-agnostic design
The `sniff_trace_t.protocol` field and `sniff_params_t` payload structure are shared
across protocols. Only 15693 is implemented now. To add 14443-A later:
1. Parse `sniff_params_t` in `SniffIso14443a()`
2. Use same `CMD_HF_SNIFF_STREAM` / `CMD_HF_SNIFF_STATUS` commands
3. Set `protocol=1` in trace entries
4. Python `_stream_reader` dispatches to `decode_14a()` based on protocol field
5. Add `session.start_14a(live=True, idle_timeout_ms=100)` — 14443-A is faster,
shorter idle timeout appropriate
## Idle timeout rationale
15693 default: **400ms**. A typical inventory+read cycle completes in ~20-30ms. Reader
poll interval is 50-100ms. 400ms = 4 missed polls = reader is definitely gone. Short
enough that trace appears almost instantly after removing the device.
Configurable via `idle_timeout_ms` parameter for different reader behaviors.
## Legacy compatibility
`live=False` preserves existing behavior exactly:
- `flags=0` in payload → firmware takes legacy path (unchanged code)
- Python blocks, waits for button exit, downloads BigBuf, decodes batch
- No background thread, no streaming
## REPL workflow
```
$ source activate sniff
$ python
Sniff session ready -- session.start_15693()
>>> session.start_15693()
[Snf] >> Started
# hold phone to antenna...
[Snf] Reader -> Tag: 26 01 00 INVENTORY
[Snf] Tag -> Reader: 00 E0 04 ... INVENTORY RESPONSE
[Snf] Reader -> Tag: 22 20 04 ... READ SINGLE BLOCK blk=0
[Snf] Tag -> Reader: 00 E1 10 06 01 ... READ SINGLE BLOCK RESPONSE
# pull phone away, 400ms later trace flushes
# press button to pause
[Snf] || Paused (12 frames)
# examine data in REPL
>>> session.entries[-1]
{'timestamp': 42318, 'direction': 1, 'data': b'\x00\xe1\x10...', ...}
# press button to resume for more samples
[Snf] >> Resumed (12 frames)
# done
>>> session.stop()
[Snf] [] Stopped (28 frames)
>>> len(session.entries)
28
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
# Firmware Upstream Rebase Plan (2026-07-03)
Rebase the `firmware/` submodule (fork `dangerous-tac0s/proxmark3-pm3py`)
onto current RRG `upstream/master` **without losing our sim / sniff /
inventory patch**. Backed by a non-destructive dry-run (worktree + merge-tree).
## Current state (measured)
- **merge-base:** `c7086c227` (2026-02-20) — where we branched.
- **our patch:** 33 commits on top of merge-base (main == `35db99fa5`).
- **upstream ahead:** 1049 commits (tip `87388b389`, 2026-07-03) — ~4.5 months.
- Firmware is on `main`; ALM branch already scrapped.
## Dry-run findings — the conflict surface is small and concentrated
A **squash-then-rebase** (all 33 commits applied as one combined diff, per
`git merge-tree`) conflicts in **exactly 3 files**; everything else
auto-merges or is untouched:
| File | Verdict | ours vs upstream (lines) |
|------|---------|--------------------------|
| `armsrc/iso15693.c` | ⚠️ real work — 13 hunks | +599/146 vs +523/214 |
| `client/src/cmdhf15.c` | ⚠️ moderate | +372/7 vs +544/45 |
| `include/pm3_cmd.h` | ✅ trivial (textual re-append) | +21/0 vs +94/113 |
| `appmain.c`, `iclass.c`, `iso15693.h`, `iso15.h`, `Makefile` | ✅ auto-merge | — |
| `sim_table.c/h`, `sim_crypto.c/h`, `Standalone/hf_15sniff.c`, `Standalone/hf_unisniff.c` | ✅ upstream never touched | — |
A **sequential** rebase (replay 33 commits) is worse: it conflicts on
commit #1 and, because **24 of the 33 commits touch `iso15693.c`**, it
re-conflicts that file many times. → **Squash first, resolve once.**
### CMD-ID collision check — CLEAR ✅
All six IDs we added are free in upstream (upstream's `0x033x` block only
reaches `CSETUID_V2 0x0333`; `0x095x` is empty):
```
0x0336 CMD_HF_ISO15693_SIM_TRACE 0x0950 CMD_SIM_TABLE_UPLOAD
0x0337 CMD_HF_SNIFF_STREAM 0x0951 CMD_SIM_TABLE_CLEAR
0x0338 CMD_HF_SNIFF_STATUS 0x0952 CMD_SIM_TABLE_UPDATE
```
So `pm3_cmd.h` is a pure textual re-append (drop our `#define`s at a clean
spot in the churned region), **not** a semantic collision.
### `iso15693.c` hunk map (13 hunks, 2 functions)
- **15693 sniff loop (~L15981853):** our streaming-sniff feature (ring
buffer, button-toggle, idle flush) vs upstream sniff edits. Biggest hunk
~53 lines (our idle/flush block) — mostly *keep-ours-add-both*.
- **SimTag / sim loop (~L24753372):** our sim handler (table lookup,
access control, random UID, periodic field report) + the `CheckCrc15`
4-byte-frame fix vs upstream. ~10 hunks: three large (span 3643, our
inserted blocks → take-ours), the rest small (≤16) needing genuine
line-level reconciliation — notably **our `CheckCrc15` vs upstream's
`CalculateCrc15`** at ~L2591 (keep ours; it fixes 4-byte frames).
Effort estimate: **~half a day** of focused merge work, dominated by
`iso15693.c`; `cmdhf15.c` moderate; `pm3_cmd.h` ~10 min.
## Strategy: squash-to-one → rebase (single 3-file resolution) → validate → re-split
Resolve conflicts exactly once (matching the merge-tree preview), validate
the build, then restore clean per-file commits for cheap future rebases
(the design-doc "atomic single-file commit" model).
### Procedure (all in an isolated worktree — real checkout never moves)
```bash
FW=/home/work/pm3py/firmware
WT=<scratchpad>/fw-rebase
git -C $FW fetch upstream master
git -C $FW worktree add -b rebase-wip "$WT" 35db99fa5
cd "$WT"
# 1. collapse 33 commits into one diff on our base (no upstream yet → no conflicts)
MB=$(git merge-base HEAD upstream/master)
git reset --soft "$MB"
git commit -m "pm3py sim+sniff+inventory patch (squashed for rebase)"
# 2. rebase the single commit → conflicts in the 3 known files, resolved once
git rebase upstream/master # resolve iso15693.c, cmdhf15.c, pm3_cmd.h
# ... resolve, git add, git rebase --continue
# 3. re-split into logical per-file commits (soft reset + staged adds)
git reset --soft upstream/master
# commit A: sim_table.c/h + sim_crypto.c/h (new modules)
# commit B: pm3_cmd.h + iso15.h + iso15693.h (IDs + struct fields)
# commit C: appmain.c (dispatch)
# commit D: iso15693.c (sim + sniff + inventory)
# commit E: cmdhf15.c (client reader/inventory)
# commit F: Makefile + Standalone/* (build glue)
```
### Validation (all local per build-workflow — ARM builds here, not the Mac)
1. `make -C "$WT" clean && make -C "$WT" PLATFORM=PM3GENERIC` — ARM compiles.
2. `make -C "$WT" client` — validates `cmdhf15.c`.
3. `python -m pytest tests/` in pm3py — sim tests are transport-mocked, so
they stay green; confirms no Python-side regression.
4. **Hardware smoke test (user, has PM3):** flash, then `hf 15` scan +
15693 sim + a phone read (the known-good sim path). This is the only
check the dry-run can't cover — upstream may have altered 15693 wire
behavior around our insertions.
### Landing
- Show resolved `iso15693.c` diff for review **before** anything touches
`main`.
- Fast-forward `firmware` main to `rebase-wip`, bump the submodule pointer
in a pm3py commit, force-push the fork (`--force-with-lease`).
- Remove the worktree (`git worktree remove`, no `--force`).
## Risks & rollback
- **Semantic drift in `iso15693.c`:** upstream may have changed shared
helpers our sim path calls. Compile catches signature breaks; behavior
needs the hardware smoke test.
- **Rollback:** old main is preserved. Recovery ref for pre-rebase tip:
`35db99fa5`. Nothing is force-pushed until build + review pass.
- **Safety Net:** `branch -D`, `rm -rf`, `git clean -f`, `worktree remove
--force` are blocked — use `-d`/plain `rm`/`worktree remove` (no force),
or hand force-ops to the user.
## Go-forward (close the gap that let us drift 1049 commits)
From the earlier maintenance discussion, still unbuilt:
- `pm3py/firmware_compat.py` — pin `UPSTREAM_TESTED_COMMIT` = `87388b389`.
- A **real** rebase-check CI (the existing `firmware/.github/workflows/
rebase.yml` is upstream's Changelog Reminder, not ours).
```

View File

@@ -0,0 +1,117 @@
# Custom ISO14443-A command handling for the card sim — Design
**Status:** Design approved, unimplemented · **Date:** 2026-07-05 · **Plan:** [2026-07-05-14a-custom-commands-plan.md](2026-07-05-14a-custom-commands-plan.md)
## Problem
The 14a card sim serves the *standard* command set from firmware-native handlers
backed by emulator RAM (EML). It has **no mechanism for custom/vendored commands**:
- **14a-3 (Layer 3):** proprietary commands common in the parts we model — most
importantly **NTAG I2C** `SECTOR_SELECT` (0xC2) + cross-sector `READ`/`FAST_READ`,
plus vendor reads (ST25TN system block / product ID). Today an unknown L3 command
falls through `SimulateIso14443aTagEx`'s final `else` and gets **no response**.
- **14a-4 (Layer 4 / ISO-DEP):** vendor APDUs and crypto (DESFire, JCOP, EMV, custom
applets). Today RATS→ATS is answered, but any I-block APDU gets a **blanket fake
`90 00`** (`iso14443a.c:2529-2545`) with no parsing — wrong for everything real.
This contrasts with the **15693 sim**, which already serves custom commands from a
Python-compiled response table in BigBuf (`sim_table`) consulted in its `default:`
case (`iso15693.c:~3081`). That mechanism is proven; 14a simply never got it wired in.
## Constraints
1. **86µs FDT at Layer 3.** A tag answer must start ~86µs after the reader command
ends. This rules out a live USB round-trip for L3 — no reader-command → host →
response is possible inside the window.
2. **Reuse over reinvention.** The firmware is a fork maintained with atomic
single-file commits for easy rebase against upstream PM3. The 15693 `sim_table`
server, the 120-byte `TableEntry` wire format, the `TableCompiler`, and the
`0x0950-0x0952` upload command IDs already exist and are fork-local.
3. **Additive, non-invasive.** The native-EML sim is correct and battle-tested for
standard commands. Custom-command support must *layer on top* of it, not replace it.
## Decision
**A table-first L3 + WTX-L4 hybrid, layered on the native-EML sim.** Five mechanisms,
each the cheapest *correct* one for its command class, all tracing through the existing
`EmLogTrace` chokepoint:
| # | Layer | Mechanism | Handles | Host round-trip? |
|---|-------|-----------|---------|------------------|
| A | L3 | **Native-EML** (unchanged) | Standard: anticoll, READ, FAST_READ, WRITE, GET_VERSION, READ_SIG, counters, RATS | No |
| B | L3 | **Native-EML + sector offset** (graft) | NTAG I2C cross-sector READ/FAST_READ | No |
| C | L3 | **Static `sim_table`** (new hook) | Custom-command *state machine* (SECTOR_SELECT), static vendor reads | No (pre-uploaded) |
| D | L3 | **Native firmware auth** (existing branches) | UL-C 3DES, UL-AES, MFC Crypto1 | No |
| E | L4 | **Static `sim_table`** (same hook) | Deterministic ISO-DEP APDUs: Type-4 NDEF SELECT/ReadBinary, canned GET/PUT DATA | No (pre-uploaded) |
| F | L4 | **WTX relay** (new) | Dynamic ISO-DEP crypto: DESFire/JCOP/EMV, unpredictable reader nonce | Yes (S(WTX) buys seconds) |
The whole thing is **three additive firmware hooks** in `SimulateIso14443aTagEx`,
copied in spirit from the 15693 sim:
1. **Host-poll** at loop top — `data_available()`/`receive_ng()` handling
`CMD_SIM_TABLE_UPLOAD/UPDATE/CLEAR`, EML load, relay responses (mirrors
`iso15693.c:2472-2508`). Prerequisite: without it the table is empty forever.
2. **Table-check** at the top of the final `else` (`:2493`) — normalize, `sim_table_lookup`,
`sim_table_execute`; placed *before* the ST25TA block and the fake-`90 00` switch so
the table overrides those fakes. On an L4 I-block miss with ISO-DEP active, falls into
the WTX relay.
3. **Sector-offset graft** on native `READ`/`FAST_READ` — add `sim_active_sector()*0x400`
to the `emlGet` source offset.
## Why not the alternatives
- **Relay-first at L3 (rejected).** Serving custom L3 commands by relaying to Python on
a reader *retry* was scored `correctness 2 / fdt_safety 2` and rejected: the 86µs FDT
is unreachable over USB, and a reader that gives up drops to REQA/WUPA + anticollision,
losing the very state (selected sector) the relay was trying to serve. Retry-relay
survives only as a debug affordance.
- **Per-page table for cross-sector reads (rejected).** Representing NTAG I2C sectors as
hundreds of per-page `sim_table` entries blows the linear-scan lookup toward the FDT and
the entry/response-size ceilings. Instead, **reads stay native** and become
sector-aware via a one-line offset — O(1), wire-speed, no entry explosion.
## The SECTOR_SELECT problem → group state + a firmware projection
NTAG I2C `SECTOR_SELECT` is the hard case that shaped the design. It is:
- **Two-phase:** `C2 FF` (packet 1) → 4-bit ACK, then a *bare* sector byte `s 00 00 00`
(packet 2, no opcode) → **silent success** (the tag transmits nothing for >1ms).
- **Stateful and persistent:** it changes the addressing of *all subsequent* READs until
the next SECTOR_SELECT or a re-selection.
This is handled by the `sim_table` **group state machine** plus a read-only firmware
projection:
- Phase 1 (`C2 FF`) activates a transient `GRP_SEL_PENDING` group and 4-bit-ACKs.
- Phase 2 (`s 00 00 00`) matches **only inside `GRP_SEL_PENDING`** (so a stray `00`/`01`
frame can't false-flip the sector), sets `GRP_SECTOR_s`, clears the pending group, and
responds with `response_len == 0` = the passive-ACK silence.
- An invalid-sector catch-all in `GRP_SEL_PENDING` returns a 4-bit NAK.
- `sim_active_sector()` derives the active sector **read-only** from the group register,
so the group register stays the single source of truth (no drift between routing and
read data), and native READ/FAST_READ offset into the right sector.
This is the "graft" that resolves both judges' top risks: it keeps the table tiny
(~6 entries, not 256) so FDT is trivially safe, and it makes native-firing-first
*correct* (reads are sector-aware) instead of a bug that serves flat sector-0 data.
## Crypto taxonomy correction
The judges flagged a miscategorization worth stating plainly: **Ultralight-C 3DES
(0x1A/0xAF) and UL-AES are Layer-3 framed** (no ISO-DEP). They are servable by *neither*
the table (unpredictable reader `RndB`) nor the L4 WTX relay (86µs FDT). They use the
**existing native firmware auth branches** (`iso14443a.c:2242/2273/2324/2355`) with the
model key loaded into EML — a "native-auth" track (mechanism D), first cut with the
firmware's fixed nonce (a documented fidelity gap vs the model's random `RndB`). Only
genuinely dynamic *ISO-DEP* crypto (DESFire/JCOP AES) goes to the L4 relay.
## Provenance
This design was produced by a multi-agent workflow: 6 parallel readers mapped the 14a
sim dispatch, the proven 15693 `sim_table` + retry-relay template, the tag-model custom
commands, the `TableCompiler`, the WTX-relay design, and the L3 timing/retry reality;
3 competing designs (static-table-first, relay-first, hybrid) were adversarially judged;
and the result was synthesized. Judge outcome: static-table-first and hybrid both
"adopt-with-changes" (reuse 5/5); relay-first "reject" (FDT-unsafe). See the plan doc for
the sequenced, implementation-grade steps.

View File

@@ -0,0 +1,248 @@
# Custom ISO14443-A command handling — Implementation Plan
**Status:** Planned, unimplemented · **Date:** 2026-07-05 · **Design:** [2026-07-05-14a-custom-commands-design.md](2026-07-05-14a-custom-commands-design.md)
Serve custom/vendored ISO14443-A commands in the card sim, layered on the native-EML
14a sim. Two independently shippable tracks: **L3** (NTAG I2C SECTOR_SELECT + cross-sector
reads, static vendor reads, native auth) and **L4** (static ISO-DEP APDUs + WTX relay for
dynamic crypto). Reuses the proven 15693 `sim_table` server, the 120-byte `TableEntry`
format, and the `TableCompiler`. All firmware line numbers are anchors against the current
`firmware/armsrc/iso14443a.c`; each firmware change lands as an atomic single-file commit
anchored to **stable symbols** (`ISO14443A_CMD_RATS`, the final `else`,
`EmSendPrecompiledCmd`), not line numbers.
---
## L3 track — mechanisms
- **(A) Native-EML (unchanged):** REQA/WUPA/anticoll/SELECT/PPS/GET_VERSION/READ_SIG/
READ/FAST_READ/WRITE/COMPAT_WRITE/READ_CNT/INCR_CNT/HALT/RATS. Byte-correct within
86µs, zero new code. WRITE mutates the EM BigBuf so later reads reflect writes natively.
- **(B) Native-EML + sector offset (graft):** cross-sector READ/FAST_READ.
`sim_active_sector()` maps the `sim_table` active-group bits → `{0,1,3}`; native READ
(`:2033-2058`) and FAST_READ (`:2081-2093`) add `sim_active_sector()*0x400` to the
`emlGet` source offset. Python pre-zeroes invalid pages in the uploaded multi-sector EML
image so native 00-fill falls out for free. Reads stay O(1)-native, sector-correct.
- **(C) Static `sim_table` (new `:2493` hook):** the SECTOR_SELECT *state machine* +
genuinely-custom static reads only (see worked example). No per-page entries.
- **(D) Native firmware auth (existing branches):** UL-C 3DES (`:2242`), UL-AES
(`:2273/2324/2355`), MFC Crypto1 — model key in EML, firmware computes challenge/response,
first cut fixed-nonce. Not table, not relay.
**L3 miss policy:** reader-retry relay is debug-only (86µs FDT is unreachable over USB).
Everything reachable at L3 is covered by AD.
## L4 track — mechanisms
Gated by a new `iso_dep_active` flag (set at RATS→ATS `:2234`; cleared on HALT / S(DESELECT)).
- **(E) Static `sim_table`** (same `:2493` hook): deterministic ISO-DEP APDUs — Type-4
NDEF SELECT-App(AID) / SELECT-File(CC,NDEF) / ReadBinary, canned GET/PUT DATA, fixed
`90 00`. Firmware masks the PCB (`(pcb&0xE2)==0x02` = I-block), computes
`off = 1 + CID + NAD`, strips the trailing CRC, `MATCH_PREFIX` on the bare APDU; on hit
re-wraps (`response[0]=pcb`, CID re-inserted at `response[1]` when `pcb&0x08`, payload at
`response+off`, `AddCrc14A`). **Overrides the bogus blanket `90 00`** at `:2529-2545`
which is exactly why the hook sits at `:2493` (before the switch), not `:2576`.
- **(F) WTX relay** (dynamic crypto): on an I-block table miss with `iso_dep_active`,
firmware sends a tag-side **S(WTX)** to buy time (FWT×59 ≈ 4.8s), relays the normalized
APDU to Python, spin-waits for the computed response, re-wraps and sends. The Python
transponder model owns per-session nonce/IV/auth state.
**L4 chaining** (gating dependency for DESFire/NDEF coverage): tag block-number toggle
(PCB bit0 per I-block), reader→tag reassembly (PCB bit4 → R(ACK) `0xA2|bn`), tag→reader
FSD split into chained I-blocks. `DYNAMIC_RESPONSE_BUFFER_SIZE` bumped `64→256` first.
v1 gated to single-frame + `0xAF` continuation until chaining lands.
---
## Firmware steps
| ID | File | Change |
|----|------|--------|
| **FW-1** | `armsrc/sim_table.h` + `.c` | Generalize `sim_table_execute` from `(iso15_tag_t *tag)` to `(uint8_t *eml_data, uint16_t eml_size, uint32_t *auth_state)`; replace `tag->data`/`sizeof(tag->data)`/`tag->auth_state` at `sim_table.c:132-169`. Drop `#include iso15.h` from the header. Add `#define SIM_FLAG_RESP_4BIT 0x04`. Struct unchanged (120 bytes PACKED). Fork-local. |
| **FW-2** | `armsrc/iso15693.c` | Update the single `sim_table_execute` call site (`~:3081`) to the new signature: `(tag->data, sizeof(tag->data), &tag->auth_state)`. Keeps 15693 green. |
| **FW-3** | `armsrc/iso14443a.c` (commit A) | `#include sim_table.h`. Add file-scope `static uint32_t s_auth_state` and `sim_active_sector()` near the sim-trace statics (`~:1727`); reset `s_auth_state=0` in the `FLAG_SIM_TRACE` arm. Bump `DYNAMIC_RESPONSE_BUFFER_SIZE 64→256` and the modulation buffer. Insert **host-poll** block at loop top after `sim_trace_flush` (`~:1907`): `CMD_SIM_TABLE_UPLOAD` (4-byte `initial_groups` + 120B entries → `sim_table_init/add`), `UPDATE`, `CLEAR`, `CMD_HF_MIFARE_EML_MEMSET` (`emlSet`), `CMD_BREAK_LOOP`. Copied ~verbatim from `iso15693.c:2472-2508`. |
| **FW-4** | `armsrc/iso14443a.c` (commit B) | **L3 table-check** at `:2493` (after the `response_n`/`modulation_n` reset, before `tagType==10` and the fake-`90 00` switch): `sim_table_lookup` + `sim_table_execute`; honor `SIM_FLAG_RESP_4BIT` (`EmSend4bit`), `response_len==0` (passive-ACK silence), byte response (join the `:2591` build path), `SIM_FLAG_APPEND_CRC`. **Sector-offset graft** on native READ/FAST_READ (`sim_active_sector()*0x400`). Add labels `build_send:` (`:2591`) and `after_send:` (past `:2620`). |
| **FW-5** | `include/pm3_cmd.h` | Append `CMD_SIM_RELAY_EVENT 0x0953` and `CMD_SIM_RELAY_RESP 0x0954` in the existing `0x095x` block. Additive. |
| **FW-6** | `armsrc/iso14443a.c` (commit C) | Add file-scope `bool iso_dep_active` (near `order` `:1870`); set at RATS→ATS (`:2234`), clear on HALT (`:2214`) and S(DESELECT). Extend the `:2493` hook: detect I-block, `off=1+CID+NAD`, strip PCB/CID/NAD+CRC, `MATCH_PREFIX` on the bare APDU; on hit re-wrap and join `build_send`. Overrides fake `90 00`. |
| **FW-7** | `armsrc/iso14443a.c` (commit D, then E) | **D:** `EmSendWtx(uint8_t wtxm)` tag-side S(WTX) sender modeled on `EmSendCmd` (the `:3787-3808` `send_wtx` is reader-side, not reusable). On L4 miss: `EmSendWtx(59)` → await reader S(WTX) echo (`(b&0xF2)==0xF2`) → `reply_ng(CMD_SIM_RELAY_EVENT,{L4,apdu})` → spin `data_available()`/`receive_ng()` for `CMD_SIM_RELAY_RESP` (GetTickCount timeout) → re-wrap + `EmSendCmd`. **E:** block-number toggle + reader→tag chaining reassembly + tag→reader FSD split. |
## pm3py steps
| ID | File | Change |
|----|------|--------|
| **PY-1** | `pm3py/core/protocol.py` | **FIX id drift:** repoint `SIM_TABLE_UPLOAD/CLEAR/UPDATE` from stale `0x0900/0901/0902``0x0950/0951/0952`. Add `SIM_RELAY_EVENT=0x0953`, `SIM_RELAY_RESP=0x0954`. Reuse `HF_MIFARE_EML_MEMSET=0x0602` for EML load. |
| **PY-2** | `pm3py/sim/table_compiler.py` | Add `RESP_FLAG_4BIT=0x04` + group constants (`GRP_SEL_PENDING=1, GRP_SECTOR0=2, GRP_SECTOR1=3, GRP_SECTOR3=4`). Add `compile_ntag_i2c(tag)`: `base=compile_14a(tag)` + **only** the SECTOR_SELECT machine (no per-page reads). Add an `enumerate_table_entries()` hook so a model can yield full `TableEntry` objects (groups/flags) alongside the legacy `enumerate_responses` 3-tuple. |
| **PY-3** | `pm3py/transponders/hf/iso14443a/nxp/ntag_i2c.py` | Add `build_eml_image()` laying out `[mfu prefix][sector0, invalid pages zeroed][sector1][sector3 regs]` at `0x400` stride, reusing `_masked_page`/`_page_readable`. Add `enumerate_table_entries()` yielding the SECTOR_SELECT machine. Keep `enumerate_responses` (sector-0) for other consumers. |
| **PY-4** | `pm3py/sim/sim_session.py` (L3) | Fix constants. In `start_14a`: if the tag is table-backed, load the EML image (`CMD_HF_MIFARE_EML_MEMSET`) then `_compile_and_upload_table` with a 14a dispatch branch (`NtagI2C→compile_ntag_i2c`, `MifareClassic→compile_mifare`, else `compile_14a`). Factor the `initial_groups`+120B chunked uploader into a shared `_upload(table, initial_groups)`. |
| **PY-5** | `pm3py/sim/sim_session.py` (L4) | Async relay path for L4-crypto tags: send `HF_ISO14443A_SIMULATE` via async `PM3Transport`, `create_task(self._relay_loop(tag))`; **do not** spawn the sync `_trace_reader` thread (single async reader owns trace + relay on one fd). Flesh out `_relay_loop`: `CMD_SIM_RELAY_EVENT``RESELECT``tag.power_on()`; `L4``out=await tag.handle_frame(RFFrame.from_bytes(apdu))`, reply `CMD_SIM_RELAY_RESP` with `resp_flags` + bare bytes. |
## New / reused command IDs & flags
- `CMD_SIM_RELAY_EVENT = 0x0953` **(new)** fw→host: normalized L4 APDU on table miss.
- `CMD_SIM_RELAY_RESP = 0x0954` **(new)** host→fw: computed L4 response.
- `CMD_SIM_TABLE_UPLOAD/CLEAR/UPDATE = 0x0950/0951/0952` **(reused;** fix `protocol.Cmd` stale `0x0900` series).
- `CMD_HF_MIFARE_EML_MEMSET = 0x0602` **(reused)** to load the multi-sector 14a EML image (native sim already reads tag memory from the EM BigBuf via `emlGet`).
- `SIM_FLAG_RESP_4BIT = 0x04` **(new)** response-flag (fw `sim_table.h` + `RESP_FLAG_4BIT` in `table_compiler.py`) — not a wire command.
- `GRP_SEL_PENDING=1 / GRP_SECTOR0=2 / GRP_SECTOR1=3 / GRP_SECTOR3=4` — compiler-side group-bit convention (max 31 groups), not firmware constants.
## Wire formats
**`sim_table_entry_t`** (120-byte PACKED, reused byte-for-byte, LE
`<32s B B 64s B B B H B B B B B I I B B B B>`): `match[32]@0`, `match_len@32`,
`match_mode@33` (0=EXACT, 1=PREFIX), `response[64]@34`, `response_len@98`,
`response_flags@99` (bit0=`APPEND_CRC`, **bit2=`RESP_4BIT`** new), `eml_action@100`,
`eml_offset(u16)@101`, `eml_len@103`, `eml_resp_insert@104`, `cmd_data_offset@105`,
`cmd_data_len@106`, `group@107`, `activate_groups(u32)@108`, `deactivate_groups(u32)@112`,
`set_auth@116`, `clear_auth@117`, `flags@118` (bit1=`CONSUME`), `_pad@119`.
`TableEntry.serialize()` already emits exactly this.
**Upload** (unchanged): `CMD_SIM_TABLE_UPLOAD` first frame = 4-byte `initial_groups` (LE)
+ N×120B entries filling `512-4` bytes; `CMD_SIM_TABLE_UPDATE` = `(512//120)*120`B chunks;
`CMD_SIM_TABLE_CLEAR` = empty.
**Relay event** (`CMD_SIM_RELAY_EVENT` payload): `byte[0]=ev_type` (0=RESELECT, 3=L3-debug,
4=L4), `byte[1]=norm_len`, `byte[2..]=normalized frame` (L4: PCB/CID/NAD+CRC stripped).
**Relay resp** (`CMD_SIM_RELAY_RESP` payload): `byte[0]=ev_type echo`, `byte[1]=resp_flags`
(bit0=append-CRC, bit1=4BIT, bit2=passive-ACK/no-tx, bit4=chaining-more), `byte[2..]=bare
response`. `tag.handle_frame` returns **bare bytes** — firmware owns PCB/CID/NAD framing
and block-number.
## NTAG I2C worked example (NT3H2211, 2k) — reading Sector 1
**Compile (`compile_ntag_i2c`):** `initial_groups` seeds `GRP_SECTOR0` active. Multi-sector
EML image loaded via `CMD_HF_MIFARE_EML_MEMSET`. ~6 table entries (far under any FDT/entry
limit):
- `E1` `[C2 FF]` EXACT → `[0A]` `RESP_4BIT`, activate `GRP_SEL_PENDING`.
- `E2` `[00 00 00 00]` EXACT, group=`GRP_SEL_PENDING`, `response_len=0`, activate
`GRP_SECTOR0`, deactivate `SECTOR1|SECTOR3|SEL_PENDING`.
- `E3` `[01 00 00 00]``GRP_SECTOR1`. `E4` `[03 00 00 00]``GRP_SECTOR3`.
- `E5` `[]` PREFIX (last) in `GRP_SEL_PENDING``[00]` 4-bit NAK, deactivate `SEL_PENDING`.
**Runtime:**
1. Native anticoll/SELECT (unchanged). `sim_active_sector()==0`.
2. Reader → `C2 FF``:2493` hit `E1``EmSend4bit(0x0A)`, `active_groups |= SEL_PENDING`.
3. Reader → `01 00 00 00` (bare sector byte) → `:2493` in `SEL_PENDING` hit `E3`
`response_len==0` ⇒ apply state (+`SECTOR1`, clear others + `SEL_PENDING`), **transmit
nothing** = the >1ms passive-ACK. `sim_active_sector()` now returns 1.
4. Reader → `FAST_READ 3A 00 0F`**native** FAST_READ fires first (correct), reads
`emlGet` at `base + 1*0x400` = sector-1 memory, returns via `EmSendCmd`. Sector-correct,
O(1), wire-speed, no table scan, no USB.
5. Back to sector 0: `C2 FF` (E1), `00 00 00 00` (E2 → `SECTOR0`, silence).
6. Invalid sector: `C2 FF` then `07 00 00 00``E5` → 4-bit NAK, `SEL_PENDING` cleared.
All frames trace via `EmLogTrace`/`sim_trace_push` automatically.
## Stateful command handling — five representations
1. **In-table group state** (no host, wire-speed): SECTOR_SELECT active sector,
SLIX2-style privacy, PWD_AUTH-gated read sets. `activate_groups`/`deactivate_groups`
masks; two-phase handshakes use a transient PENDING group so a stray frame can't
false-flip; silent success = `response_len==0`; 4-bit ACK/NAK = `RESP_4BIT`.
2. **Firmware sector projection** (the graft): `sim_active_sector()` derives the active
sector *read-only* from `active_groups` — single source of truth, native reads O(1).
3. **EML-backed mutable state** (no host): live WRITE persists into EM BigBuf; later
native reads reflect it.
4. **Native-auth state** (no host): UL-C/UL-AES/MFC via existing firmware branches with
key in EML; `auth_state` word + firmware nonce chain carry session state. First cut
fixed-nonce.
5. **Relay state** (host, L4 only): dynamic ISO-DEP crypto; Python owns per-session
nonce/IV/auth in `handle_frame`. A challenge-prefix entry may be `SIM_FLAG_CONSUME`
(one-shot) to hand the encrypted tail to the relay.
**Session reset:** on REQA/WUPA/HALT firmware resets `active_groups` + `iso_dep_active`
and (for L4 relay tags) emits `CMD_SIM_RELAY_EVENT{RESELECT}` so Python calls
`tag.power_on()`, keeping the C and Python state machines in lockstep across a reader
give-up → re-anticollision.
## Test plan
- **UNIT (pm3py):** `TableEntry.serialize()` round-trips `RESP_4BIT`/group fields at exact
byte offsets (99, 107-118), `sizeof==120`.
- **UNIT:** `compile_ntag_i2c` emits exactly the E1-E5 machine (correct match/groups/masks/
flags); assert **no** per-page sector reads.
- **UNIT:** `build_eml_image()` lays out `[prefix][sector0 zeroed-invalid][sector1]
[sector3]` at 0x400 stride with per-sector PWD/PACK masking (no secret leakage).
- **UNIT:** `protocol.Cmd` drift fixed (`SIM_TABLE_* == 0x0950/1/2`, relay ids); sim_session
constants match; FLAG_SIM_TRACE bit confirmed against firmware.
- **UNIT:** `_relay_loop` parses `CMD_SIM_RELAY_EVENT{L4}`, calls `handle_frame`, replies
with correct `resp_flags`; RESELECT triggers `power_on`.
- **FIRMWARE UNIT (host build of `sim_table.c`):** group gating + activate/deactivate
transitions E1→E3; `response_len==0` path; `RESP_4BIT` path; `sim_active_sector()` maps
bits → `{0,1,3}`.
- **FIRMWARE UNIT:** worst-case `sim_table_lookup` latency on the 48MHz ARM for the ~30-entry
table vs 86µs FDT — **measure, do not hand-wave** (must-pass gate).
- **HARDWARE L3:** PM3 Easy sims NT3H2211; reader issues `C2 FF` + `01 00 00 00` +
FAST_READ; live-trace confirms 4-bit ACK, >1ms silence, sector-1 data, invalid NAK; and
that a table hit fully suppresses the fake-`90 00` path.
- **HARDWARE L3:** verify passive-ACK phase-2 emits **nothing** (no spurious frame the
reader misreads as NAK).
- **HARDWARE L4 static:** Type-4 NDEF SELECT-App/File/ReadBinary from the table, overriding
fake `90 00`; TagInfo reads NDEF.
- **HARDWARE L4 relay:** DESFire GET_VERSION (`0xAF` chaining) and a >FSD NDEF read via WTX
relay; assert S(WTX) echo, block-number toggle, chained reassembly both directions.
- **REGRESSION:** 15693 sim still works after the `sim_table_execute` signature change; full
pytest suite green.
## Sequencing
**L3 track (ship first, independently testable):**
1. FW-1 (`sim_table` signature + `RESP_4BIT`) → FW-2 (`iso15693.c` call site, keeps 15693 green).
2. FW-3 (commit A: include, statics, buffer bump, host-poll upload).
3. FW-4 (commit B: L3 table-check + sector-offset graft).
4. PY-1 (id fix) → PY-2 (`compile_ntag_i2c`) → PY-3 (`build_eml_image` + entries) → PY-4 (`start_14a` upload + dispatch).
5. **Test L3 on hardware.** Commit + bump firmware gitlink.
**L4 track (independent, builds on the L3 host-poll hook):**
6. FW-5 (`pm3_cmd.h` relay ids).
7. FW-6 (commit C: `iso_dep_active` + static-APDU serve overriding fake `90 00`).
8. FW-7 (commit D: `EmSendWtx` + WTX relay; commit E: block-number toggle + chaining).
9. PY-5 (async `start_14a` relay path + `_relay_loop`).
10. **Test L4 on hardware** (DESFire chaining, NDEF>FSD).
## Risks & mitigations
| Risk | Mitigation |
|------|------------|
| **FDT scan blowout** — linear `sim_table_lookup` approaches 86µs on a big table. | Sector-offset graft keeps the 14a table tiny (~6-30 entries); cross-sector reads are native (O(1)). Firmware-host unit test **measures** worst-case latency vs 86µs before shipping. Contingency: first-byte bucket index in `sim_table.c` (benefits 15693 too). |
| **Dispatch order** — native READ/FAST_READ fire before `:2493` and would serve flat sector-0 data. | Resolved by design: cross-sector reads are **not** routed through the table; native branches are made sector-aware via one `emlGet` offset. Native-first is now correct. |
| **Passive-ACK phase-2** emits a spurious frame the reader reads as NAK. | `sim_table_execute` sets `*resp_len=0`; hook sets `p_response=NULL` and jumps past send; the `if(response_n>0)` build guard yields silence. Hardware test asserts >1ms silence. |
| **Command-id drift** — `protocol.py` at stale `0x0900` series mismatches framing. | PY-1 fixes it as the first Python commit; unit test asserts the values. |
| **UL-C/UL-AES miscategorized** as L4. | Routed to existing native firmware auth branches with model key via EML; documented fixed-nonce fidelity gap. Not claimed under table/relay. |
| **L4 relay claims DESFire/NDEF** but chaining/block-number don't exist in FW. | Chaining is an explicit gating commit (FW-7 E) before DESFire is claimed; buffer bumped to 256 first; v1 gated to single-frame + `0xAF`. |
| **`iso14443a.c` is a hot upstream file** — hooks churn on rebase. | Atomic single-file commits anchored to stable symbols; heavy logic in self-contained blocks; `sim_table`/`sim_crypto` fork-local; only one 15693 call site changes. |
| **CONSUME one-shots** not reset until re-UPLOAD. | Session-reset emits `RESELECT`; where a CONSUME challenge must re-arm, Python re-uploads/re-adds; documented constraint. |
## Open decisions
1. **`FLAG_SIM_TRACE` bit:** `sim_session.py` uses `0x2000`, the 14a live-trace plan doc
says `0x4000`; confirm the firmware's actual bit before wiring the async relay.
*(Note: reconciled this session — firmware F1 landed `FLAG_SIM_TRACE 0x2000`; the doc's
`0x4000` is stale. Verify against `pm3_cmd.h`.)*
2. **Multi-sector EML layout:** confirm the EM BigBuf `CARD_MEMORY` fits sector0+1+3
(~2KB + config) and that `CMD_HF_MIFARE_EML_MEMSET` writes at the offset the sector graft
expects; else add a dedicated `CMD_HF_ISO14443A_EML_SETMEM` (fallback id `0x033A`).
3. **NTAG I2C no-rollover fidelity:** native READ may roll over at emulator-memory end, not
sector end. Decide pre-zeroing vs an explicit sector-length clamp in the READ branch.
4. **NTAG21x first-READ NFC counter bump** is a side effect the native/static path loses.
Accept as documented deviation, or force that first READ through a CONSUME entry (same
for EV1 INCR_CNT edges).
5. **UL-C 3DES fixed nonce vs model random `RndB`:** acceptable for sim; decide whether
firmware 3DES-with-supplied-nonce is wanted or a downscope note suffices.
6. **L4 chaining scope for v1:** minimum viable = single-frame APDUs + DESFire `0xAF` +
tag→reader FSD split. Confirm whether reader→tag chaining is needed for any target
reader in the first cut or can be deferred.
7. **Empirical reader behaviour:** do target NTAG I2C reader stacks actually issue
SECTOR_SELECT for sector 1, or only read sector-0 NDEF? If the latter, sector-1 can be
validated later without blocking the L3 ship.
8. **Apply hooks to `SimulateIso14443aTagAID` (`:4716`)?** Deferred; `TagEx` is the primary
`FLAG_SIM_TRACE` path.
## Provenance
Produced by the `plan-14a-custom-commands` design workflow (13 agents: 6 readers → 3
designs → 3 adversarial judges → synthesis). Judge outcome: static-table-first and hybrid
"adopt-with-changes" (reuse 5/5, fdt_safety 3-4); relay-first "reject" (FDT-unsafe at L3).
The recommended architecture is static-table-first L3 grafted with a firmware sector
projection to resolve the SECTOR_SELECT/entry-explosion risk, plus a WTX relay for dynamic
L4 crypto.

View File

@@ -0,0 +1,76 @@
# Live trace for ISO14443-A sim (14a-3, extensible to 14a-4)
Add a live reader↔tag trace to the ISO14443-A card simulation, mirroring the
working ISO15693 sim trace, designed so 14a-4 (ISO-DEP / Layer 4) reuses the
same firmware mechanism. Backed by the feasibility eval (verdict:
**feasible with care** — 86µs FDT does not break it).
## Design invariants (why 86µs is not "too fast")
1. **Response is never touched** — table/precompiled, FPGA-timed via
`EmSendCmd14443aRaw` (SSP-clock alignment + FPGA delay-line). Tracing is
architecturally incapable of affecting FDT.
2. **Both trace pushes deferred to *after* transmit** → zero added work inside
the 86µs window. (A push is a ≤128B memcpy ≈ <200 cycles / <4.2µs anyway.)
3. **Flush ≤1 ring entry per idle iteration** (NOT 15693's bulk-drain) the
only USB-touching part must never overlap a reader-command edge, because
`reply_ng` and the Miller software-UART receive share the ARM thread.
4. **Ring + drop-on-full** lossy under anticollision bursts, but the sim
never stalls. RF timing is preserved; trace is merely lossy in tight gaps.
## P0 — Prerequisite (standalone bug): fix `start_14a` sim-start payload
`sim_session.py:101` builds `payload = atqa + sak + uid` then calls
`send_ng_no_response(HF_ISO14443A_SIMULATE)` with **no payload** (`:123` TODO).
The sim never starts correctly today. Fix: send the proper `HF_ISO14443A_SIMULATE`
NG struct the exact `<BH10sB20sBB16s16sB>` layout already used by the fixed
`hf.iso14a.sim()` (tagtype, flags, uid[10], exitAfter, rats…). Land first;
14a sim is unusable without it, trace or not.
## Firmware (atomic single-file commits + build)
- **F1 `include/pm3_cmd.h`:** add `CMD_HF_ISO14443A_SIM_TRACE` (default id
`0x0339`, next to the 15693 one at `0x0336`); reserve a `FLAG_SIM_TRACE` bit
in the 14a sim `flags` word (default `0x4000`, a free high bit) so the host
opts trace in/out mirrors 15693's `flags & 0x02`.
- **F2+F3 `armsrc/iso14443a.c`:** copy the `TRACE_PUSH`/`TRACE_FLUSH` ring block
from `iso15693.c:2423` but **`ENTRY_MAX` 128** (ISO-DEP I-blocks exceed 64B,
needed for 14a-4) and ring 16; `trace_enabled` from the new flag. Hook into
`SimulateIso14443aTagEx` (`:1725`): buffer received cmd after `:1851` (don't
push); after `EmSendPrecompiledCmd` (`:2554`) do `TRACE_PUSH(0,cmd)` then
`TRACE_PUSH(1,response)`; `TRACE_FLUSH_ONE()` at loop top (`:1844`); full
drain on loop exit.
- **F4:** build `fullimage PLATFORM=PM3GENERIC` must be clean.
## pm3py (reuses the generic scriptable-trace plumbing)
- **Y1 `protocol.py`:** add `Cmd.HF_ISO14443A_SIM_TRACE`.
- **Y2 `sim_session.py:284`:** generalize the trace-reader guard
(`cmd == CMD_HF_ISO15693_SIM_TRACE`) into a `{cmd: (protocol, decoder)}`
dispatch; add the 14a branch (`dir=data[0]&0x0F`, `crc_fail=data[0]&0x80`,
`protocol=1`, `decode_14443a`).
- **Y3 `start_14a`:** synchronous trace path mirroring `start_15693` (raw
pyserial + the existing `_trace_reader` thread) + `trace`/`on_frame`/`quiet`
kwargs. `on_frame`/`quiet`/`entries` + `TraceFormatter(mode='sim')` unchanged.
## I/O model decision (the one real design fork)
- **14a-3:** sync trace path (no WTX relay at Layer 3) avoids dual-reader-on-
one-fd contention.
- **14a-4 (later):** the async `_relay_loop` owns WTX; route `SIM_TRACE` through
*it* (single async reader), not a second thread. Firmware F1F3 are identical
for both layers only the pm3py consumer differs. WTX creates ms-scale idle
windows ideal for flushing.
## Tests
- **T1:** mocked-transport unit test synthetic `CMD_HF_ISO14443A_SIM_TRACE`
frames assert `TraceFormatter`/`on_frame`/`entries` (mirror
`test_trace_scripting.py`).
- **T2 (bench):** phone doing anticollision + SELECT + RATS verify (a) card
never drops, (b) frames stream, tolerating loss during anticollision.
## Risks (+ mitigations)
- Ring fill under heavy Layer-3 polling lossy by design; ring=16, accept loss
in anticollision (uninteresting traffic).
- Flush overlapping a reader edge single-entry-per-idle at loop top only;
never bulk-drain post-transmit. A missed command = reader retry, not teardown.
- `ENTRY_MAX=64` too small for ISO-DEP 128 (RAM 1KB static; check budget).
- pm3py dual reader on one fd 14a-3 sync only; 14a-4 converges to async relay.
## Sequence
`P0 → F1 → F2+F3 → F4 → Y1 → Y2 → Y3 → T1 → (T2 on bench)`

View File

@@ -0,0 +1,50 @@
# Pure-Python firmware flasher + `pm3flash`
**Status:** ✅ Landed (hardware-validated on a PM3 Easy) · **Date:** 2026-07-05
Related: [firmware-fork DT-Gitea migration](2026-07-05-firmware-fork-dt-migration.md)
## Goal
Let pm3py get the fork firmware onto a device by itself — no C `pm3-flash`/DFU/JTAG — so
`pip install pm3py` + one command flashes the build this pm3py corresponds to. Supports the
project "in the meantime", before tagged firmware releases exist on the DT Gitea.
## What landed
- **`Flasher`** (`pm3py/core/flash.py`) over the legacy 544-byte **OLD frame** (bootloader
protocol, distinct from the NG frames the rest of the client speaks; OLD-frame plumbing in
`core/transport.py`). Automatic OS→bootloader handover (`CMD_START_FLASH`, no button),
`CMD_FINISH_WRITE` block loop with per-block ACK/NACK, reset, reopen-and-verify.
- **Safety:** fullimage-only by default; the bootrom region is refused unless
`allow_bootrom=True`. A bad OS write is recoverable (proven — recovered a corrupted-OS
device); a bad bootrom write bricks to JTAG.
- **Firmware pin** (`pm3py/_firmware.py`): `FIRMWARE_PIN` = the fork build pm3py corresponds
to (currently the submodule SHA). `pm3.firmware.matches_pin` / `.expected_firmware` on
connect.
- **`pm3flash` CLI** (`pm3py/flash_cli.py`): detect → resolve/build image → flash → verify.
`--image` (prebuilt), `--build PLATFORM` (`make` from the submodule; platform explicit —
never inferred from `is_rdv4`, which reports the running firmware, not the board),
`--force`, `--allow-bootrom`.
## Lessons (found on hardware / by adversarial review)
- **Merge segments sharing a flash page.** The SAM7 erase-programs whole pages; two adjacent
PT_LOAD segments in one page must be written once with both segments' data, else 0xFF
padding clobbers real bytes (bug: the flashed OS booted but had invalid version info).
- **`--build` must flash what it built,** not gate on the pin — a dirty rebuild keeps the same
base SHA, so `matches_pin` can't distinguish it (`--build` therefore implies `--force`).
- **No CWD-relative firmware discovery** — running `make` on a stray `./firmware` is arbitrary
code execution; only an explicit arg / `$PM3PY_FIRMWARE_SRC` / the package repo root.
## Open follow-ups
- **Auto-download** of the pinned release asset (toolchain-free install) — needs a tagged
firmware release to exist; rides on the DT-Gitea migration. Drops into `resolve_image`.
- **Read-back verify** via `CMD_READ_MEM_DOWNLOAD` (v1 trusts per-block ACK/NACK, like the C
flasher).
## Provenance
Built + hardware-validated in one session; the `--build` layer was adversarially reviewed by
a multi-agent workflow (correctness / subprocess-safety / CLI-UX lenses with a verify pass),
which surfaced the pin-skip, make-missing, and CWD-Makefile issues fixed above.

View File

@@ -0,0 +1,96 @@
# Firmware fork → DT Gitea migration + clean RRG-tracking model
**Status:** Planned, not started · **Date:** 2026-07-05 · Related: [2026-07-03-firmware-upstream-rebase-plan.md](2026-07-03-firmware-upstream-rebase-plan.md)
## Goal
Host pm3py and its Proxmark3 firmware fork on the DT Gitea, customize the firmware,
and pull RRG (`RfidResearchGroup/proxmark3`) updates **without ever touching the RRG
version** — via the standard **rebase-fork** model.
## The model
One firmware repo, `<dt-org>/proxmark3`, with a strict branch split:
| Branch | Role |
|--------|------|
| `master` | **pristine RRG mirror** — only ever fast-forwarded from `upstream`, never our commits |
| `pm3py` | our customizations (atomic single-file commits) on top of `master`; the submodule pins this |
Remotes: `origin` = `<dt-org>/proxmark3` (SSH), `upstream` = RRG (already configured).
pm3py itself moves to `<dt-org>/pm3py`; its `.gitmodules` points at the `pm3py` branch.
Why rebase-fork (not merge / subtree / quilt): our ~33 patches already *are* a hand-managed
atomic-commit patch queue; rebase is the native git form. It keeps `git diff master..pm3py`
a clean picture of exactly our changes, and resolves the recurring `iso15693.c` conflict
(24/33 commits touch it) **once** per update instead of on every merge.
## The three real gotchas (all handled)
1. **Gitlink must be published or fresh clones dangle.** The firmware `pm3py` tip must be
pushed *before* pm3py's gitlink references it (Phase 6 before 7).
2. **The uncommitted EML host-poll change is in no ref** — it would be silently lost on any
checkout/reclone. Backed up (`scratchpad/eml-hostpoll.patch`) and committed on `pm3py`
in Phase 5.
3. **Our history is intermixed with upstream merges**`merge-base(main, upstream/master)`
is only 11 commits below HEAD, so 48 of our 59 commits sit *below* it in the upstream DAG.
A naive `rebase --onto master $MB pm3py` would carry only the top 11 and **drop 48**. So
the migration does **not** rebase — it mirrors everything verbatim and defers the clean
split to the first upstream pull (see Ongoing).
## Migration runbook (no rewrite, no data loss)
Split by **who**: `[A]` = I do it locally (offline); `[U]` = you run it (Gitea web / pushes,
since my sandbox can't reach `:20022`).
- **Phase 0 [A] — safety net:** back up the EML diff (done); tag every must-preserve head
`bak/<name>`; `git bundle create --all` for both repos.
- **Phase 1 [U] — create EMPTY DT repos** `<dt-org>/proxmark3` and `<dt-org>/pm3py` (no
README/license — a first commit blocks a mirror push).
- **Phase 2 [A] — materialize local heads:** `git branch pm3py main` (verbatim), and local
branches for the 6 feature branches. No rebase.
- **Phase 3 [U] — mirror-push firmware:** `git fetch upstream master` then
`git push --mirror ssh://git@git.dngr.us:20022/<dt-org>/proxmark3.git` (safe: target empty).
- **Phase 4 [U] — seed `master`:** `git push <dt> refs/remotes/upstream/master:refs/heads/master`.
- **Phase 5 [A] — commit EML on `pm3py`, repoint config, bump gitlink:** commit the EML
host-poll; rename firmware `origin``mikefaith-backup`, add DT `origin`; set `.gitmodules`
`url`=DT SSH + `branch=pm3py`, `submodule sync`; `git add .gitmodules firmware` + commit.
- **Phase 6 [U] — publish the firmware tip (CRITICAL):** `git push origin pm3py`.
- **Phase 7 [U] — mirror-push pm3py + repoint origin** to `<dt-org>/pm3py`.
- **Phase 8 [U] — VERIFY** a fresh `git clone --recurse-submodules` of `<dt-org>/pm3py`:
submodule resolves (no "reference is not a tree"), `make PLATFORM=PM3GENERIC` + `pytest`
pass, then a hardware smoke test.
- **Phase 9 [U] — retire old repos** (ARCHIVE read-only, never delete) only after Phase 8.
Rollback at any point: nothing is rewritten/deleted, so `git remote rename mikefaith-backup
origin` restores the pre-migration state; the old repos and the Phase-0 bundles are the
backstop until Phase 8 passes.
## Ongoing: pull RRG updates without impacting RRG
```
git -C firmware fetch upstream master
git -C firmware push origin refs/remotes/upstream/master:refs/heads/master # ff-only, pristine
# rebase our patches onto the new base in an isolated worktree, build+test, then:
git -C firmware push origin rebase-wip:pm3py --force-with-lease # only our branch, leased
cd <pm3py> && git add firmware && git commit -m "chore(firmware): rebase onto RRG <tip>, bump gitlink"
```
`master` is append-only (never rewritten); only `pm3py` is force-pushed, always with a lease.
The **first** pull also does the deferred clean split: enumerate our commits
(`git log --author=michael --author=dangerous-tac0s upstream/master..pm3py`), skip
already-upstreamed ones (hf-mfu-esetblk PR #3404), and rebuild `pm3py` as an explicit
patch series onto fresh `master` — validate `git diff <rebuilt> pm3py` shows only deltas
upstream lacks. Later pulls are the simple linear `rebase --onto` case. See the 2026-07-03
plan for the per-file conflict detail (`iso15693.c` hot spot).
## Inputs needed from you
- The **DT org name** on git.dngr.us (substitutes `<dt-org>`).
- Fork default branch: recommend `master` (matches RRG).
- Confirm your SSH key has write in the `<dt-org>` namespace.
## Provenance
Multi-agent workflow (strategy eval + state audit + topology design → synthesized runbook).
The adversarial data-loss verify pass was cut short by a rate limit; the mirror-to-empty-repo
safety, publish-before-reference ordering, and EML backup were re-checked by hand.

View File

@@ -0,0 +1,158 @@
# pm3py ↔ pyws plugin — implementation plan (2026-07-05)
A pyws plugin, living in **this** repo, that turns pm3py into a first-class pyws workspace:
open `pyws pm3` and you land in a shell with the reader connected (or the sim ready), the
transponder classes in scope, your tags reconstructed from history, and replay/save that
understand which pm3py operations touch hardware.
pyws itself stays domain-agnostic; all pm3py knowledge lives here and is discovered through the
`pyws.plugins` entry point.
## Decisions (locked with the user)
- **Sim arming is explicit (option b).** On load the connection opens and the tag model is
reconstructed, but the sim goes live only when *you* call `sim.start(tag)`. No auto-arm.
- **One connection, one mode per workspace** — sim-mode (`SimSession` owns the serial port) or
reader-mode (`Proxmark3` owns it), never both live. They can't share `/dev/ttyACM*`.
- **`sim` is a lifecycle wrapper around `SimSession`** (which already runs its own trace thread) —
response-table sims only for now, since upstream's WTX relay (`sim_session.py:_relay_loop`) is a
stub for future 14a Layer-4.
- **14a / NTAG first**, ISO-15693 a fast-follow.
- `SimSession.open()``connect` effect (replayable); it raises loudly if no PM3 is attached — the
resource surfaces that as a *disconnected* status on load (non-fatal) and as a clear error on an
explicit call.
## Wiring
- Module: `pm3py/pyws_plugin.py`, class `Pm3pyPlugin(WorkspacePlugin)`.
- Entry point in pm3py's `pyproject.toml`:
```toml
[project.entry-points."pyws.plugins"]
pm3py = "pm3py.pyws_plugin:Pm3pyPlugin"
[project.optional-dependencies]
pyws = ["pyws"] # installs the engine; dependency points pm3py -> pyws, never the reverse
```
- Install for use: `pip install -e ".[pyws]"` into the pm3py venv (which already has pm3py + deps).
## What the plugin provides
### Namespace (`Pm3pyPlugin.namespace(session)`)
Inject what a pm3py workspace user reaches for, so `startup.py` needs no `from pm3py… import …`:
- `SimSession` and the common tag classes (`NTAG213/215/216`, `MifareUltralight*`,
`MifareClassicTag`, `NfcType2Tag`, `Tag15693`/`NfcType5Tag`, implant factories `xNT/xM1/NExT/…`).
These are re-exported from `pm3py.sim` (`pm3py/sim/__init__.py:71`).
- `reader` and/or `sim` (the resource objects, see below), bound by the resource layer.
- NDEF helpers (`ndef_uri`, `ndef_text`, `ndef_mime` from `pm3py.sim.type5`).
Keep it a curated set, not a wildcard dump — anything else is a normal `from pm3py.sim import X`.
### `sim` resource (sim-mode) — `create_resource("sim", cfg, session)`
A pyws `Resource` wrapping a `SimSession`.
- `start()` (resource lifecycle, at session start): `SimSession.open(port, baudrate)`
(`sim_session.py:73`). Port from cfg (`sim: {port: /dev/ttyACM0}`), else pm3py auto-detect.
On failure (no device) → catch, `status = disconnected`, log a notice; **do not crash the
session** (you can still edit tags offline).
- Bound object `sim` exposes a thin facade over the session:
- `sim.start(tag, *, tagtype=…, trace=False)` → dispatch to `sess.start_14a(tag, …)`
(`sim_session.py:116`) or `sess.start_15693(tag, …)` (`:226`) by tag base class. **This is the
explicit arm.** Effect: `sim`.
- `sim.stop()` → `sess.stop()` (`:515`). Effect: `sim`.
- `sim.push(tag=None)` → `tag.sync()` (or `sess.sync()`), the "push my edits to the running sim"
step. Effect: `write`.
- `sim.frames` → `sess.entries` (decoded reader↔tag trace).
- `stop()` (session exit): `sess.stop(); sess.close()` (`:531`) — port released, tag unbound.
### `reader` resource (reader-mode) — `create_resource("reader", cfg, session)`
A pyws `Resource` wrapping `Proxmark3` (`core/client.py:50`).
- `start()`: build `Proxmark3(port, baudrate)` and connect. Since pyws resources start on the
ResourceManager's async loop, `await pm3.connect()` fits naturally; the bound `reader` object is
the connected client (`.hw/.hf/.lf`, `pm3.hf.iso14a.scan()`, `pm3.hf.mfu.rdbl()`, …). No-device →
disconnected status, non-fatal.
- `stop()`: `await pm3.disconnect()`.
### One mode per workspace
A workspace declares exactly one of `reader:` / `sim:` under `resources:`. If both are declared,
the plugin refuses the second with a clear message (they contend on the port). In-session switching
(`sim.stop()` then connect a reader) is possible but not the default path.
### `tag` — user-created, **not** a resource
The tag model is created by you (`authorized_tag = NTAG213(uid=…, password=…, pack=…)`) in
`startup.py` or interactively, and reconstructed on load by autoreplay. It is *not* a plugin
resource. (Optional future convenience: a `tag:` resource that builds a default tag from yaml
config — deferred; yaml is a poor place for uid/keys/memory.)
## Effect declarations (replay safety)
`Pm3pyPlugin.effects` — pattern policy consumed by pyws's classifier. Safe-by-default deny-list:
anything classified `write/field/sim/destructive` is withheld on autoreplay; everything else
(incl. `connect`, `read`, pure model edits) replays.
```python
effects = {
"connect": ["sim.open", "sim.close", "reader.connect", "reader.disconnect"],
"read": ["reader.hf.*.scan", "reader.hf.*.rdbl", "reader.hf.*.rdsc",
"reader.hf.mfu.rdbl", "reader.hf.*.inventory", "*.read_block*"],
"field": ["reader.hf.tune", "reader.hf.dropfield", "reader.lf.tune"],
"write": ["reader.hf.*.wrbl", "reader.hf.*.writebl", "reader.lf.*.writebl",
"*.sync", "*.write_block*"],
"sim": ["sim.start", "sim.stop", "sim.start_14a", "sim.start_15693",
"reader.hf.*.sim"],
"destructive": ["*.format*", "*.lock*"],
}
```
Consequences (the important ones):
- `authorized_tag = NTAG213(…)` and `tag.set_page(4, …)` / `tag.set_ndef(…)` → no match → `unknown`
→ **replay** (pure-Python model edits, no device). So the tag rebuilds on load.
- `tag.sync()` → `write` → **withheld** on autoreplay. Reconstruction rebuilds the *model*, never
re-pushes to firmware.
- `sim.start(tag)` → `sim` → **withheld** → the sim is armed explicitly (decision b).
- `reader.hf.iso14a.scan()` → `read` → replays; `reader.hf.mfu.wrbl(…)` → `write` → withheld.
Patterns are refined against real method names during P3; treat the above as the shape.
## How it rides pyws's features
- **Autoreplay + `tag.sync()` ordering** — the model rebuilds from history (`set_page` etc. replay),
but `sync()` is withheld, so after load the *model* is current while firmware is not. Since arming
is explicit, your `sim.start(tag)` (or `sim.push()`) is what pushes the final model — no stale-sync
hazard, and no arming before the tag exists.
- **save() / state.py** — the tag is a live object → not written to `state.py` (only round-trippable
data is). It comes back via history replay of its construction + edits. Keys/uids that you typed as
literals persist fine; the object is rebuilt, not serialized.
- **Events** — the plugin may subscribe to `WorkspaceReloaded` later for niceties (e.g. warn if the
live sim is now serving a tag you've edited but not re-pushed). Not required for v1.
- **Serial arbitration** — enforced by one-mode-per-workspace; the resource owns the single port.
## Phases
- **P1 — reader + namespace.** `Pm3pyPlugin` + entry point + `[pyws]` extra; `reader` resource
(connect/disconnect) + namespace injection. `pyws` in a reader-mode workspace → connected `reader`.
Tested against pm3py's existing `AsyncMock` transport (no hardware).
- **P2 — sim resource.** `sim` resource wrapping `SimSession` (open on start, explicit
`sim.start(tag)` / `sim.stop`, `sim.push`, `sim.frames`, clean teardown). Mock-tested where the
serial layer allows; otherwise a thin fake `SimSession`.
- **P3 — effects + docs.** Effect patterns above, verified against real method names; add the pyws
integration section to pm3py's `CLAUDE.md`; a documented (non-scaffolded) `pm3` workspace example.
- **P4 — hardware acceptance.** `pyws pm3` in sim-mode → create/reconstruct a tag → `sim.start(tag)`
→ read it back with the ACR1552U. The real end-to-end.
## Testing (hardware-free)
- Reuse pm3py's `AsyncMock` transport pattern for the `reader` resource.
- For `sim`, inject a fake `SimSession` (or patch `SimSession.open`) so the resource lifecycle,
arming, effect classification, and teardown are all testable without a PM3.
- pyws-side already covers resource lifecycle, effect gating, autoreplay — the plugin tests only its
own wiring.
## Open / future
- ISO-15693 sim path (`start_15693`, response-table compile/upload).
- Auto-arm option (opt-in `sim.live: true`) if the explicit model gets tedious — the `WorkspaceLoaded`
hook is already there.
- Dual-interface (NTAG5, PM3 RF + MCU I2C via `DualInterfaceSession`) as a distinct resource.
- WTX relay for 14a Layer-4 once upstream `_relay_loop` is real.
## pm3py docs to update (P3)
- `CLAUDE.md`: a short "pyws integration" section — entry point, `[pyws]` extra, the reader/sim
resources, and the effect/replay semantics.

View File

@@ -0,0 +1,93 @@
# C client oracle + LF demod parity — future-work notes
*2026-07-16. Written while finishing the rawcli T5577 catalog and validating on real
hardware (a T5577 emulating **EM4100 `00FFFFFFFF`**, config word **`00148040`**).*
I wanted the stock proxmark3 C client as a ground-truth **oracle** for LF demod — to
cross-check pm3py's decode of the live tag. It wouldn't run in this environment, and digging
into why surfaced the notes below. **Net: there is no C-client _code_ bug to PR from what we
found** — the two real items are (1) an environment blocker and (2) a pm3py-side demod-parity
gap. Captured here so a future effort doesn't re-derive it.
## 1. The C client won't run inside the VS Code snap sandbox (oracle blocker)
- **Symptom:** `./pm3 -p /dev/ttyACM0 -c 'lf search'`
`symbol lookup error: /snap/core20/current/lib/x86_64-linux-gnu/libpthread.so.0:
undefined symbol: __libc_pthread_init, version GLIBC_PRIVATE`
- **Root cause:** this shell runs *inside* the VS Code snap (`SNAP_REVISION`, `SNAP_REAL_HOME`,
`/snap/code/250/...`). The snap runtime injects core20's older glibc / `libpthread.so.0`
ahead of the system libs the client was built against, so a `GLIBC_PRIVATE` symbol the
system libc no longer exports gets looked up in the old snap libpthread and fails.
- **Confirmed it is NOT a client defect:**
- The binary has **no RPATH/RUNPATH**; its `NEEDED` libs (`libpython3.12`, `Qt5*`,
`readline`, …) all resolve to system `/lib/x86_64-linux-gnu` under `ldd`.
- `LD_LIBRARY_PATH` is **unset**; clearing `LD_LIBRARY_PATH`/`GTK_PATH` did not help — the
injection is at the snap-runtime level, not via a variable we can scrub.
- **To use the client as an oracle:** run it from a **non-snap login shell / real terminal**
(outside VS Code's snap), or build+run it inside a matched (non-snap) environment. No
proxmark3 change fixes a sandbox library injection.
- **Marginal upstream idea (only if it ever matters):** the `pm3` launcher could sanitize
obviously-contaminating library paths before `exec`, but that is a weak, environment-specific
band-aid — not worth a PR on its own.
- Client checkout here: `dangerous-tac0s/proxmark3` `v4.20728-1264-g273777b21`.
## 2. LF clock detection — pm3py's port was weaker than the C client (pm3py-side parity)
We found and fixed a real bug in pm3py `pm3py/lf/dsp.py::detect_clock`: it estimated the bit
clock from the **minimum** edge interval, so a handful of stray sub-bit edges on a
strongly-coupled, rail-clipped tag collapsed the estimate (true half-bit 32 → ~3), snapping the
data clock to 8 and producing an all-phase-error ASK demod (nothing decoded). Fixed to key off
the **shortest _well-supported_ interval** (≥20% of the modal count) instead of the raw minimum.
The C client's `DetectASKClock` (`common/lfdemod.c`) is already robust to this — worth porting
its approach for full parity:
- It first runs `DetectCleanAskWave` and, for clean/strong/**clipped** peaks, routes to a
dedicated `DetectStrongAskClock` — exactly the case that broke our port.
- Otherwise it **error-scores every candidate clock** `{8,16,32,40,50,64,100,128,272}` against
the wave and picks the best fit — not a single-interval heuristic.
**Action (pm3py, not a C-client PR):** port the candidate-clock error-scoring + a
strong/clean-ASK fast path into `pm3py/lf`. Our support-based `detect_clock` fix is a partial,
targeted step; the C client's scoring is the fuller solution.
## 3. Data-block reads — investigated to ground truth (no bug), one real improvement
Post-fix, pm3py decodes the live tag as:
- Emitted credential: **EM4100 `00FFFFFFFF`**
- T5577 config block 0: **`00148040`** (Manchester, RF/64, MAXBLK 2, `ST=0`, non-inverted →
EM4100/EM4102) — decoded rotation-resolved against presets (reliable).
- Data blocks: `READ(1)=A108421F`, `READ(2)=007FE108`, which at first looked *wrong* (not a
rotation of the encoder's `FF801EF7`/`BDEF7BC0`).
Chased to ground truth against the **ATA5577C datasheet + proxmark3 C source + an on-hardware
write-read-back** — and there is **no read/addressing bug**:
- Datasheet §5.9: the direct-access command (`opcode 10` + `0` + 3-bit addr — exactly what the
firmware sends) reads *only the addressed block*, repetitively. A clean 32-bit repeat in the
capture proves block-read mode engaged (the 64-bit emulation stream can't produce one).
- `T55xx_SetBits` clocks the address MSB-first; pm3py's `readbl` payload matches the firmware
struct byte-for-byte. Addressing is stock-proxmark, verified end-to-end.
- A block-3 write-read-back was **not** decisive on its own: `writebl` and `readbl` share the
same address path, so they round-trip regardless of whether addressing is correct (it only
proved the demod inverts). Proxmark, Flipper, and pm3py all write the header into block 1, so
the alternative "pm3py reads 1↔2 swapped" had to be excluded with an **addressing-free** read.
- **Decisive test (MAXBLK=1):** temporarily set the config MAXBLK field to 1 (`00148040 →
00148020`) so regular-read streams *only physical block 1* (datasheet §5.11.1), read it off
the air (no read-command addressing), then restored `00148040`. Physical block 1 came back as
`~BDEF7BC0` — the **non-header** half — matching `READ(1)`. So `READ(1)` == physical block 1.
Conclusion: this tag was simply **cloned with the two EM4100 halves in reverse order**
(block 1 = `BDEF7BC0`, block 2 = `FF801EF7`) by a non-proxmark/Flipper/pm3py tool; both orders
stream the same cyclic waveform and read as `00FFFFFFFF`. pm3py reads them correctly. The "wrong"
appearance was polarity (inversion) + rotation, i.e. the documented "best-effort — bit alignment
not verified".
**Real improvement (open):** `read_t55xx_block` should *resolve* data-block polarity/rotation
instead of leaving it best-effort. Polarity is determinable — read block 0 (config), resolve its
polarity against the known preset, and carry that polarity to the data blocks (they share the
tag's modulation). Rotation can be anchored off the block-read response start. The C client's
`cmdlft55xx` demod already does this alignment — worth porting alongside §2. Still not
cross-checked against `lf t55 dump` (client blocked by §1), but the write-read-back makes that
non-blocking.

68
docs/plans/README.md Normal file
View File

@@ -0,0 +1,68 @@
# pm3py roadmap — design & implementation plans
This directory is the project roadmap: dated design docs (`*-design.md`) and
implementation plans (`*-plan.md`). Each file is a self-contained spec — problem,
approach, concrete steps, tests, risks.
> **Status column is best-effort**, inferred from the codebase and memory at the time of
> writing (2026-07-05). Correct it if a plan's real state differs. Legend:
> ✅ landed · 🔄 in progress · 📋 planned · 📎 reference/ongoing.
## ISO 14443-A sim expansion
| Plan | Status | Summary |
|------|--------|---------|
| [2026-07-05-14a-live-trace-plan](2026-07-05-14a-live-trace-plan.md) | 🔄 | Live reader↔tag trace for the 14a sim (firmware ring + `EmLogTrace` capture + pm3py consumer). Code complete; flush + direct-send-capture fixes awaiting hardware verify. |
| [2026-07-05-14a-custom-commands-design](2026-07-05-14a-custom-commands-design.md) | 📋 | **Design:** how to serve custom 14a-3 (NTAG I2C SECTOR_SELECT + cross-sector reads) and 14a-4 (vendor ISO-DEP APDUs) commands — table-first L3 + WTX-L4 hybrid layered on the native-EML sim. |
| [2026-07-05-14a-custom-commands-plan](2026-07-05-14a-custom-commands-plan.md) | 📋 | **Plan:** sequenced L3 + L4 tracks — `sim_table` hook, sector-offset graft, WTX relay, `TableCompiler` extension, tests. |
## Sim framework & response table
| Plan | Status | Summary |
|------|--------|---------|
| [2026-03-17-stateful-table-firmware-design](2026-03-17-stateful-table-firmware-design.md) | ✅ | Response-table sim in firmware BigBuf: stateful `sim_table` + `sim_crypto`, group/auth state. |
| [2026-03-17-stateful-table-firmware-plan](2026-03-17-stateful-table-firmware-plan.md) | ✅ | Implementation of the above (15693 sim fully working: inventory, NDEF, NXP custom commands). |
| [2026-03-17-table-compiler-update-plan](2026-03-17-table-compiler-update-plan.md) | ✅ | `TableCompiler` / `ResponseTable` — compile per-IC responses into the 120-byte entry format. |
## Trace & sniff
| Plan | Status | Summary |
|------|--------|---------|
| [2026-03-17-trace-formatter-design](2026-03-17-trace-formatter-design.md) | ✅ | `TraceFormatter` — colored, decoded, column-wrapped trace output (sim/reader/sniff modes). |
| [2026-03-17-trace-formatter-plan](2026-03-17-trace-formatter-plan.md) | ✅ | Implementation of the trace formatter. |
| [2026-03-18-ndef-trace-decode](2026-03-18-ndef-trace-decode.md) | ✅ | NDEF TLV/record decode for trace annotation. |
| [2026-03-19-live-sniff-design](2026-03-19-live-sniff-design.md) | ✅ | Live streaming sniff (firmware `CMD_HF_SNIFF_STREAM` + `SniffSession`). |
| [2026-03-19-live-sniff-plan](2026-03-19-live-sniff-plan.md) | ✅ | Implementation of live sniff. |
## Package refactor & migration
| Plan | Status | Summary |
|------|--------|---------|
| [2026-03-18-refactor-design](2026-03-18-refactor-design.md) | ✅ | Split the monolith into `core/`, `trace/`, `sniff/`, `sim/`, `transponders/`. |
| [2026-03-18-refactor-progress](2026-03-18-refactor-progress.md) | ✅ | Progress log for the refactor (complete, all on master). |
| [2026-03-18-sim-migration](2026-03-18-sim-migration.md) | ✅ | Migrate the sim framework into the new package layout. |
## Firmware maintenance
| Plan | Status | Summary |
|------|--------|---------|
| [2026-07-03-firmware-upstream-rebase-plan](2026-07-03-firmware-upstream-rebase-plan.md) | 📎 | Rebase the `proxmark3-pm3py` fork against upstream PM3 (atomic single-file-commit workflow). |
| [2026-07-05-firmware-fork-dt-migration](2026-07-05-firmware-fork-dt-migration.md) | 📋 | Migrate the firmware fork + pm3py to the DT Gitea; clean `master`(RRG-mirror)/`pm3py`(patches) rebase-fork model. |
## Firmware flashing
| Plan | Status | Summary |
|------|--------|---------|
| [2026-07-05-firmware-flasher](2026-07-05-firmware-flasher.md) | ✅ | Pure-Python flasher (OLD-frame bootloader protocol) + `pm3flash` CLI + firmware pin; hardware-validated on a PM3 Easy. Auto-download of the pinned release is the open follow-up (rides on the DT-Gitea migration). |
---
### Conventions
- **Naming:** `YYYY-MM-DD-<topic>-{design,plan}.md`. A design doc explains *why* and *what*;
a plan doc gives sequenced, implementation-grade steps. Small features may have only a
`-plan.md`.
- **Firmware changes** land as atomic single-file commits anchored to stable symbols (not
line numbers) for easy rebase against upstream — see the firmware-upstream-rebase plan.
- **Provenance:** plans produced with multi-agent design workflows note it in a Provenance
section.

100
docs/rawcli-todo.md Normal file
View File

@@ -0,0 +1,100 @@
# rawcli — catalog & memory-hinting roadmap
The MVP is a list of **named transponders**, each surfaced through the **same UX**: a command
catalog + a datasheet memory map, both offered as **autocomplete suggestions in the completion
dropdown** — never on the bottom bar, never in command output.
## The UX contract (applies to every transponder)
- Completion dropdown offers: command names (by letter → `READ(`), opcodes (by hex/binary value →
raw byte inserted, command as the label), and page/block landmarks with datasheet roles.
- **Must work in both hex AND binary entry modes**, rendering in the active base.
- Function-call args are base-10 (`READ(4)`); raw bytes are hex, `0b` to intermix binary.
## FIXED — raw byte-line autocomplete now fires in hex AND binary
Root cause: `entry._digit` applied each keystroke as `buf.document = Document(...)`, which resets
prompt_toolkit's `complete_state` and never re-fires completion — so the live menu never appeared
during raw byte entry. This hit **both** hex and binary (the earlier "hex works, binary doesn't"
was inaccurate; only command-name completion worked, because that path already used `insert_text`).
Fixed by appending the per-keystroke delta via `buf.insert_text` instead — `type_digit` only ever
appends (optionally after an auto-space) or drops, so the change is always a pure suffix, and
`insert_text` triggers completion exactly like an ordinary keystroke. Verified by driving the real
key handler: the menu now populates on every digit in both modes. Regression:
`TestKeyBindings::test_digit_binding_fires_completion_trigger_for_raw_bytes`.
The Ctrl-t / Ctrl-_ toggle correctly flips the breadcrumb `0x` <-> `0b` and requests a redraw
(verified, guarded by `test_toggle_updates_breadcrumb_prefix`). An end-to-end live-session test
(`TestRawcliLiveSession`) drives the real PromptSession via a pipe input. A human eyeball of the
rendered dropdown on a real terminal is the only thing left unautomated.
## Priority 1 — MIFARE Classic (do this first)
- **Identify**: distinguish 1K (SAK 0x08) / 4K (0x18) / Mini (0x09); today only named, no map.
- **Memory map** (block → role), mirroring the NTAG page map:
- Block 0 = manufacturer block: UID + BCC + SAK + ATQA + manufacturer data.
- Sector trailer (block 3,7,…,63 on 1K; last block of each sector on 4K): **Key A (0-5),
access bits (6-8) + GPB (9), Key B (10-15)** — the access bits are the bit-level detail.
- Other blocks = data block (with sector number).
- 4K: sectors 0-31 are 4 blocks; sectors 32-39 are 16 blocks each.
- **Catalog**: READ/WRITE/HALT exist; note that reads/writes need prior AUTH (Key A/B) — the
authenticate step is the reader's job before these.
- Autocomplete must work in hex + binary (see the bug above).
## After MFC — 15693 vicinity chips: ICODE SLIX ✅, SLIX2 ✅, NTAG5 ✅, ICODE DNA (next)
1. **15693 header builder** ✅ — `iso15_frame()` (flags + command + NXP mfg 0x04), shared by all
15693 catalogs. The **full request-flags byte** is settable: sub-carrier (0x01), data-rate,
inventory, protocol-ext (0x08), and the mode-dependent bits 5-6 (Select/Address vs AFI byte +
Nb_slots 1/16), plus option. `iso15_flags_decode` decodes every bit for the byte-0 hint.
2. **Per-chip identify** ✅ — `identify.name_iso15` now splits the whole E0 04 family with a
READ_CONFIG (0xC0) probe (addressed + retried), **hardware-verified against a real NTAG5 and a
real DNA**: session register 0xA0 succeeds only on NTAG5 (`00 01400000`); DNA errors on 0xA0
(`01 0F`) but its config block 0x00 succeeds; SLIX2 has no READ_CONFIG (falls to the 0xBD
signature step); plain SLIX answers neither. So NTAG5 → "NTAG 5", DNA → "ICODE DNA" (was
mislabeled SLIX2), SLIX2/SLIX as before. Real UIDs: NTAG5 `E0 04 01 58`, DNA `E0 04 01 18`
(the model's NTAG5 prefix `…18` was copied from DNA; real NTAG5 is `…58`).
3. **Catalogs**`_SLIX`, `_SLIX2`, and **`_NTAG5`** ✅ all done, dispatched by `catalog_for`,
verified live. `_NTAG5` = SLIX2 minus STAY_QUIET_PERSISTENT (NTAG5 rejects it) + READ_CONFIG
(0xC0) / WRITE_CONFIG (0xC1) / PICK_RANDOM_UID (0xC2) / READ_SRAM (0xD2) / WRITE_SRAM (0xD3).
**Follow-up:** the AES / ISO 29167-10 commands AUTHENTICATE (0x35) / CHALLENGE (0x39) / READBUFFER
(0x3A) for Link/Boost (AES-capable) variants — a crypto sub-protocol with its own param format.
4. Autocomplete UX ✅ (hex + binary, command names + opcode-by-value; the byte-line completion bug
is fixed).
`INVENTORY_READ` (0xA0) / `FAST_INVENTORY_READ` (0xA1) ✅ — in `_SLIX` (inherited by `_SLIX2`),
**full request body** via `_inv_read_frame`: `[AFI] | mask_len(bits) | mask value | first_block |
count-1`, with the inventory flags set by `iso15_frame(inventory=…, afi=…)`. Mask-less (default),
mask-based selective anticollision, and AFI filter all supported. **15693 is now datasheet-complete
end to end** (flags byte + both SLIX/SLIX2 command sets + inventory-read body).
### NEXT: ICODE DNA catalog (`_DNA`)
DNA is now identified ("ICODE DNA") but borrows `_SLIX2`. Build a dedicated `_DNA` catalog. Sourcing
(datasheets are local in the repo root — see [[reference_datasheets]] in memory):
- **Command set + semantics + memory map:** `SL2S6002_SDS.pdf` (the DNA short data sheet). It names
the commands but — being the *short* sheet — omits exact opcodes (defers to the NDA full sheet).
- **Opcodes for the shared commands:** `SL2S2602.pdf` (SLIX2) — DNA extends SLIX2, so EAS/AFI,
password (0xB2-B5), READ_SIGNATURE (0xBD), persistent-quiet, etc. carry over. Start from
`_SLIX2.commands` like `_NTAG5` did.
- **DNA-specific:** READ_CONFIG (0xC0) / WRITE_CONFIG (0xC1) — hardware-confirmed on the real DNA;
CID (default 0xC000); AES CHALLENGE (0x39) / AUTHENTICATE (0x35) / READBUFFER (0x3A) from
`docs/NTAG5_SECURITY.md` (verified protocol) — same crypto sub-protocol as the NTAG5 AES follow-up.
- **Memory map** (for `memory.py`): user = **63 blocks (0-62)**, **block 63 = 16-bit counter**;
**config memory = 48 blocks** via READ_CONFIG (holds keys, CID, originality signature, privileges).
- Route `"DNA"``_DNA` in `catalog_for` (currently → `_SLIX2`). Hardware-verify against the real
DNA (finicky: 16-slot inventory + addressed + retries, per [[project_ntag5_dna_id]]).
The NTAG5 AES commands (0x35/0x39/0x3A) follow-up shares this crypto work — source from `NTA5332.pdf`
(the real NTAG5 Link/Boost datasheet, also local) + `docs/NTAG5_SECURITY.md`.
**Hardware verify pending:** SLIX2 catalog was verified only against unit tests + a live-session
drive (no SLIX2 tag on the reader). Confirm on a real SLIX2 when one is available.
## Also queued
- **T5577 config block (block 0)** bitfield decode (modulation, bit-rate, block count, PWD/AOR) —
currently just "config block".
- NTAG I2C plus (model exists, identify names it) — catalog + map.
See [[project_rawcli_mvp]] in memory for the named-transponder scope.

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="3b6201d97")
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

5
pm3py/cli/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""pm3py command-line tools. The ``pm3py`` console-script dispatches to subcommands (rawcli,
and future flash/client). See :mod:`pm3py.cli.main`."""
from .main import main
__all__ = ["main"]

View File

@@ -0,0 +1,8 @@
"""``pm3py client`` — an interactive prompt_toolkit REPL that wraps the stock Proxmark3 C client.
Unlike :mod:`pm3py.cli.rawcli` (which speaks pm3py's pure-Python protocol stack), this drives the
real ``proxmark3`` binary over pexpect, so it works with any Proxmark3 / firmware. It offers
autocompletion and help hints for the full pm3 command tree — sourced from the client's
``commands.json`` help dump — plus a right-arrow help toggle: highlight a completion, press RIGHT to
expand its full help, LEFT / ESCAPE to back out.
"""

101
pm3py/cli/clientcli/app.py Normal file
View File

@@ -0,0 +1,101 @@
"""clientcli interactive loop — a prompt_toolkit REPL that drives the stock ``proxmark3`` client
with command-tree autocompletion, live hints, and right-arrow help shown in the completion
dropdown. Mirrors the :mod:`pm3py.cli.rawcli.app` skeleton; the testable logic lives in the
sibling modules."""
from __future__ import annotations
import sys
from .tree import load_command_tree
from .driver import make_driver, DriverError
from .session import ClientSession
from .completer import ClientCompleter
from .keys import install_key_bindings
from .hints import one_line_hint
_DEFAULT_HINT = "type a pm3 command · Tab to complete · → help on a selection · 'quit'"
def dispatch(state, line, out=print) -> bool:
"""Handle one input line. Returns False to exit. ``quit``/``exit``/``q`` leave the wrapper;
everything else is sent to the client and its output printed. A driver failure ends the loop."""
cmd = line.strip()
if cmd.lower() in ("quit", "exit", "q"):
return False
try:
output = state.driver.execute(cmd)
except DriverError as exc:
out(f"client error: {exc}")
return False
if output:
out(output)
return True
def run(port=None, commands=None, client="proxmark3", driver="auto") -> int:
from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.application import get_app
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.shortcuts import CompleteStyle
def out(text):
# render the ANSI our client/formatters emit (colour on a TTY, stripped when piped)
print_formatted_text(ANSI(str(text)))
try:
tree, meta = load_command_tree(commands)
except FileNotFoundError as exc:
print(f"pm3py client: {exc}", file=sys.stderr)
return 2
dev = make_driver(port=port, client=client, driver=driver)
try:
dev.start()
except DriverError as exc:
print(f"pm3py client: {exc}", file=sys.stderr)
return 1
state = ClientSession(dev, tree)
def bottom_toolbar():
# only ever the one-line relevance hint — the full help lives in the completion dropdown
try:
hint = one_line_hint(tree, get_app().current_buffer.text)
except Exception:
hint = None
return ANSI(hint) if hint else _DEFAULT_HINT
kb = KeyBindings()
install_key_bindings(kb, state)
session = PromptSession(
completer=ClientCompleter(state),
complete_while_typing=True, # ipython-style live menu
complete_style=CompleteStyle.COLUMN, # single column keeps ←/→ free for the toggle
auto_suggest=AutoSuggestFromHistory(), # inline suggestion hints
bottom_toolbar=bottom_toolbar, # live relevance / help panel
key_bindings=kb,
)
def message():
return ANSI(state.colored_breadcrumb() + " ")
n = meta.get("commands_extracted", "?")
out(f"pm3py client — {n} commands · {dev.backend} backend · → help · 'quit' to exit")
with patch_stdout():
while True:
state.help_open = False # each new line starts with help collapsed
state.help_target = None
try:
line = session.prompt(message)
except (EOFError, KeyboardInterrupt):
break
if not line.strip():
continue
if not dispatch(state, line, out):
break
dev.close()
out("bye")
return 0

View File

@@ -0,0 +1,82 @@
"""Completion for ``pm3py client``: walk the command trie by cursor position, offering subcommands
at each level and option flags on a leaf. Models :class:`pm3py.cli.rawcli.completer.RawCompleter`
— route by position, yield ``Completion`` with ``display_meta`` as the tooltip."""
from __future__ import annotations
from prompt_toolkit.completion import Completer, Completion
from .hints import help_rows
from .tree import parse_option
def _skip(flags) -> bool:
return "--help" in flags or "-h" in flags
class ClientCompleter(Completer):
def __init__(self, session):
self._session = session
def get_completions(self, document, complete_event):
# help mode (RIGHT on a selection): the dropdown becomes the command's help — the same
# two-column rows as normal completions, but non-inserting (LEFT restores completion).
if self._session.help_open:
for disp, meta in help_rows(self._session.help_target):
yield Completion("", start_position=0, display=disp, display_meta=meta)
return
tree = self._session.tree
text = document.text_before_cursor
tokens = text.split()
at_word = bool(tokens) and not text.endswith(" ")
partial = tokens[-1] if at_word else ""
path = tokens[:-1] if at_word else tokens
node = tree.node_at(path)
if node is not None:
# inside the tree: a leaf with no more subcommands (or a flag being typed) → options;
# otherwise the child subcommands at this level.
if node.is_leaf and (partial.startswith("-") or not node.children):
yield from self._options(node.entry, partial)
else:
yield from self._subcommands(node, partial)
return
# a path token fell off the tree — we're in a leaf's argument territory; offer flags only.
leaf = self._deepest_leaf(tree, path)
if leaf is not None and partial.startswith("-"):
yield from self._options(leaf, partial)
def _subcommands(self, node, partial):
for tok in sorted(node.children):
if not tok.startswith(partial):
continue
child = node.children[tok]
if child.is_leaf:
meta = child.entry.get("description", "")
else:
meta = f"{len(child.children)} subcommands"
# insert a trailing space so accepting the completion commits the token and immediately
# re-triggers completion at the next level ("hf " + <tab> → "hf 14a " → 14a's children)
yield Completion(tok + " ", start_position=-len(partial), display=tok, display_meta=meta)
def _options(self, entry, partial):
for opt in entry.get("options", []):
flags, meta, desc = parse_option(opt)
if not flags or _skip(flags):
continue
match = next((f for f in flags if f.startswith(partial)), None)
if match is None:
continue
disp = (", ".join(flags) + (f" {meta}" if meta else "")).strip()
yield Completion(match, start_position=-len(partial), display=disp, display_meta=desc)
def _deepest_leaf(self, tree, path):
node, leaf = tree.root, None
for tok in path:
node = node.children.get(tok)
if node is None:
break
if node.is_leaf:
leaf = node.entry
return leaf

View File

@@ -0,0 +1,281 @@
"""Drivers for the stock Proxmark3 C client.
``pm3py client`` does not reimplement the wire protocol — it drives the real client, so it works
with any Proxmark3 / firmware. Two interchangeable backends behind :class:`ClientDriver`:
- :class:`SwigDriver` (default) — the in-process ``_pm3`` SWIG bindings: ``pm3.pm3(port).console()``
+ ``grabbed_output``. Cleaner (no prompt-matching) but needs the built ``_pm3`` extension and a
connected device (the C ``pm3_open`` calls ``exit()`` on a real port with no responding PM3).
- :class:`PexpectDriver` — spawns the ``proxmark3`` binary and reads output up to the next
``pm3 -->`` prompt. Zero build; the fallback when ``_pm3`` isn't importable. The client's ANSI
colour is preserved (we ANSI-strip only for prompt/echo matching), and we never mutate ``prefs``.
:func:`make_driver` picks SWIG when ``_pm3`` is importable, else pexpect.
"""
from __future__ import annotations
import glob
import importlib.machinery
import importlib.util
import os
import re
import shutil
import sys
from pathlib import Path
from .tree import find_repo_root
#: full prompt matcher for clean text: ``"[<dev><net><ctx>] pm3 --> "``. Used to parse a label the
#: caller already has in hand (and in tests); pexpect matches on the tail instead — see below.
PROMPT_RE = re.compile(r"\[([^\]]*)\] pm3 -->\s*$")
#: what pexpect waits for. The ``pm3 --> `` tail is emitted uncoloured, so this matches regardless
#: of colour or the bracketed-paste sequences readline emits around the ``[...]`` label — matching
#: the label directly would let a stray ``[`` (from ``\x1b[?2004h``) capture the wrong bracket.
_EXPECT_RE = re.compile(r"pm3 -->\s*$")
#: the ``[label]`` at the end of the ANSI-stripped text just before the tail.
_LABEL_RE = re.compile(r"\[([^\]]*)\]\s*$")
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
#: every CSI sequence except SGR colour (final byte ``m``) — strips readline's bracketed-paste /
#: cursor / erase controls from passed-through output while keeping the client's colour intact.
_NONSGR = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-ln-~]")
#: fallback locations searched when a bare client name isn't on PATH (typical fork build layouts).
_CLIENT_FALLBACKS = (
"~/proxmark3/client/proxmark3",
"/usr/local/bin/proxmark3",
"/usr/bin/proxmark3",
)
def resolve_client(client: str) -> str:
"""Resolve the client binary. An explicit path is used verbatim; a bare name is looked up on
PATH. Only the *default* ``proxmark3`` name falls back to known fork build locations — an
explicitly-named client that isn't found is returned unchanged, so the spawn fails clearly
rather than silently running a different binary. """
if os.sep in client or (os.altsep and os.altsep in client):
return client
found = shutil.which(client)
if found:
return found
if client == "proxmark3": # auto-locate only the default binary
candidates = []
root = find_repo_root()
if root is not None: # the checkout this file was dropped beside
candidates.append(root / "client" / "proxmark3")
candidates += [Path(c).expanduser() for c in _CLIENT_FALLBACKS]
for p in candidates:
if p.is_file() and os.access(p, os.X_OK):
return str(p)
return client
class DriverError(RuntimeError):
"""The underlying client could not be launched, timed out, or exited."""
def strip_ansi(text: str) -> str:
return _ANSI.sub("", text or "")
def _scrubbed_env() -> dict:
"""A minimal environment for spawning the client. Snap / GTK / ``LD_*`` vars leaking in from a
sandboxed terminal (e.g. VSCode's snap) make the Qt-linked client crash on a libpthread symbol
clash, so we hand it only what it needs."""
env = {"TERM": os.environ.get("TERM", "xterm")}
for key in ("HOME", "PATH", "USER", "LANG", "LC_ALL"):
if key in os.environ:
env[key] = os.environ[key]
env.setdefault("PATH", "/usr/local/bin:/usr/bin:/bin")
return env
def _extract_output(raw: str, sent: str) -> str:
"""A command's output: ``child.before`` minus the echoed command line and framing blank lines.
The client's ANSI colour is preserved; readline's non-colour control sequences (bracketed
paste, cursor moves) are stripped, and the echo comparison is fully ANSI-stripped."""
lines = _NONSGR.sub("", raw or "").replace("\r", "").split("\n")
while lines and not strip_ansi(lines[0]).strip():
lines.pop(0)
if lines and strip_ansi(lines[0]).strip() == sent.strip():
lines.pop(0)
return "\n".join(lines).strip("\n")
class ClientDriver:
"""Driver interface. ``prompt`` is the last-seen context label (``usb`` / ``offline`` / …);
``backend`` names the mechanism (``swig`` / ``pexpect``)."""
prompt = "offline"
backend = "?"
def start(self) -> None: ...
def execute(self, line: str) -> str: ...
def close(self) -> None: ...
class PexpectDriver(ClientDriver):
backend = "pexpect"
def __init__(self, port=None, client="proxmark3", timeout=30):
self._port = port
self._client = resolve_client(client)
self._timeout = timeout
self._child = None
self.prompt = "offline"
def start(self) -> None:
import pexpect
# the stock binary does NOT auto-detect a port (that's the `pm3` wrapper's job) — without
# one it drops to offline mode, so resolve a port ourselves when none was given
port = self._port or _autodetect_port()
args = [port] if port else []
try:
self._child = pexpect.spawn(
self._client, args, env=_scrubbed_env(),
encoding="utf-8", codec_errors="replace", timeout=self._timeout,
)
self._child.expect(_EXPECT_RE)
except pexpect.ExceptionPexpect as exc:
raise DriverError(
f"could not start the proxmark3 client ({self._client!r}): {exc}\n"
" pass --client /path/to/proxmark3, or add it to your PATH"
) from exc
self._sync_prompt()
def execute(self, line: str) -> str:
import pexpect
if self._child is None:
raise DriverError("client not started")
try:
self._child.sendline(line)
self._child.expect(_EXPECT_RE)
except pexpect.EOF as exc:
raise DriverError("client exited") from exc
except pexpect.TIMEOUT as exc:
raise DriverError(f"client did not respond within {self._timeout}s") from exc
out = _extract_output(self._child.before, line)
self._sync_prompt()
return out
def _sync_prompt(self) -> None:
# the "[label]" sits at the end of the text just before the matched "pm3 -->" tail
m = _LABEL_RE.search(strip_ansi(self._child.before or ""))
if m:
self.prompt = m.group(1).strip() or "offline"
def close(self) -> None:
if self._child is None:
return
try:
self._child.sendline("quit")
self._child.close()
except Exception:
pass
self._child = None
#: extra checkout roots probed for the SWIG build (the discovered repo is tried first).
_SWIG_ROOTS = ("~/proxmark3",)
def _load_pm3_module():
"""Import and return the SWIG ``pm3`` module. If it isn't already importable, locate the built
library (``libpm3rrg_rdv4.so``, which exports ``PyInit__pm3``) and the ``pm3.py`` stub from the
discovered checkout and load the extension under the name ``_pm3`` — no symlink or filesystem
write. Raises :class:`ImportError` if the build can't be found."""
try:
import pm3
return pm3
except ImportError:
pass
roots = []
if os.environ.get("PM3_SWIG_PATH"):
roots.append(os.environ["PM3_SWIG_PATH"])
repo = find_repo_root()
if repo is not None:
roots.append(str(repo))
roots += [os.path.expanduser(r) for r in _SWIG_ROOTS]
for root in roots:
build = os.path.join(root, "client", "experimental_lib", "build")
pyscripts = os.path.join(root, "client", "pyscripts")
lib = os.path.join(build, "libpm3rrg_rdv4.so")
stub = os.path.join(pyscripts, "pm3.py")
if not (os.path.isfile(lib) and os.path.isfile(stub)):
# PM3_SWIG_PATH may itself be the dir holding both files
lib, stub, pyscripts = (os.path.join(root, "libpm3rrg_rdv4.so"),
os.path.join(root, "pm3.py"), root)
if not (os.path.isfile(lib) and os.path.isfile(stub)):
continue
if "_pm3" not in sys.modules:
loader = importlib.machinery.ExtensionFileLoader("_pm3", lib)
spec = importlib.util.spec_from_loader("_pm3", loader)
mod = importlib.util.module_from_spec(spec)
loader.exec_module(mod)
sys.modules["_pm3"] = mod
if pyscripts not in sys.path:
sys.path.insert(0, pyscripts)
import pm3
return pm3
raise ImportError(
"the pm3 SWIG extension (_pm3) isn't importable. Build it (in client/experimental_lib: "
"cmake .. && make -j), set $PM3_SWIG_PATH, or use --driver pexpect"
)
def _autodetect_port():
ports = sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*"))
return ports[0] if ports else None
class SwigDriver(ClientDriver):
"""In-process driver over the ``_pm3`` SWIG bindings. Needs a connected device: the C
``pm3_open`` calls ``exit()`` on a real port with no responding PM3, so we can't recover from a
bad connection — we only open an auto-detected or explicitly given port."""
backend = "swig"
def __init__(self, port=None, pm3_module=None):
self._port = port
self._mod = pm3_module
self._p = None
self.prompt = "offline"
def start(self) -> None:
mod = self._mod or _load_pm3_module()
port = self._port or _autodetect_port()
if port is None:
raise DriverError(
"no serial device found for the SWIG driver (no /dev/ttyACM*). plug in a "
"Proxmark, pass --port, or use --driver pexpect"
)
self._p = mod.pm3(port) # exits the process if this port has no PM3
self.prompt = "usb"
def execute(self, line: str) -> str:
if self._p is None:
raise DriverError("client not started")
# capture=False, quiet=False: let the client print its OWN coloured output straight to
# stdout. The grab buffer always strips ANSI (ui.c force-filters it), so capturing would
# lose the Proxmark's colours — the whole point of the PRINT path. Returns "" (already out).
self._p.console(line, False, False)
return ""
def close(self) -> None:
self._p = None
def make_driver(port=None, client="proxmark3", driver="auto"):
"""The driver for ``pm3py client``. ``driver`` is ``auto`` (SWIG if ``_pm3`` importable, else
pexpect), ``swig`` (error if unavailable), or ``pexpect``."""
if driver in ("auto", "swig"):
try:
mod = _load_pm3_module()
except ImportError as exc:
if driver == "swig":
raise DriverError(str(exc)) from exc
mod = None # auto → fall back to pexpect
if mod is not None:
return SwigDriver(port=port, pm3_module=mod)
return PexpectDriver(port=port, client=client)

View File

@@ -0,0 +1,81 @@
"""Hints for clientcli, all driven by the command tree.
- :func:`one_line_hint` — the one-line relevance hint on the bottom bar for the current input.
- :func:`help_rows` — the full help of a command as ``(display, meta)`` rows for the *completion
dropdown* (never the bottom bar — the dropdown is where all hints live, per rawcli 65be53d);
:func:`resolve_target` maps the *highlighted* completion back to the node whose help to show.
"""
from __future__ import annotations
from .tree import parse_option
def _skip(flags) -> bool:
return "--help" in flags or "-h" in flags
def one_line_hint(tree, text):
"""A one-line hint for ``text``: a leaf's description + usage, or an interior node's subcommand
count. Resolves on the completed prefix when the buffer ends mid-word. ``None`` if unknown."""
tokens = (text or "").split()
if not tokens:
return None
node, used = tree.node_at(tokens), tokens
if node is None:
node, used = tree.node_at(tokens[:-1]), tokens[:-1]
if node is None or node is tree.root:
return None
if node.is_leaf:
e = node.entry
return f"{e['command']}{e['description']} · {e['usage']}"
return f"{' '.join(used)}{len(node.children)} subcommands"
def help_rows(node):
"""The full help of a highlighted ``node`` as ``(display, meta)`` rows for the completion
dropdown — a leaf's name/usage/options/notes, or an interior node's subcommands. Presented in
the same two-column form the normal completions use. ``[]`` when there is nothing to show."""
if node is None:
return []
if node.is_leaf:
e = node.entry
rows = [(e["command"], e.get("description", ""))]
if e.get("usage"):
rows.append((e["usage"], "usage"))
for opt in e.get("options", []):
flags, meta, desc = parse_option(opt)
if not flags or _skip(flags):
continue
rows.append(((", ".join(flags) + (f" {meta}" if meta else "")).strip(), desc))
rows += [(n, "example") for n in e.get("notes", [])]
return rows
kids = sorted(node.children)
rows = [(node.token or "(commands)", f"{len(kids)} subcommands")]
for tok in kids:
child = node.children[tok]
meta = child.entry.get("description", "") if child.is_leaf else f"{len(child.children)} subcommands"
rows.append((tok, meta))
return rows
def resolve_target(tree, buffer_text, current_completion):
"""The tree node whose help to show for the highlighted completion. A subcommand completion
extends the current path; a flag completion targets the leaf under the cursor. ``None`` if
nothing resolves."""
tokens = (buffer_text or "").split()
at_word = bool(tokens) and not (buffer_text or "").endswith(" ")
path = tokens[:-1] if at_word else tokens
# subcommand completions carry a trailing space (the Tab-descend mechanism) — strip it or
# node_at's exact-token lookup misses and the help panel silently never shows
ctext = (getattr(current_completion, "text", "") if current_completion else "").strip()
if ctext.startswith("-"):
for i in range(len(path), -1, -1):
node = tree.node_at(path[:i])
if node is not None and node.is_leaf:
return node
return None
# menu navigation has already inserted the highlighted completion into the buffer
# (Buffer.go_to_completion), so the path may already end with ctext — don't append it twice
if ctext and (not path or path[-1] != ctext):
path = path + [ctext]
return tree.node_at(path)

130
pm3py/cli/clientcli/keys.py Normal file
View File

@@ -0,0 +1,130 @@
"""The help-toggle key bindings for clientcli — the centrepiece interaction.
When a completion is highlighted in the menu, RIGHT swaps the *dropdown* to the full help of that
command (name, usage, options, notes as rows); LEFT (primary) or ESCAPE / Ctrl-g (secondary)
restores normal completion. The help never touches the bottom bar — the dropdown is where all hints
live. The highlighted node is captured into ``session.help_target`` at the moment RIGHT is pressed,
then the completer renders its help while ``help_open`` is set.
The pure ``open_help`` / ``close_help`` helpers carry the state transitions for unit tests; the key
wiring is exercised in the live REPL.
"""
from __future__ import annotations
def open_help(session, node) -> bool:
"""Open help iff ``node`` resolved to something. Captures it as ``help_target`` so the completer
can render its help. Returns the resulting ``help_open`` state."""
if node is not None:
session.help_target = node
session.help_open = True
return session.help_open
def close_help(session) -> bool:
session.help_open = False
session.help_target = None
return session.help_open
def apply_tab(buff) -> None:
"""Commit the highlighted completion (or the first, if none is highlighted) into ``buff``.
The default Tab only *previews* under ``complete_while_typing`` — completions regenerate on
every buffer change, so the preview never sticks. ``apply_completion`` commits it; our
trailing-space completions then re-trigger completion at the next level. When no menu is open
yet, start one (selecting the first)."""
state = buff.complete_state
if state is None:
buff.start_completion(select_first=True)
return
completion = state.current_completion or (state.completions[0] if state.completions else None)
if completion is not None:
buff.apply_completion(completion)
def _bind(kb, key, handler, **kw) -> None:
"""Add a binding, ignoring key names a given prompt_toolkit build doesn't accept."""
try:
kb.add(key, **kw)(handler)
except ValueError:
pass
def install_key_bindings(kb, session) -> None:
"""Wire TAB completion and RIGHT/LEFT/ESCAPE/Ctrl-g help toggling onto ``kb``. Never raises on
an unknown key."""
from prompt_toolkit.filters import Condition, completion_is_selected
from prompt_toolkit.key_binding.bindings.named_commands import get_by_name
from .hints import resolve_target
help_is_open = Condition(lambda: session.help_open)
# The exact buffer document + completion menu captured when RIGHT was pressed, so LEFT can put
# the user back on the completion they had highlighted (not a freshly regenerated menu).
saved = {}
# Tab commits the highlighted (or first) completion so it sticks and descends a level;
# Shift-Tab keeps prompt_toolkit's default (previous completion).
_bind(kb, "tab", lambda event: apply_tab(event.current_buffer))
# complete_while_typing only re-runs completion on INSERT, so deleting characters leaves the
# dropdown stale (or blank). Re-run the completer after each deletion so the menu tracks the
# now-shorter input. Wrap the default readline command, then refresh — but not while the help
# panel is up (editing exits help first, handled by the LEFT/close path).
def _delete_then_complete(command_name):
binding = get_by_name(command_name)
def _handler(event):
binding.handler(event) # the default deletion
if not session.help_open:
event.current_buffer.start_completion(select_first=False) # refresh the dropdown
return _handler
for _key, _cmd in (("backspace", "backward-delete-char"),
("delete", "delete-char"),
("c-w", "unix-word-rubout"),
("c-u", "unix-line-discard")):
_bind(kb, _key, _delete_then_complete(_cmd))
def _open(event):
buff = event.current_buffer
state = buff.complete_state
cur = state.current_completion if state else None
node = resolve_target(session.tree, buff.text, cur)
if not open_help(session, node): # only if it resolved to a real command
return
# Remember exactly where we are. DETACH the menu (complete_state = None) rather than
# cancel_completion() — the latter calls go_to_completion(None), which would mutate this
# very state object and wipe the highlighted index we need to restore.
saved["document"] = buff.document
saved["state"] = state
buff.complete_state = None
buff.start_completion(select_first=False) # regenerate → help rows (completer sees help_open)
event.app.invalidate()
def _close(event):
buff = event.current_buffer
close_help(session)
if saved.get("state") is not None:
# Restore the pre-RIGHT selection exactly. Set the document first (this detaches
# complete_state without firing completion), then re-attach the saved menu — the same
# order Buffer.go_to_completion uses.
buff.document = saved["document"]
buff.complete_state = saved["state"]
saved.clear()
else:
buff.cancel_completion()
event.app.invalidate()
# RIGHT opens help for the highlighted completion. With nothing selected (or help already open)
# the filter is false, so the binding is inert and the default cursor-right still works.
_bind(kb, "right", _open, filter=completion_is_selected & ~help_is_open)
# LEFT is the instant back-out; active only while help is open, else default cursor-left.
_bind(kb, "left", _close, filter=help_is_open)
# ESCAPE also backs out, but it is prompt_toolkit's meta prefix, so a bare escape fires only
# after ~0.5s (timeoutlen); Ctrl-g is the instant alternative. Do NOT use eager=True — it would
# fix the delay at the cost of breaking Alt-key sequences.
_bind(kb, "escape", _close, filter=help_is_open)
_bind(kb, "c-g", _close, filter=help_is_open)

142
pm3py/cli/clientcli/pack.py Normal file
View File

@@ -0,0 +1,142 @@
"""Generate a standalone, dependency-light single-file ``pm3cli`` from this package.
The runtime modules (:mod:`tree`, :mod:`driver`, :mod:`session`, :mod:`hints`, :mod:`completer`,
:mod:`keys`, :mod:`app`) are concatenated in dependency order with their intra-package imports
stripped, then a self-contained ``main()`` is appended. The result is one file that needs no pm3py
install — drop it inside a stock Iceman/RRG Proxmark3 checkout and run it. Two outputs:
- ``pm3cli.py`` — a readable single file; the target runs ``pip install prompt_toolkit`` (and
``pexpect`` for the non-SWIG backend), then ``python3 pm3cli.py``.
- ``pm3cli.pyz`` — a zipapp with prompt_toolkit/pexpect bundled inside; ``python3 pm3cli.pyz`` with
nothing to install.
The package (with its tests) stays the source of truth — this only re-emits it, so the standalone
can't drift.
"""
from __future__ import annotations
import importlib
import os
import shutil
import tempfile
import zipapp
#: runtime modules, in definition-before-use order (tree first, app last).
_MODULES = ("tree", "driver", "session", "hints", "completer", "keys", "app")
#: bundled into the .pyz so it runs with zero pip installs (all pure-Python).
_PYZ_DEPS = ("prompt_toolkit", "pexpect", "ptyprocess", "wcwidth")
_HEADER = '''\
#!/usr/bin/env python3
"""pm3cli — an autocompleting REPL for the stock Proxmark3 client.
GENERATED from pm3py.cli.clientcli — do not edit by hand; regenerate with `pm3py client --pack`.
Drop this beside (or inside) a Proxmark3 checkout and run it: python3 pm3cli.py
It finds the checkout's doc/commands.json + client automatically, auto-detects /dev/ttyACM*,
and drives the stock client (SWIG _pm3 if built, else the proxmark3 binary via pexpect).
Needs prompt_toolkit (pip install prompt_toolkit); the pexpect backend also needs pexpect.
"""
from __future__ import annotations
'''
_MAIN = '''
# --------------------------------------------------------------------------- standalone entrypoint
def main(argv=None) -> int:
import argparse
import importlib.util
import sys
p = argparse.ArgumentParser(
prog="pm3cli", description="autocompleting REPL for the stock Proxmark3 client")
p.add_argument("--port", default=None, help="serial port (default: auto-detect /dev/ttyACM*)")
p.add_argument("--commands", default=None,
help="path to commands.json (default: auto-locate in the checkout)")
p.add_argument("--client", default="proxmark3",
help="proxmark3 binary to wrap (default: PATH, then the checkout)")
p.add_argument("--driver", choices=["auto", "swig", "pexpect"], default="auto",
help="backend: auto (SWIG if built, else pexpect), swig, or pexpect")
args = p.parse_args(argv)
if importlib.util.find_spec("prompt_toolkit") is None:
print("pm3cli needs prompt_toolkit: pip install prompt_toolkit", file=sys.stderr)
return 2
if args.driver != "swig" and importlib.util.find_spec("pexpect") is None:
print("pm3cli's pexpect backend needs pexpect: pip install pexpect\\n"
" (or build the SWIG _pm3 lib and pass --driver swig)", file=sys.stderr)
return 2
return run(port=args.port, commands=args.commands, client=args.client, driver=args.driver)
if __name__ == "__main__":
raise SystemExit(main())
'''
def _drop(line: str) -> bool:
"""True for intra-package / future imports that must not survive amalgamation."""
s = line.lstrip()
return (s.startswith("from __future__")
or s.startswith("from .")
or s.startswith("from pm3py")
or s.startswith("import pm3py"))
def amalgamate() -> str:
"""Return the full standalone source as a string."""
here = os.path.dirname(os.path.abspath(__file__))
parts = [_HEADER]
for mod in _MODULES:
src = open(os.path.join(here, mod + ".py"), encoding="utf-8").read()
body = "\n".join(ln for ln in src.splitlines() if not _drop(ln))
parts.append(f"\n\n# ===================================================== clientcli.{mod}\n"
+ body.strip() + "\n")
parts.append(_MAIN)
return "".join(parts)
def _build_pyz(code: str, out_path: str) -> None:
import importlib.metadata as _im
with tempfile.TemporaryDirectory() as d:
with open(os.path.join(d, "__main__.py"), "w", encoding="utf-8") as f:
f.write(code)
for pkg in _PYZ_DEPS:
try:
mod = importlib.import_module(pkg)
except Exception:
continue
src = mod.__file__
if src.endswith("__init__.py"): # a package → copy the whole dir
shutil.copytree(os.path.dirname(src), os.path.join(d, pkg),
ignore=shutil.ignore_patterns("__pycache__", "*.pyc",
"tests", "test"))
else: # a single-module dep
shutil.copy2(src, os.path.join(d, os.path.basename(src)))
# bundle the .dist-info too — some packages read their own version via
# importlib.metadata at import time (e.g. prompt_toolkit), which reads it from the zip.
try:
info = getattr(_im.distribution(pkg), "_path", None)
if info is not None and os.path.isdir(info):
shutil.copytree(info, os.path.join(d, os.path.basename(str(info))))
except Exception:
pass
zipapp.create_archive(d, out_path, interpreter="/usr/bin/env python3")
os.chmod(out_path, 0o755)
def pack(out_path: str) -> str:
"""Write the standalone to ``out_path``. A ``.pyz`` extension builds a dependency-bundled
zipapp; anything else writes the readable single ``.py``. Returns ``out_path``."""
code = amalgamate()
compile(code, out_path, "exec") # fail loudly if the amalgamation is invalid
if out_path.endswith(".pyz"):
_build_pyz(code, out_path)
else:
with open(out_path, "w", encoding="utf-8") as f:
f.write(code)
os.chmod(out_path, 0o755)
return out_path

View File

@@ -0,0 +1,42 @@
"""clientcli session state: the driver, the loaded command tree, and the help-toggle flag. Kept
free of prompt_toolkit so it stays unit-testable (mirrors :mod:`pm3py.cli.rawcli.session`)."""
from __future__ import annotations
from typing import Any
# ANSI palette aligned with pm3py/trace/format.py (same as rawcli's breadcrumb)
_DIM = "\033[2m"
_BGREEN = "\033[1;32m"
_BRED = "\033[1;31m"
_BOLD = "\033[1m"
_RESET = "\033[0m"
class ClientSession:
"""Mutable clientcli state. ``driver`` runs the stock client, ``tree`` is the loaded command
trie. ``help_open`` swaps the completion dropdown to the help of ``help_target`` (the node
captured when RIGHT was pressed); LEFT clears both back to normal completion."""
def __init__(self, driver: Any, tree: Any):
self.driver = driver
self.tree = tree
self.help_open: bool = False
self.help_target: Any = None
@property
def prompt_label(self) -> str:
"""The client's current context label (``usb`` / ``fpc`` / ``offline`` / ``usb|tcp`` …)."""
return getattr(self.driver, "prompt", "offline") or "offline"
def breadcrumb(self) -> str:
return f"[client / {self.prompt_label}] pm3py -->"
def colored_breadcrumb(self) -> str:
"""The breadcrumb with ANSI colour — bold ``client``, green when a device is attached, red
when offline. Stripping the ANSI yields :meth:`breadcrumb`."""
label = self.prompt_label
color = _BRED if "offline" in label else _BGREEN
sep = f"{_DIM} / {_RESET}"
segs = [f"{_BOLD}client{_RESET}", f"{color}{label}{_RESET}"]
return (f"{_DIM}[{_RESET}" + sep.join(segs) + f"{_DIM}]{_RESET} "
f"{_BOLD}pm3py -->{_RESET}")

158
pm3py/cli/clientcli/tree.py Normal file
View File

@@ -0,0 +1,158 @@
"""Command-tree model for ``pm3py client``.
Loads the stock Proxmark3 client's machine-readable help dump (``doc/commands.json``) and builds a
prefix trie over the full command paths (``"hf mf rdbl"``). The JSON keys are **leaf** paths only —
interior nodes (``"hf"``, ``"hf mf"``) are synthesised from the path segments (no leaf path is a
prefix of another, so the trie is unambiguous). Option strings are pre-rendered by the client
(``"-k, --key <hex> key, 6 hex bytes"``); :func:`parse_option` splits them into
``(flags, metavar, description)`` for option-flag completion.
Regenerate the JSON for another client build with::
proxmark3 --fulltext | client/pyscripts/pm3_help2json.py - -
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
#: env override for the commands.json path (highest priority in :func:`load_command_tree`).
_ENV_VAR = "PM3_COMMANDS_JSON"
#: a single option flag, e.g. ``-a``, ``-1``, ``--key`` (numeric short flags like ``-1`` exist).
_FLAG = re.compile(r"^--?[A-Za-z0-9][\w-]*$")
def find_repo_root(start=None):
"""Locate a Proxmark3 client checkout (stock Iceman/RRG or a fork) by its ``doc/commands.json``
marker, walking up from ``$PM3_REPO``/``$PM3_ROOT``, ``start``, the current directory, and this
file's location. Returns a :class:`~pathlib.Path` to the repo root, or ``None``. This is what
lets a dropped-in single file find whatever checkout it lives beside."""
seeds = []
for env in ("PM3_REPO", "PM3_ROOT"):
if os.environ.get(env):
seeds.append(Path(os.environ[env]))
if start:
seeds.append(Path(start))
seeds.append(Path.cwd())
try:
seeds.append(Path(__file__).resolve().parent)
except NameError: # __file__ absent (e.g. frozen/REPL)
pass
seen = set()
for seed in seeds:
base = seed.resolve() if seed.exists() else seed
for cand in (base, *base.parents):
if cand in seen:
continue
seen.add(cand)
if (cand / "doc" / "commands.json").is_file():
return cand # dropped inside the checkout
sub = cand / "proxmark3" # a side-by-side checkout (dev layout)
if (sub / "doc" / "commands.json").is_file():
return sub
return None
@dataclass
class CommandNode:
"""A node in the command trie. ``entry`` is the ``commands.json`` leaf dict for a command, or
``None`` for a synthesised interior (category) node."""
token: str # last path segment; "" for the root
children: dict[str, "CommandNode"] = field(default_factory=dict)
entry: dict | None = None
@property
def is_leaf(self) -> bool:
return self.entry is not None
class CommandTree:
def __init__(self, commands: dict):
self.root = CommandNode("")
for path, entry in commands.items():
node = self.root
for seg in path.split():
node = node.children.setdefault(seg, CommandNode(seg))
node.entry = entry
def node_at(self, tokens) -> CommandNode | None:
"""The node reached by walking ``tokens`` from the root, or ``None`` if any is unknown."""
node = self.root
for tok in tokens:
node = node.children.get(tok)
if node is None:
return None
return node
def children_of(self, tokens) -> list[CommandNode]:
"""Completion candidates below ``tokens`` (alphabetical), or ``[]`` for an unknown path."""
node = self.node_at(tokens)
if node is None:
return []
return [node.children[k] for k in sorted(node.children)]
def entry_of(self, tokens) -> dict | None:
"""The leaf metadata at ``tokens`` (``None`` for an interior or unknown path)."""
node = self.node_at(tokens)
return node.entry if node else None
def parse_option(line: str):
"""Split a rendered option string into ``(flags, metavar, description)``.
``"-k, --key <hex> key, 6 hex bytes"`` -> ``(["-k", "--key"], "<hex>", "key, 6 hex bytes")``
``"--blk <dec> block number"`` -> ``(["--blk"], "<dec>", "block number")``
``"-a input key type is key A (def)"`` -> ``(["-a"], "", "input key type is key A (def)")``
The leading run of flag tokens is delimited by trailing commas (``-k,`` promises another flag);
a following ``<...>`` token is the metavar; the rest is the description.
"""
toks = line.split()
flags: list[str] = []
i, more = 0, True
while i < len(toks) and more:
raw = toks[i]
core = raw[:-1] if raw.endswith(",") else raw
if not _FLAG.match(core):
break
flags.append(core)
more = raw.endswith(",") # a trailing comma promises another flag
i += 1
meta = ""
if i < len(toks) and toks[i].startswith("<"):
meta = toks[i]
i += 1
return flags, meta, " ".join(toks[i:])
def load_command_tree(path=None):
"""Return ``(CommandTree, metadata)``. Resolution order: an explicit ``path`` (``--commands``),
then ``$PM3_COMMANDS_JSON``, then the discovered repo's ``doc/commands.json`` (see
:func:`find_repo_root`), then ``./doc/commands.json``. Raises :class:`FileNotFoundError` (with
the regen hint) when nothing is found."""
candidates = []
if path:
candidates.append(Path(path))
elif os.environ.get(_ENV_VAR):
candidates.append(Path(os.environ[_ENV_VAR]))
else:
root = find_repo_root()
if root is not None:
candidates.append(root / "doc" / "commands.json")
candidates.append(Path("doc/commands.json"))
for cand in candidates:
p = cand.expanduser()
if p.is_file():
data = json.loads(p.read_text())
return CommandTree(data["commands"]), data.get("metadata", {})
raise FileNotFoundError(
"no Proxmark3 commands.json found (looked in: "
+ ", ".join(str(c) for c in candidates) + ").\n"
" run this from inside a Proxmark3 checkout, pass --commands PATH, set $PM3_REPO, or\n"
" regenerate it: proxmark3 --fulltext | client/pyscripts/pm3_help2json.py - -"
)

72
pm3py/cli/main.py Normal file
View File

@@ -0,0 +1,72 @@
"""`pm3py` top-level CLI dispatcher.
Subcommands live in submodules: ``rawcli`` (the interactive raw-command TUI over pm3py's own
protocol stack) and ``client`` (an autocompleting REPL wrapping the stock ``proxmark3`` binary).
Room is left for ``flash`` (wrapping pm3flash).
"""
from __future__ import annotations
import argparse
import sys
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="pm3py", description="pm3py command-line tools")
sub = parser.add_subparsers(dest="command")
p = sub.add_parser("rawcli", help="interactive raw-command TUI for a connected Proxmark3")
p.add_argument("--port", default=None, help="serial port (default: auto-detect)")
c = sub.add_parser("client", help="autocompleting REPL wrapping the stock proxmark3 client")
c.add_argument("--port", default=None, help="serial port passed to proxmark3 (default: auto)")
c.add_argument("--commands", default=None, help="path to commands.json (default: auto-locate)")
c.add_argument("--client", default="proxmark3", help="proxmark3 binary to wrap (default: PATH)")
c.add_argument("--driver", choices=["auto", "swig", "pexpect"], default="auto",
help="backend: auto (SWIG if built, else pexpect), swig, or pexpect")
c.add_argument("--pack", metavar="OUT", default=None,
help="spin off a standalone drop-in instead of launching: OUT.py (single file) "
"or OUT.pyz (deps bundled). Distributable to stock Proxmark3 users.")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "rawcli":
try:
import prompt_toolkit # noqa: F401
except ModuleNotFoundError:
print(
"pm3py rawcli needs prompt_toolkit (a core dependency) but it isn't importable.\n"
" reinstall pm3py, or: pip install prompt_toolkit",
file=sys.stderr,
)
return 2
from pm3py.cli.rawcli.app import run
return run(port=args.port)
if args.command == "client":
if args.pack:
from pm3py.cli.clientcli.pack import pack
out = pack(args.pack)
print(f"wrote standalone drop-in: {out}")
return 0
# prompt_toolkit is always needed; pexpect only for the pexpect backend (SWIG doesn't use it)
needs = ["prompt_toolkit"] + (["pexpect"] if args.driver != "swig" else [])
missing = [m for m in needs if not _importable(m)]
if missing:
print(
f"pm3py client needs {' and '.join(missing)} but "
f"{'they are' if len(missing) > 1 else 'it is'} not importable.\n"
f" reinstall pm3py, or: pip install {' '.join(missing)}",
file=sys.stderr,
)
return 2
from pm3py.cli.clientcli.app import run
return run(port=args.port, commands=args.commands, client=args.client, driver=args.driver)
parser.print_help()
return 1
def _importable(name: str) -> bool:
import importlib.util
return importlib.util.find_spec(name) is not None

View File

@@ -0,0 +1,6 @@
"""rawcli — an interactive, annotated raw-command TUI for a connected Proxmark3.
Launched via ``pm3py rawcli``. Distinct from the pyws Python REPL and the future ``pm3py client``
command CLI: it has its own flexible grammar (loose hex/binary entry with 0x/0b toggle,
function-style transponder commands) and a live Proxmark-style annotated trace pane.
"""

261
pm3py/cli/rawcli/app.py Normal file
View File

@@ -0,0 +1,261 @@
"""rawcli interactive loop.
Phase 1 skeleton: connect (best-effort), a live breadcrumb prompt, ``help``/``quit``, and a raw
hex payload → annotated exchange. Entry-mode toggling, identify/connection, the command catalog,
and TLV editing arrive in later phases (see the plan). The interactive loop is intentionally thin;
the testable logic lives in :mod:`session` / :mod:`trace_view` / (later) ``parser``.
"""
from __future__ import annotations
import sys
from .session import RawSession
from .parser import parse_line
from .entry import install_key_bindings
from .identify import identify, clear, format_summary
from .catalog import catalog_for
from .trace_view import render_exchange
_HELP = """\
rawcli — raw command interface
help [cmd] show this help (or one command's usage)
identify probe the field; sets HF/LF + transponder, keeps the connection
transponder show the identified transponder
tlv [bytes] decode / edit a TLV (NDEF) blob
close drop the tag connection
quit | exit | q leave rawcli
<hex/bin ...> send raw bytes to the tag (CRC auto-appended on 14a); trace prints above
NAME(args) run a command from the identified tag (READ(4), GET_VERSION()) — listed below
Entry:
* Raw bytes are hex by default ("30 04" or "3004" auto-spaces into bytes).
* Ctrl-/ (or Ctrl-t) toggles hex (0x) / binary (0b) entry — shown in the breadcrumb.
* Base is tracked PER BYTE: the toggle only changes the base of the NEXT byte, it never
rewrites what you've typed. So `30` then toggle then `00000100` gives 30 00000100 and
sends 0x30 0x04. A byte auto-seals at its width (2 hex / 8 binary) and the next digit
starts a fresh byte. A 0x/0b prefix on one token overrides the mode for that token.
* Function-call arguments are base-10 unless prefixed: READ(4) = page 4, READ(0x2B) = hex.
* Suggestions (commands, opcodes by value, and the tag's memory map) appear in the
completion menu as you type — in whichever base you're entering.
* You can't mix a command and raw bytes on one line — that's an error, not a transmit."""
def _connect(port):
"""Best-effort device connection; returns the client or None (offline)."""
try:
from pm3py import Proxmark3
return Proxmark3.sync(port) # sync proxy: scan()/raw() are plain calls
except Exception as exc: # no device / connect failure — run offline
print(f"rawcli: no device ({type(exc).__name__}: {exc}); running offline", file=sys.stderr)
return None
ISO14A_APPEND_CRC = 0x20
def _needs_crc(payload: bytes) -> bool:
"""Whether a 14a frame should get a CRC appended — everything except the frames ISO14443-A
sends without one: the 7-bit short frames REQA (0x26) / WUPA (0x52) and the ANTICOLLISION
frames (0x9x 0x20, which carry a BCC, not a CRC)."""
if not payload:
return False
b0 = payload[0]
if len(payload) == 1 and b0 in (0x26, 0x52):
return False
if len(payload) >= 2 and b0 in (0x93, 0x95, 0x97) and payload[1] == 0x20:
return False
return True
def _ensure_selected(device, protocol: str) -> None:
"""Re-select the 14a tag before a command so a stale/dropped selection (or a prior bad frame)
can't leave the tag unresponsive. Silent — the re-select isn't traced. Note: this resets any
prior auth, so a PWD_AUTH must be issued together with the command it protects (a later
keep-session mode will address multi-step auth)."""
if device is None or protocol != "hf14a":
return
try:
device.hf.iso14a.scan()
except Exception:
pass
def _raw_exchange(device, payload: bytes, protocol: str = "hf14a", crc: bool = False) -> bytes | None:
"""Issue one raw exchange over the identified protocol, returning the response bytes. ``crc``
appends the ISO14443-A CRC (catalog commands need it; manual raw hex is sent verbatim)."""
if device is None:
return None
try:
if protocol == "hf14a":
resp = device.hf.iso14a.raw(payload, flags=ISO14A_APPEND_CRC if crc else 0)
elif protocol == "hf15":
raw = getattr(device.hf.iso15, "raw", None)
resp = raw(payload) if raw else None
else:
return None # lf raw exchange lands in a later phase
if not isinstance(resp, dict):
return None
data = resp.get("raw") # bytes; "data" is the hex string form
if data is None:
hexstr = resp.get("data")
data = bytes.fromhex(hexstr) if isinstance(hexstr, str) else hexstr
return data
except Exception as exc:
print(f"rawcli: exchange failed ({type(exc).__name__}: {exc})", file=sys.stderr)
return None
def dispatch(state: RawSession, line: str, out=print) -> bool:
"""Handle one input line. ``out`` prints a (possibly ANSI-colored) string. Returns False to
exit the REPL, True to keep going. Testable without a live prompt (default ``out=print``)."""
try:
cmd = parse_line(line, state.entry_mode, state.byte_bases)
except ValueError as exc:
out(f"rawcli: {exc}")
return True
finally:
state.byte_bases = [] # the line is consumed — start the next one fresh
if cmd.kind == "control":
if cmd.name in ("quit", "exit", "q"):
return False
if cmd.name == "help":
_handle_help(state, cmd.args, out)
elif cmd.name == "identify":
result = identify(state, emit=out) # stream the probe exchanges to the trace
out(format_summary(result))
elif cmd.name == "transponder":
out(state.transponder or "no transponder identified — run 'identify'")
elif cmd.name == "close":
clear(state)
out("connection closed")
elif cmd.name == "tlv":
_handle_tlv(state, cmd.args, out)
return True
if cmd.kind == "call":
_handle_call(state, cmd, out)
return True
if not cmd.payload:
return True
_ensure_selected(state.device, state.protocol)
response = _raw_exchange(state.device, cmd.payload, protocol=state.protocol,
crc=state.protocol == "hf14a") # 14a frames need CRC; bare `60` works
out(render_exchange(cmd.payload, response, protocol=state.protocol, is_tty=True))
return True
def _handle_call(state: RawSession, cmd, out=print) -> None:
catalog = catalog_for(state)
if catalog is None:
out("no transponder identified — run 'identify' first")
return
tc = catalog.get(cmd.name)
if tc is None:
out(f"unknown command {cmd.name!r} for {catalog.name}; try 'help'")
return
# LF commands aren't a raw-byte exchange (14a/15693) — they drive the device via lf.t55 etc.
# and return a human result line. (build() still exists for the opcode hint in completion.)
if tc.run is not None:
if state.device is None:
out("no device connected")
return
try:
out(str(tc.run(state.device, *cmd.args)))
except (TypeError, ValueError) as exc:
out(f"usage: {tc.usage()} ({exc})")
except Exception as exc: # a flaky device shouldn't kill the REPL
out(f"rawcli: {tc.name} failed ({type(exc).__name__}: {exc})")
return
try:
built = tc.build(*cmd.args)
except (TypeError, ValueError) as exc:
out(f"usage: {tc.usage()} ({exc})")
return
frames = built if isinstance(built, list) else [built] # multi-frame (e.g. two-phase write)
crc = catalog.protocol == "hf14a" # 14a commands need CRC to be accepted
_ensure_selected(state.device, catalog.protocol) # re-select once for the command
for frame in frames:
response = _raw_exchange(state.device, frame, protocol=catalog.protocol, crc=crc)
out(render_exchange(frame, response, protocol=catalog.protocol, is_tty=True))
def _handle_tlv(state: RawSession, args, out=print) -> None:
from .tlv import format_tlv, prompt_tlv
from .parser import parse_bytes
if args:
try:
data = parse_bytes(" ".join(args), state.entry_mode)
except ValueError as exc:
out(f"rawcli: {exc}")
return
else:
data = prompt_tlv(state) # interactive multi-line editor
if not data:
return
out(format_tlv(data))
def _handle_help(state: RawSession, args, out=print) -> None:
catalog = catalog_for(state)
if args and catalog is not None:
tc = catalog.get(args[0])
if tc is not None:
out(f"{tc.usage()}{tc.help}")
return
lines = [_HELP]
if catalog is not None:
lines.append(f"\n{catalog.name} commands:")
lines += [f" {catalog.get(n).usage():<28}{catalog.get(n).help}" for n in catalog.names()]
out("\n".join(lines))
def run(port=None) -> int:
from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.patch_stdout import patch_stdout
from .completer import RawCompleter
def out(text):
# parse the ANSI our formatters emit and render it through prompt_toolkit (renders
# color on a terminal, strips it when piped) — plain print() shows the codes literally
print_formatted_text(ANSI(str(text)))
state = RawSession(device=_connect(port))
def bottom_toolbar():
# just the entry-mode status — all command/page hints live in the completion menu
return f"entry {state.entry_prefix} · Ctrl-/ hex/binary · 'help'"
kb = KeyBindings()
install_key_bindings(kb, state)
session = PromptSession(
key_bindings=kb,
completer=RawCompleter(state),
complete_while_typing=True, # ipython-style live menu
auto_suggest=AutoSuggestFromHistory(), # inline suggestion hints
bottom_toolbar=bottom_toolbar, # live relevance hint
)
def message():
return ANSI(state.colored_breadcrumb() + " ")
with patch_stdout():
while True:
try:
line = session.prompt(message)
except (EOFError, KeyboardInterrupt):
break
if not line.strip():
continue
if not dispatch(state, line, out):
break
out("bye")
return 0

575
pm3py/cli/rawcli/catalog.py Normal file
View File

@@ -0,0 +1,575 @@
"""Per-transponder command catalogs.
A declarative, enumerable command set for each tag family: each entry knows its parameters, how
to **build** the raw bytes to send, and its help text. rawcli surfaces these for ``help`` and for
function-style calls (``READ(4)`` → build → raw exchange → annotated trace). Homed here for now;
the richer per-vendor catalogs can migrate into the ``pm3py/reader/`` scaffold as they grow.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from .parser import parse_arg_int
@dataclass
class TagCommand:
name: str
params: tuple[str, ...]
build: Callable[..., bytes] | None = None # HF: string args -> raw payload bytes to exchange
help: str = ""
run: Callable[..., object] | None = None # LF: (device, *args) -> a human result line
opcode_byte: int | None = None # explicit wire opcode. ISO15693 sets this to the
# COMMAND byte (frame byte 1), since byte 0 is flags
def usage(self) -> str:
return f"{self.name}({', '.join(self.params)})"
def opcode(self) -> int | None:
"""The command's wire opcode — an explicit ``opcode_byte`` if given (ISO15693: the command
byte, not the flags-first frame byte), else the first built byte. None for run-only LF
commands with no build."""
if self.opcode_byte is not None:
return self.opcode_byte
if self.build is None:
return None
try:
built = self.build(*(["00"] * len(self.params))) # "00" is valid for both _int and _hex
except Exception:
return None
if isinstance(built, list): # two-phase write: the first frame's opcode
built = built[0] if built else b""
return built[0] if built else None
@dataclass
class Catalog:
name: str
protocol: str
commands: dict[str, TagCommand]
def get(self, name: str) -> TagCommand | None:
return self.commands.get(name.upper())
def by_opcode(self, op: int) -> TagCommand | None:
"""The command whose first sent byte is ``op`` — for hinting a raw payload (``30 …`` →
READ). None if no command matches."""
for tc in self.commands.values():
if tc.opcode() == op:
return tc
return None
def names(self) -> list[str]:
return list(self.commands)
def _int(s: str) -> int:
return parse_arg_int(s) # base 10 by default; 0x/0b/0o to override
def _hex(s: str) -> bytes:
return bytes.fromhex(s.lower().replace("0x", "").replace(" ", ""))
def _catalog(name: str, protocol: str, *cmds: TagCommand) -> Catalog:
return Catalog(name, protocol, {c.name.upper(): c for c in cmds})
def _c(name, params, build=None, help="", run=None, opcode=None) -> TagCommand:
return TagCommand(name, tuple(params), build, help, run, opcode)
# ---- ISO 14443-A Type 2 (NTAG / Ultralight) ----
TYPE2 = _catalog(
"NTAG / Ultralight (Type 2)", "hf14a",
_c("READ", ["page"], lambda page: bytes([0x30, _int(page)]),
"read 4 pages starting at <page> (0x30)"),
_c("FAST_READ", ["start", "end"], lambda start, end: bytes([0x3A, _int(start), _int(end)]),
"read pages <start>..<end> in one frame (0x3A)"),
_c("GET_VERSION", [], lambda: bytes([0x60]), "product/version info (0x60)"),
_c("READ_SIG", [], lambda: bytes([0x3C, 0x00]), "read the ECC originality signature (0x3C)"),
_c("READ_CNT", ["counter"], lambda counter="0": bytes([0x39, _int(counter)]),
"read the 24-bit NFC counter (0x39)"),
_c("PWD_AUTH", ["password"],
lambda password: bytes([0x1B]) + _hex(password).ljust(4, b"\x00")[:4],
"authenticate with the 4-byte password → PACK (0x1B)"),
_c("WRITE", ["page", "data"],
lambda page, data: bytes([0xA2, _int(page)]) + _hex(data).ljust(4, b"\x00")[:4],
"write 4 bytes <data> to <page> (0xA2)"),
_c("COMPAT_WRITE", ["page", "data"],
lambda page, data: [bytes([0xA0, _int(page)]), _hex(data).ljust(16, b"\x00")[:16]],
"compatibility write: 16-byte <data> to <page>, two-phase (0xA0)"),
_c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the tag (0x50 00)"),
)
# ---- MIFARE Classic ---------------------------------------------------------------------------
# Classic reads/writes are gated by a crypto1 auth per sector, so — unlike NTAG — a bare 0x30 does
# nothing. These run via hf.mf, which does the auth + block op in firmware. build() still exposes
# the wire opcode (0x30/0xA0) for hex/binary completion. Key defaults to the transport key
# FFFFFFFFFFFF / key A; override as READ(<block>, <12-hex key>, A|B).
_MFC_KEYTYPE = {"A": 0, "B": 1, "0": 0, "1": 1}
# common transport / default keys tried by CHK when you don't know the sector key
_MFC_KEYS = ["FFFFFFFFFFFF", "A0A1A2A3A4A5", "D3F7D3F7D3F7", "000000000000",
"B0B1B2B3B4B5", "4D3A99C351DD", "1A982C7E459A", "AABBCCDDEEFF"]
def _kt(keytype) -> int:
return _MFC_KEYTYPE.get(str(keytype).upper(), 0)
def _mfc_read(dev, block, key="FFFFFFFFFFFF", keytype="A"):
r = dev.hf.mf.rdbl(_int(block), key=key, key_type=_kt(keytype))
if isinstance(r, dict) and r.get("success"):
return f"block {_int(block)} = {r['data']} (key {str(keytype).upper()})"
err = r.get("error") if isinstance(r, dict) else "?"
return f"READ block {_int(block)}: auth/read failed with key {str(keytype).upper()} {key} (status {err})"
def _mfc_write(dev, block, data, key="FFFFFFFFFFFF", keytype="A"):
r = dev.hf.mf.wrbl(_int(block), _hex(data).ljust(16, b"\x00")[:16], key=key, key_type=_kt(keytype))
ok = isinstance(r, dict) and r.get("success")
return f"WRITE block {_int(block)} <- {_hex(data).hex()}: {'ok' if ok else 'failed'} (key {str(keytype).upper()})"
def _mfc_chk(dev, block, keytype="A"):
r = dev.hf.mf.chk(_int(block), _MFC_KEYS, key_type=_kt(keytype))
if isinstance(r, dict) and r.get("found"):
return f"block {_int(block)} key {str(keytype).upper()} = {r['key']}"
return f"CHK block {_int(block)} key {str(keytype).upper()}: no key from the default list works"
def _mfc_auth(dev, block, key, keytype):
# a successful authenticated read == the key authenticates that sector (standalone crypto1 auth
# isn't a firmware single-shot; auth is otherwise implicit in READ/WRITE)
r = dev.hf.mf.rdbl(_int(block), key=key, key_type=_kt(keytype))
ok = isinstance(r, dict) and r.get("success")
return f"AUTH_{str(keytype).upper()} block {_int(block)} key {key}: {'ok' if ok else 'auth failed'}"
def _mfc_auth_a(dev, block, key="FFFFFFFFFFFF"):
return _mfc_auth(dev, block, key, "A")
def _mfc_auth_b(dev, block, key="FFFFFFFFFFFF"):
return _mfc_auth(dev, block, key, "B")
def _mfc_value(dev, block, action, value, verb, key, keytype):
r = dev.hf.mf.value(_int(block), action, value=value, key=key, key_type=_kt(keytype))
ok = isinstance(r, dict) and r.get("success")
return f"{verb} block {_int(block)}: {'ok' if ok else 'failed'} (key {str(keytype).upper()})"
def _mfc_incr(dev, block, value="1", key="FFFFFFFFFFFF", keytype="A"):
return _mfc_value(dev, block, 0, _int(value), f"INCREMENT by {_int(value)}", key, keytype)
def _mfc_decr(dev, block, value="1", key="FFFFFFFFFFFF", keytype="A"):
return _mfc_value(dev, block, 1, _int(value), f"DECREMENT by {_int(value)}", key, keytype)
def _mfc_restore(dev, block, key="FFFFFFFFFFFF", keytype="A"):
return _mfc_value(dev, block, 2, 0, "RESTORE (in place)", key, keytype)
def _mfc_transfer(dev, block, dest, key="FFFFFFFFFFFF", keytype="A"):
# datasheet TRANSFER commits the transfer buffer; the firmware fuses it with the value op, so
# expose it as a value-block copy: restore <block> then transfer to <dest>
r = dev.hf.mf.value(_int(block), 2, transfer_block=_int(dest), key=key, key_type=_kt(keytype))
ok = isinstance(r, dict) and r.get("success")
return f"TRANSFER block {_int(block)} -> {_int(dest)}: {'ok' if ok else 'failed'} (key {str(keytype).upper()})"
def _mfc_personalize(dev, option="0"):
return "PERSONALIZE_UID (0x40) — MIFARE Classic EV1 command; opcode shown for completion, execution not yet wired"
def _mfc_setmod(dev, option="0"):
return "SET_MOD_TYPE (0x43) — MIFARE Classic EV1 command; opcode shown for completion, execution not yet wired"
# The MIFARE Classic datasheet wire command set (MF1S50/EV1). All ops after activation are crypto1-
# gated, so they run via hf.mf (auth + op in firmware); build() exposes the wire opcode for the
# hex/binary completion and the block map (params[0]=="block"). CHK is a pm3py key-finder (not
# datasheet) kept because you need a key before anything else works.
MFC = _catalog(
"MIFARE Classic", "hf14a",
_c("AUTH_A", ["block", "key"], build=lambda block, key="0": bytes([0x60, _int(block)]),
run=_mfc_auth_a, help="authenticate a sector with Key A — reports if the key works (0x60)"),
_c("AUTH_B", ["block", "key"], build=lambda block, key="0": bytes([0x61, _int(block)]),
run=_mfc_auth_b, help="authenticate a sector with Key B — reports if the key works (0x61)"),
_c("READ", ["block", "key", "keytype"], build=lambda block, key="0", keytype="0": bytes([0x30, _int(block)]),
run=_mfc_read, help="auth + read a 16-byte block (key def FFFFFFFFFFFF/A) (0x30)"),
_c("WRITE", ["block", "data", "key", "keytype"],
build=lambda block, data, key="0", keytype="0": [bytes([0xA0, _int(block)]), _hex(data).ljust(16, b"\x00")[:16]],
run=_mfc_write, help="auth + write a 16-byte block (0xA0)"),
_c("INCREMENT", ["block", "value", "key", "keytype"],
build=lambda block, value="0", key="0", keytype="0": bytes([0xC1, _int(block)]),
run=_mfc_incr, help="increment a value block by <value> and commit (0xC1 + transfer)"),
_c("DECREMENT", ["block", "value", "key", "keytype"],
build=lambda block, value="0", key="0", keytype="0": bytes([0xC0, _int(block)]),
run=_mfc_decr, help="decrement a value block by <value> and commit (0xC0 + transfer)"),
_c("RESTORE", ["block", "key", "keytype"],
build=lambda block, key="0", keytype="0": bytes([0xC2, _int(block)]),
run=_mfc_restore, help="restore/refresh a value block in place (0xC2 + transfer)"),
_c("TRANSFER", ["block", "dest", "key", "keytype"],
build=lambda block, dest="0", key="0", keytype="0": bytes([0xB0, _int(block)]),
run=_mfc_transfer, help="copy a value block <block> -> <dest> (0xC2 restore + 0xB0 transfer)"),
_c("CHK", ["block", "keytype"], run=_mfc_chk,
help="try the default key list against a block — reports the working key A/B"),
_c("PERSONALIZE_UID", ["option"], build=lambda option="0": bytes([0x40, _int(option)]),
run=_mfc_personalize, help="EV1: UID/anticollision personalization (0x40)"),
_c("SET_MOD_TYPE", ["option"], build=lambda option="0": bytes([0x43, _int(option)]),
run=_mfc_setmod, help="EV1: set modulation type (0x43)"),
_c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the card (0x50 00)"),
)
# ---- ISO 15693 request framing --------------------------------------------------------------
# A 15693 request is flags | command | [mfg] | [UID LSB-first] | params | CRC. The first byte is
# the request-FLAGS bitfield, NOT the command — so 15693 TagCommands carry an explicit opcode=
# (the command byte) for completion, and iso15_frame() assembles the wire frame (CRC added by
# hf.iso15.raw()).
# Request-flags byte (byte 0). Bits 5-6 (0x10/0x20) have two meanings, chosen by the Inventory
# flag (0x04): non-inventory -> Select/Address; inventory -> AFI/Nb_slots (ISO 15693-3 §7.3.1).
F15_SUBCARRIER = 0x01 # two subcarriers (else one)
F15_HIGH_RATE = 0x02 # data_rate: high (else low)
F15_INVENTORY = 0x04
F15_PROTO_EXT = 0x08
F15_SELECT = 0x10 # non-inventory: Select flag
F15_AFI = 0x10 # inventory: AFI flag (same bit, inventory mode)
F15_ADDRESSED = 0x20 # non-inventory: Address flag
F15_1SLOT = 0x20 # inventory: Nb_slots = 1 (else 16)
F15_OPTION = 0x40
NXP_MFG = 0x04
def iso15_frame(command: int, params: bytes = b"", *, uid=None, addressed: bool = False,
high_rate: bool = True, option: bool = False, inventory: bool = False,
select: bool = False, proto_ext: bool = False, two_subcarrier: bool = False,
slots16: bool = False, afi: int | None = None, mfg: int | None = None) -> bytes:
"""Assemble an ISO 15693 request frame (pre-CRC): flags | command | [mfg] | [UID|AFI] | params.
Exposes the full request-flags byte (byte 0). Bits 5-6 differ by mode (ISO 15693-3 §7.3.1):
non-inventory -> Select(0x10)/Address(0x20); inventory -> AFI(0x10)/Nb_slots(0x20). Set
``inventory=True`` for the INVENTORY family — 1 slot unless ``slots16``, and the AFI byte + flag
when ``afi`` is given; otherwise ``select``/``addressed`` apply. ``two_subcarrier`` (0x01),
``proto_ext`` (0x08) and ``option`` (0x40) apply in both modes. An addressed UID is transmitted
LSByte-first; the inventory AFI byte precedes ``params`` (i.e. the mask length/value)."""
flags = 0
if two_subcarrier:
flags |= F15_SUBCARRIER
if high_rate:
flags |= F15_HIGH_RATE
if proto_ext:
flags |= F15_PROTO_EXT
if option:
flags |= F15_OPTION
if inventory:
flags |= F15_INVENTORY
if afi is not None:
flags |= F15_AFI # inventory: AFI flag (bit 5)
if not slots16:
flags |= F15_1SLOT # inventory: Nb_slots = 1 (bit 6)
else:
if select:
flags |= F15_SELECT # non-inventory: Select (bit 5)
if addressed and uid:
flags |= F15_ADDRESSED # non-inventory: Address (bit 6)
out = bytearray([flags, command])
if mfg is not None:
out.append(mfg)
if inventory:
if afi is not None:
out.append(afi)
elif addressed and uid:
u = bytes.fromhex(uid) if isinstance(uid, str) else bytes(uid)
out += u[::-1] # UID is transmitted LSByte-first
return bytes(out) + bytes(params)
def iso15_flags_decode(flags: int) -> str:
"""Human decode of a 15693 request-flags byte (byte 0), for the raw-entry hint. Covers every
bit; bits 5-6 are mode-dependent (Select/Address vs AFI/Nb_slots, see iso15_frame)."""
if flags & F15_INVENTORY:
parts = ["inventory", "high rate" if flags & F15_HIGH_RATE else "low rate"]
if flags & F15_SUBCARRIER:
parts.append("two subcarrier")
parts.append("1 slot" if flags & F15_1SLOT else "16 slots")
if flags & F15_AFI:
parts.append("AFI")
if flags & F15_PROTO_EXT:
parts.append("protocol-ext")
else:
parts = ["high rate" if flags & F15_HIGH_RATE else "low rate"]
if flags & F15_SUBCARRIER:
parts.append("two subcarrier")
parts.append("addressed" if flags & F15_ADDRESSED else "non-addressed")
if flags & F15_SELECT:
parts.append("select")
if flags & F15_PROTO_EXT:
parts.append("protocol-ext")
if flags & F15_OPTION:
parts.append("option")
return " · ".join(parts)
# common flags bytes offered at frame byte 0 (the decode hint covers any value you type)
ISO15_FLAG_LANDMARKS = [0x02, 0x22, 0x12, 0x42, 0x62, 0x0A, 0x26, 0x06, 0x36, 0x00]
def _inv_read_frame(cmd: int, first_block, count, mask_len="0", mask="", afi="") -> bytes:
"""INVENTORY READ / FAST INVENTORY READ request body:
``[AFI] | mask_len(bits) | mask_value | first_block | (count-1)``.
``mask_len`` 0 (default) reads the one tag in the field. A non-zero ``mask_len`` (bits) plus a
``mask`` value (hex, LSByte-first on the wire, padded/truncated to ceil(bits/8) bytes) does
selective anticollision; ``afi`` (when given) adds the AFI filter byte + flag. This is the full
ICODE inventory-read request per the datasheet."""
ml = _int(mask_len)
nbytes = (ml + 7) // 8
mask_bytes = ((_hex(mask) if mask else b"") + b"\x00" * nbytes)[:nbytes]
body = bytes([ml]) + mask_bytes + bytes([_int(first_block), _int(count) - 1])
return iso15_frame(cmd, body, inventory=True,
afi=(_int(afi) if str(afi) != "" else None), mfg=NXP_MFG)
# ---- ISO 15693 (generic) ----
ISO15 = _catalog(
"ISO 15693", "hf15",
_c("READ_BLOCK", ["block"], lambda block: iso15_frame(0x20, bytes([_int(block)])),
"read a single block (0x20)", opcode=0x20),
_c("WRITE_BLOCK", ["block", "data"],
lambda block, data: iso15_frame(0x21, bytes([_int(block)]) + _hex(data)),
"write a single 4-byte block (0x21)", opcode=0x21),
_c("GET_SYSTEM_INFO", [], lambda: iso15_frame(0x2B), "get system information (0x2B)", opcode=0x2B),
)
# ---- ICODE SLIX (NXP SL2S2002) — datasheet command set via the header builder ----
_SLIX = _catalog(
"ICODE SLIX", "hf15",
# standard ISO 15693
_c("READ_BLOCK", ["block"], lambda block: iso15_frame(0x20, bytes([_int(block)])),
"read a single 4-byte block (0x20)", opcode=0x20),
_c("WRITE_BLOCK", ["block", "data"],
lambda block, data: iso15_frame(0x21, bytes([_int(block)]) + _hex(data).ljust(4, b"\x00")[:4]),
"write a single 4-byte block (0x21)", opcode=0x21),
_c("LOCK_BLOCK", ["block"], lambda block: iso15_frame(0x22, bytes([_int(block)])),
"permanently lock a block (0x22)", opcode=0x22),
_c("READ_MULTIPLE", ["first", "count"],
lambda first, count: iso15_frame(0x23, bytes([_int(first), _int(count) - 1])),
"read <count> blocks from <first> (0x23)", opcode=0x23),
_c("SELECT", [], lambda: iso15_frame(0x25), "put the tag in Selected state (0x25)", opcode=0x25),
_c("RESET_TO_READY", [], lambda: iso15_frame(0x26), "reset the tag to Ready (0x26)", opcode=0x26),
_c("STAY_QUIET", [], lambda: iso15_frame(0x02), "silence the tag until reset (0x02)", opcode=0x02),
_c("WRITE_AFI", ["afi"], lambda afi: iso15_frame(0x27, bytes([_int(afi)])),
"write the AFI byte (0x27)", opcode=0x27),
_c("LOCK_AFI", [], lambda: iso15_frame(0x28), "lock the AFI (0x28)", opcode=0x28),
_c("WRITE_DSFID", ["dsfid"], lambda dsfid: iso15_frame(0x29, bytes([_int(dsfid)])),
"write the DSFID byte (0x29)", opcode=0x29),
_c("LOCK_DSFID", [], lambda: iso15_frame(0x2A), "lock the DSFID (0x2A)", opcode=0x2A),
_c("GET_SYSTEM_INFO", [], lambda: iso15_frame(0x2B), "get system information (0x2B)", opcode=0x2B),
_c("GET_SECURITY", ["first", "count"],
lambda first, count: iso15_frame(0x2C, bytes([_int(first), _int(count) - 1])),
"get block security (lock) status (0x2C)", opcode=0x2C),
# NXP custom (manufacturer code 0x04 inserted by the builder)
_c("GET_NXP_SYSTEM_INFO", [], lambda: iso15_frame(0xAB, mfg=NXP_MFG),
"NXP system info — protection pointer, lock, features (0xAB)", opcode=0xAB),
_c("GET_RANDOM", [], lambda: iso15_frame(0xB2, mfg=NXP_MFG),
"get a 16-bit challenge to XOR the password with (0xB2)", opcode=0xB2),
_c("SET_PASSWORD", ["pwd_id", "xor_pwd"],
lambda pwd_id, xor_pwd: iso15_frame(0xB3, bytes([_int(pwd_id)]) + _hex(xor_pwd), mfg=NXP_MFG),
"present a password (4 bytes XOR'd with GET_RANDOM) (0xB3)", opcode=0xB3),
_c("WRITE_PASSWORD", ["pwd_id", "pwd"],
lambda pwd_id, pwd: iso15_frame(0xB4, bytes([_int(pwd_id)]) + _hex(pwd), mfg=NXP_MFG),
"write a new password (0xB4)", opcode=0xB4),
_c("LOCK_PASSWORD", ["pwd_id"],
lambda pwd_id: iso15_frame(0xB5, bytes([_int(pwd_id)]), mfg=NXP_MFG),
"lock a password (0xB5)", opcode=0xB5),
_c("PROTECT_PAGE", ["page", "prot"],
lambda page, prot: iso15_frame(0xB6, bytes([_int(page), _int(prot)]), mfg=NXP_MFG),
"set a page's read/write protection (0xB6)", opcode=0xB6),
_c("LOCK_PAGE_PROTECTION", ["page"],
lambda page: iso15_frame(0xB7, bytes([_int(page)]), mfg=NXP_MFG),
"lock a page's protection condition (0xB7)", opcode=0xB7),
_c("SET_EAS", [], lambda: iso15_frame(0xA2, mfg=NXP_MFG), "set the EAS bit (0xA2)", opcode=0xA2),
_c("RESET_EAS", [], lambda: iso15_frame(0xA3, mfg=NXP_MFG), "clear the EAS bit (0xA3)", opcode=0xA3),
_c("LOCK_EAS", [], lambda: iso15_frame(0xA4, mfg=NXP_MFG), "lock the EAS bit (0xA4)", opcode=0xA4),
_c("EAS_ALARM", [], lambda: iso15_frame(0xA5, mfg=NXP_MFG), "read the EAS alarm sequence (0xA5)", opcode=0xA5),
_c("PROTECT_EAS_AFI", [], lambda: iso15_frame(0xA6, mfg=NXP_MFG),
"password-protect EAS/AFI (0xA6)", opcode=0xA6),
_c("WRITE_EAS_ID", ["eas_id"], lambda eas_id: iso15_frame(0xA7, _hex(eas_id), mfg=NXP_MFG),
"write the 16-bit EAS ID (0xA7)", opcode=0xA7),
_c("ENABLE_PRIVACY", ["xor_pwd"], lambda xor_pwd: iso15_frame(0xBA, _hex(xor_pwd), mfg=NXP_MFG),
"enter privacy mode with the privacy password (0xBA)", opcode=0xBA),
_c("DESTROY", ["xor_pwd"], lambda xor_pwd: iso15_frame(0xB9, _hex(xor_pwd), mfg=NXP_MFG),
"permanently destroy the tag — IRREVERSIBLE (0xB9)", opcode=0xB9),
# INVENTORY READ family: inventory + block read in one exchange. Full body via _inv_read_frame
# ([AFI] | mask_len | mask | first_block | count-1); mask-less reads the one tag in the field.
_c("INVENTORY_READ", ["first_block", "count", "mask_len", "mask", "afi"],
lambda first_block, count, mask_len="0", mask="", afi="":
_inv_read_frame(0xA0, first_block, count, mask_len, mask, afi),
"inventory + read <count> blocks from <first_block>; optional <mask_len> bits + <mask> value "
"(selective anticollision) and <afi> filter (0xA0)", opcode=0xA0),
_c("FAST_INVENTORY_READ", ["first_block", "count", "mask_len", "mask", "afi"],
lambda first_block, count, mask_len="0", mask="", afi="":
_inv_read_frame(0xA1, first_block, count, mask_len, mask, afi),
"INVENTORY_READ with the response at the higher data rate (0xA1)", opcode=0xA1),
)
# ICODE SLIX2 (SL2S2602): everything the SLIX has, plus three SLIX2-only commands and a larger
# 80-block memory. READ_SIGNATURE (0xBD) is the one identify probes to tell SLIX2 from SLIX.
_SLIX2 = _catalog(
"ICODE SLIX2", "hf15", *_SLIX.commands.values(),
_c("READ_SIGNATURE", [], lambda: iso15_frame(0xBD, mfg=NXP_MFG),
"read the 32-byte ECC originality signature — SLIX2 only (0xBD)", opcode=0xBD),
_c("PASSWORD_PROTECTION_64BIT", [], lambda: iso15_frame(0xBB, mfg=NXP_MFG),
"combine the read+write passwords into 64-bit protection — SLIX2 only (0xBB)", opcode=0xBB),
_c("STAY_QUIET_PERSISTENT", [], lambda: iso15_frame(0xBC, mfg=NXP_MFG),
"persistent quiet that survives power cycles — SLIX2 only (0xBC)", opcode=0xBC),
)
# NTAG 5 (NTP5210 Switch / NTA5332 Link/Boost): the SLIX2 command set minus STAY_QUIET_PERSISTENT
# (NTAG5 rejects it) plus config memory + session registers, PICK RANDOM UID, and — on Link/Boost —
# SRAM. NTAG5 shares the DNA UID (E0 04 01 18), so routing here needs a functional identify probe.
_NTAG5 = _catalog(
"NTAG 5", "hf15",
*[c for c in _SLIX2.commands.values() if c.name != "STAY_QUIET_PERSISTENT"],
_c("READ_CONFIG", ["block", "count"],
lambda block, count: iso15_frame(0xC0, bytes([_int(block), _int(count) - 1]), mfg=NXP_MFG),
"read config / session-register blocks (0xA0 STATUS_REG, 0xA1 CONFIG_REG) (0xC0)", opcode=0xC0),
_c("WRITE_CONFIG", ["block", "data"],
lambda block, data: iso15_frame(
0xC1, bytes([_int(block)]) + _hex(data).ljust(4, b"\x00")[:4], mfg=NXP_MFG),
"write a 4-byte config / session-register block (0xC1)", opcode=0xC1),
_c("PICK_RANDOM_UID", [], lambda: iso15_frame(0xC2, mfg=NXP_MFG),
"generate a random UID for privacy mode — AES variants only (0xC2)", opcode=0xC2),
_c("READ_SRAM", ["block", "count"],
lambda block, count: iso15_frame(0xD2, bytes([_int(block), _int(count) - 1]), mfg=NXP_MFG),
"read SRAM blocks — Link/Boost only (0xD2)", opcode=0xD2),
_c("WRITE_SRAM", ["block", "data"],
lambda block, data: iso15_frame(
0xD3, bytes([_int(block)]) + _hex(data).ljust(4, b"\x00")[:4], mfg=NXP_MFG),
"write a 4-byte SRAM block — Link/Boost only (0xD3)", opcode=0xD3),
)
# ---- LF T5577 / ATA5577 (block read/write over the T55xx downlink; executes via lf.t55) ----
# build() exists only to expose the downlink opcode for hex/binary completion; execution goes
# through run() (the device method), since LF isn't a raw-byte exchange like 14a.
def _word(s) -> int:
"""A 32-bit T55xx data/password word — parsed as hex (0x/spaces tolerated), matching the pm3
client's ``-d``/``-p`` and every other rawcli WRITE. Blocks/pages stay decimal (``_int``)."""
h = str(s).lower().replace("0x", "").replace(" ", "") or "0"
return int(h, 16) & 0xFFFFFFFF
def _optpwd(password) -> int | None:
"""A T55xx password argument (hex word), or None when omitted (the empty-string default)."""
return _word(password) if str(password) != "" else None
def _pwd_note(password) -> str:
return f" (pwd {_word(password):08X})" if str(password) != "" else ""
def _t55_read(dev, block, page="0", password=""):
b, pg, pwd = _int(block), _int(page), _optpwd(password)
if b == 0 and pg == 0: # config block — the reliable one
from pm3py.lf.capture import read_config
r = read_config(dev, password=pwd)
if r:
return (f"block 0 = {r['config_hex']} ({r['modulation']}, RF/{r['data_bit_rate']}, "
f"{r['max_block']} blocks) -> {r['emulating']}{_pwd_note(password)}")
return f"READ block 0: no clean config recovered{_pwd_note(password)}"
from pm3py.lf.capture import read_t55xx_block
word = read_t55xx_block(dev, b, page=pg, password=pwd)
where = f"block {b}" + (" (page 1)" if pg else "")
if word is None:
return f"READ {where}: no clean response (LF block reads are demod-dependent){_pwd_note(password)}"
return f"{where} = {word:08X} (best-effort — bit alignment not verified){_pwd_note(password)}"
def _t55_write(dev, block, data, page="0", password=""):
w = _word(data)
r = dev.lf.t55.writebl(_int(block), w, page=_int(page), password=_optpwd(password))
ok = isinstance(r, dict) and r.get("status") == 0
where = f"block {_int(block)}" + (" (page 1)" if _int(page) else "")
return f"WRITE {where} <- {w:08X}: {'ok' if ok else 'failed'}{_pwd_note(password)}"
def _t55_wake(dev, password):
r = dev.lf.t55.wakeup(_word(password))
return f"WAKE: {'ok' if isinstance(r, dict) and r.get('status') == 0 else 'failed'}"
def _t55_reset(dev):
r = dev.lf.t55.reset()
return f"RESET: {'ok' if isinstance(r, dict) and r.get('status') == 0 else 'failed'}"
def _t55_detect(dev, password=""):
from pm3py.lf.capture import read_config
r = read_config(dev, password=_optpwd(password))
if not r:
return f"DETECT: no T55xx config recovered{_pwd_note(password)}"
return (f"config {r['config_hex']} ({r['modulation']}, RF/{r['data_bit_rate']}, "
f"{r['max_block']} blocks) -> {r['emulating']}{_pwd_note(password)}")
def _lf_reread(dev):
from pm3py.lf import identify_lf
r = identify_lf(dev)
return r["label"] if r else "no LF credential decoded"
# The ATA5577C downlink command set. The wire opcode is a 2-bit field (10/11 = read/write page
# 0/1, 00 = reset) followed by optional 32-bit password, lock bit, data and 3-bit address — a
# bit-level frame, not a byte exchange, so build() returns a synthetic opcode byte only for the
# hex/binary completion; execution goes through run() (lf.t55). READ/WRITE take an optional <page>
# (1 = traceability data) and <password> for password-mode access.
T5577 = _catalog(
"T5577 / ATA5577", "lf",
_c("READ", ["block", "page", "password"],
build=lambda block, page="0", password="": bytes([0x01, _int(block)]), run=_t55_read,
help="read a 32-bit block (0=config, 7=password, 1-6=data); <page> 1 = traceability, "
"optional <password> — best-effort demod"),
_c("WRITE", ["block", "data", "page", "password"],
build=lambda block, data, page="0", password="": bytes([0x02, _int(block)]),
run=_t55_write, help="write 32-bit <data> to <block>; optional <page> / <password> (0x02)"),
_c("WAKE", ["password"], build=lambda password: bytes([0x03]), run=_t55_wake,
help="wake a password-protected tag with the block-7 password — AOR (0x03)"),
_c("RESET", [], build=lambda: bytes([0x00]), run=_t55_reset,
help="send the reset command (opcode 00) — realign the tag's bitstream"),
_c("DETECT", ["password"], build=lambda password="": bytes([0x01, 0x00]), run=_t55_detect,
help="read + decode the config block (block 0); optional <password>"),
)
# ---- LF read-only credentials (EM4100/HID/AWID/FDX-B): broadcast tags with no command set ----
LF_READONLY = _catalog(
"LF credential (read-only)", "lf",
_c("INFO", [], run=_lf_reread, help="re-read and decode the broadcast credential"),
)
def catalog_for(session) -> Catalog | None:
"""Resolve the command catalog for the session's identified transponder, or None."""
name = (session.transponder or "").upper()
if session.protocol == "hf15":
if "NTAG5" in name or "NTAG 5" in name:
return _NTAG5
if "SLIX2" in name or "DNA" in name: # DNA: closest existing set; own catalog is a follow-up
return _SLIX2
return _SLIX if "SLIX" in name or "ICODE" in name else ISO15
if session.protocol == "hf14a":
if not session.transponder:
return None
return MFC if ("CLASSIC" in name or "MINI" in name) else TYPE2
if session.protocol == "lf":
if not session.transponder:
return None
return T5577 if "T5577" in name else LF_READONLY
return None

View File

@@ -0,0 +1,263 @@
"""ipython-style completion for rawcli.
Completes the leading token in two ways, both driven by the **identified transponder**:
- by name — control verbs and the transponder's catalog commands (help text as the tooltip);
- by **opcode** — as you type hex or binary bytes, the transponder's command opcodes are matched
against the input (respecting a ``0x``/``0b`` prefix and the current entry mode), so ``60`` and
``01100000`` both surface ``GET_VERSION``.
``help <cmd>`` completes catalog command names too. Paired in the app with menu completion while
typing and history auto-suggestion for the ipython feel.
"""
from __future__ import annotations
import re
from prompt_toolkit.completion import Completer, Completion
from .parser import CONTROL_VERBS, parse_token
from .catalog import catalog_for
from .memory import landmark_pages
from .entry import is_byte_line
_HEXDIGITS = set("0123456789abcdefABCDEF")
_BINDIGITS = set("01")
# NAME( <first-arg-being-typed> — matches only while the first argument is under the cursor
# (a comma ends the match, so second args aren't touched).
_CALL_ARG = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\(\s*([^,)]*)$")
def _opcode(tc) -> int | None:
"""The command's first byte (opcode) — None for run-only commands with no ``build``."""
return tc.opcode()
class RawCompleter(Completer):
def __init__(self, session):
self._session = session
def get_completions(self, document, complete_event):
text = document.text_before_cursor
stripped = text.lstrip()
# inside READ(<page> / WRITE(<block> — offer the tag's memory map for the page argument
m = _CALL_ARG.match(stripped)
if m:
yield from self._page_arg(m.group(1), m.group(2))
return
tokens = stripped.split()
at_word = bool(tokens) and not stripped.endswith(" ")
# ISO 15693 raw frame is position-aware: byte 0 = flags, byte 1 = command, byte 2 = mfg
# (a bitfield-first frame, unlike 14a where byte 0 is the opcode).
if getattr(self._session, "protocol", None) == "hf15" and is_byte_line(stripped):
yield from self._iso15_raw(tokens, at_word)
return
# raw byte entry, on the byte after a page-command opcode (30 …): offer the memory map as
# raw bytes, so `30 04` gets the same suggestions as READ( — inserting the raw value
on_second = (len(tokens) == 2 and at_word) or (len(tokens) == 1 and not at_word)
if on_second and self._is_raw_opcode(tokens[0]):
yield from self._raw_page_byte(tokens, at_word)
return
word = tokens[-1] if at_word else ""
completing_first = (" " not in stripped) or (len(tokens) == 1 and at_word)
if completing_first:
yield from self._first_token(word)
elif tokens and tokens[0].lower() == "help":
yield from self._catalog_names(word)
def _is_raw_opcode(self, tok: str) -> bool:
"""True when ``tok`` parses to a single byte that is a page-command opcode (so the next
raw byte is a page argument we can hint)."""
catalog = catalog_for(self._session)
if catalog is None:
return False
# parse the opcode byte in ITS base — the base it was typed in, not the current entry mode
# (after `30` in hex you toggle to binary to type the page byte; `30` is still hex)
bases = getattr(self._session, "byte_bases", []) or []
base = bases[0] if bases else getattr(self._session, "entry_mode", "hex")
try:
b = parse_token(tok, base)
except ValueError:
return False
if len(b) != 1:
return False
tc = catalog.by_opcode(b[0])
return tc is not None and bool(tc.params) and tc.params[0] in ("page", "block", "start")
def _raw_page_byte(self, tokens: list[str], at_word: bool):
"""Offer the tag's memory map for a raw page byte (after a page-command opcode), rendered as
a raw byte in the entry base — ``04`` in hex, ``00000100`` in binary. Accepting inserts the
raw value, keeping you in raw entry (30 04), not the command name."""
partial = tokens[1] if (len(tokens) >= 2 and at_word) else ""
low = partial.lower()
if low.startswith("0x"):
base, body = "hex", low[2:]
elif low.startswith("0b"):
base, body = "bin", low[2:]
elif getattr(self._session, "entry_mode", "hex") == "bin":
base, body = "bin", low
else:
base, body = "hex", low
for page, role in landmark_pages(self._session.transponder):
# a raw byte is fixed-width (2 hex / 8 binary digits) — match the full-width form only
if base == "bin":
val = f"{page:08b}"
else:
val = f"{page:02X}"
if body and not val.lower().startswith(body):
continue
yield Completion(val, start_position=-len(partial),
display=f"{val} {role}", display_meta=role)
def _raw_base(self, partial: str) -> tuple[str, str]:
"""(base, body) for a raw-frame byte token — a 0x/0b prefix wins, else the session mode."""
low = partial.lower()
if low.startswith("0x"):
return "hex", low[2:]
if low.startswith("0b"):
return "bin", low[2:]
return ("bin" if getattr(self._session, "entry_mode", "hex") == "bin" else "hex"), low
def _iso15_byte(self, value: int, partial: str, label: str, meta: str):
"""A single raw-byte completion rendered in the entry base, filtered by ``partial``."""
base, body = self._raw_base(partial)
val = f"{value:08b}" if base == "bin" else f"{value:02X}"
if body and not val.lower().startswith(body):
return None
return Completion(val, start_position=-len(partial), display=f"{val} {label}", display_meta=meta)
def _iso15_raw(self, tokens: list[str], at_word: bool):
"""Position-aware completion for an ISO 15693 raw frame: byte 0 = the request-flags menu
(each value decoded), byte 1 = the command, byte 2 = the NXP mfg code on custom commands."""
from .catalog import ISO15_FLAG_LANDMARKS, iso15_flags_decode, NXP_MFG
pos = (len(tokens) - 1) if at_word else len(tokens)
partial = tokens[pos] if (at_word and pos < len(tokens)) else ""
if pos == 0: # flags byte — the header menu + decode
for f in ISO15_FLAG_LANDMARKS:
c = self._iso15_byte(f, partial, iso15_flags_decode(f), iso15_flags_decode(f))
if c:
yield c
elif pos == 1: # command byte
catalog = catalog_for(self._session)
if catalog is None:
return
for name in catalog.names():
op = catalog.get(name).opcode()
if op is None:
continue
c = self._iso15_byte(op, partial, name, catalog.get(name).help)
if c:
yield c
elif pos == 2: # mfg byte, only after a custom command
cmd = self._token_byte(tokens[1]) if len(tokens) > 1 else None
if cmd is not None and cmd >= 0xA0:
c = self._iso15_byte(NXP_MFG, partial, "NXP manufacturer code", "NXP custom (0x04)")
if c:
yield c
def _token_byte(self, tok: str) -> int | None:
try:
b = parse_token(tok, getattr(self._session, "entry_mode", "hex"))
except ValueError:
return None
return b[0] if len(b) == 1 else None
def _page_arg(self, cmd_name: str, partial: str):
"""Complete a page/block argument with the identified tag's memory landmarks, rendered in
the active entry mode — ``0x04`` in hex, ``0b00000100`` in binary (every bit visible,
since for config pages each bit carries its own meaning). ``READ(`` lists the whole map;
typing narrows it. A ``0x``/``0b`` prefix on the argument overrides the session mode."""
catalog = catalog_for(self._session)
if catalog is None:
return
tc = catalog.get(cmd_name)
if tc is None or not tc.params or tc.params[0] not in ("page", "block", "start"):
return # only a page/block/start address gets a map
mode = self._arg_mode(partial)
body = partial.lower()
if body.startswith(("0x", "0b")):
body = body[2:]
for page, role in landmark_pages(self._session.transponder):
if mode == "bin":
val, forms = f"0b{page:08b}", (f"{page:08b}", f"{page:b}")
else:
val, forms = f"0x{page:02X}", (f"{page:02x}", f"{page:x}", str(page))
if body and not any(f.startswith(body) for f in forms):
continue
yield Completion(val, start_position=-len(partial),
display=f"{val} {role}", display_meta=role)
def _arg_mode(self, partial: str) -> str:
"""hex/bin for an argument token — a 0x/0b prefix wins, else the session entry mode."""
low = partial.lower()
if low.startswith("0x"):
return "hex"
if low.startswith("0b"):
return "bin"
return "bin" if getattr(self._session, "entry_mode", "hex") == "bin" else "hex"
def _first_token(self, word: str):
low = word.lower()
for verb in sorted(CONTROL_VERBS):
if verb.startswith(low):
yield Completion(verb, start_position=-len(word), display_meta="command")
catalog = catalog_for(self._session)
if catalog is None:
return
# by command name
for name in catalog.names():
if name.lower().startswith(low):
tc = catalog.get(name)
yield Completion(f"{name}(", start_position=-len(word),
display=tc.usage(), display_meta=tc.help)
# by opcode, from hex/binary input (transponder-dependent)
yield from self._opcode_matches(word, catalog)
def _numeric(self, word: str) -> tuple[str, str]:
"""(digits, base) for an entry token — honours a 0x/0b prefix, else the session mode."""
low = word.lower()
if low.startswith("0x"):
return low[2:], "hex"
if low.startswith("0b"):
return low[2:], "bin"
return low, getattr(self._session, "entry_mode", "hex")
def _opcode_matches(self, word: str, catalog):
# matching raw hex/binary input to a command by opcode. Accepting inserts the RAW BYTE
# (rendered in the entry base), not the command name — you're building a raw byte string.
digits, base = self._numeric(word)
if not digits:
return
if base == "hex":
valid, match, value = _HEXDIGITS, (lambda op: f"{op:02x}"), (lambda op: f"{op:02X}")
else:
valid, match, value = _BINDIGITS, (lambda op: f"{op:08b}"), (lambda op: f"{op:08b}")
if any(ch not in valid for ch in digits):
return
prefix = word[:len(word) - len(digits)] # keep any 0x/0b the user typed
for name in catalog.names():
tc = catalog.get(name)
op = _opcode(tc)
if op is not None and match(op).startswith(digits):
yield Completion(prefix + value(op), start_position=-len(word),
display=f"{name} (0x{op:02X})",
display_meta=f"opcode 0x{op:02X}{tc.help}")
def _catalog_names(self, word: str):
catalog = catalog_for(self._session)
if catalog is None:
return
low = word.lower()
for name in catalog.names():
if name.lower().startswith(low):
yield Completion(name, start_position=-len(word),
display_meta=catalog.get(name).help)

112
pm3py/cli/rawcli/entry.py Normal file
View File

@@ -0,0 +1,112 @@
"""Numeric entry: per-byte base-aware input for raw hex/binary, plus the prompt_toolkit key
bindings that drive it.
Raw byte entry mixes bases **per byte** — each byte remembers the base it was typed in, so
``30`` (hex) and ``00000100`` (binary) can sit on one line as ``30 00000100`` and send ``0x30
0x04``. The display stays clean (no ``0x``/``0b`` clutter); the base of each byte is tracked
alongside the text in ``session.byte_bases``. A byte auto-seals at its base width (2 hex / 8
binary) and the next digit starts a fresh byte in the current entry mode; toggling the mode
(Ctrl-/) only changes the base of the *next* byte — it never rewrites what you've typed.
The pure ``type_digit`` / ``group_size`` / ``is_byte_line`` functions carry the logic and are
unit-tested; the key bindings are thin wiring exercised in the live REPL.
"""
from __future__ import annotations
HEX = "hex"
BIN = "bin"
_DIGITS = {HEX: set("0123456789abcdefABCDEF"), BIN: set("01")}
def group_size(mode: str) -> int:
"""Characters per byte group: 2 for hex, 8 for binary."""
return 2 if mode == HEX else 8
def is_byte_line(text: str) -> bool:
"""Whether ``text`` (spaces ignored) is a pure hex/binary byte run — i.e. raw entry, not a
command word or function call. Empty is a byte line (you're about to type bytes)."""
return all(ch in _DIGITS[HEX] for ch in text.replace(" ", ""))
def type_digit(text: str, bases: list[str], mode: str, digit: str) -> tuple[str, list[str]]:
"""Apply one digit keystroke to a raw-byte line, base-aware. Returns ``(new_text, new_bases)``.
A byte seals at its base's width (2 hex / 8 binary); once full, the next digit starts a new
byte in ``mode``. A digit that isn't valid for the byte it would land in is dropped (no
change), so binary bytes can't gain hex digits and vice-versa."""
d = digit.lower()
groups = text.split(" ") if text else []
aligned = len(bases) == len(groups)
# extend the trailing byte if it's still open (present, aligned, below its base width)
if groups and aligned and groups[-1] and len(groups[-1]) < group_size(bases[-1]):
b = bases[-1]
if d in _DIGITS[b]:
groups[-1] += digit
return " ".join(groups), list(bases)
return text, list(bases) # wrong base for this byte — drop
# otherwise begin a new byte in the current entry mode
if d in _DIGITS[mode]:
return (" ".join(groups + [digit]) if groups else digit), list(bases) + [mode]
return text, list(bases)
def reconcile_bases(text: str, bases: list[str]) -> list[str]:
"""Trim ``bases`` to the number of byte groups left in ``text`` (after a backspace/edit). Only
a best-effort end-alignment — enough for append + backspace, which is what raw entry does."""
groups = [g for g in text.split(" ") if g]
return list(bases[:len(groups)])
#: keys that toggle entry mode. Ctrl-/ is emitted as Ctrl-_ (0x1F) by most terminals; c-t is an
#: always-available fallback that power users can rely on regardless of terminal.
TOGGLE_KEYS = ("c-_", "c-t")
def _bind(kb, key, handler) -> None:
"""Add a binding, ignoring key names a given prompt_toolkit build doesn't accept."""
try:
kb.add(key)(handler)
except ValueError:
pass
def install_key_bindings(kb, session) -> None:
"""Wire the entry-mode toggle (Ctrl-/ via Ctrl-_, plus Ctrl-t) and per-byte base-aware digit
entry onto ``kb``. ``session`` is the :class:`RawSession` whose ``entry_mode`` is toggled/read
and whose ``byte_bases`` records each raw byte's base. Never raises on an unknown key name."""
def _toggle(event):
session.toggle_entry_mode() # only flips the mode — the next byte you type uses it
event.app.invalidate() # redraw the breadcrumb
for key in TOGGLE_KEYS:
_bind(kb, key, _toggle)
def _digit(event):
buf = event.current_buffer
d = event.data
text = buf.text
# only base-group a raw byte line; a command/function-call being typed inserts verbatim
if not is_byte_line(text.replace(" ", "") + d):
buf.insert_text(d)
return
new_text, session.byte_bases = type_digit(text, session.byte_bases, session.entry_mode, d)
# Apply the change as an append via insert_text, NOT `buf.document = …`: assigning the
# document resets prompt_toolkit's complete_state and never re-fires completion, which
# killed the live autocomplete menu during raw byte entry. type_digit only ever appends
# (optionally after an auto-space) or drops, so the delta is always a pure suffix.
suffix = new_text[len(text):]
if suffix:
buf.insert_text(suffix)
for ch in sorted(_DIGITS[HEX] | _DIGITS[BIN]):
_bind(kb, ch, _digit)
def _backspace(event):
buf = event.current_buffer
buf.delete_before_cursor()
session.byte_bases = reconcile_bases(buf.text, session.byte_bases)
for key in ("backspace", "c-h"):
_bind(kb, key, _backspace)

View File

@@ -0,0 +1,314 @@
"""Tag identification for rawcli.
``identify`` doesn't just select — it *probes*, and every raw exchange it uses to figure the tag
out is streamed to the trace (reconstructed 14a activation + a real GET_VERSION). The GET_VERSION
response is reverse-mapped against the transponder models' own ``_version_bytes`` to name the
exact chip (NTAG216, not "MIFARE UL/NTAG"); the version's storage-size byte is what separates
NTAG213/215/216 and the Ultralight EV1 variants. The 14a scan uses NO_DISCONNECT, so the tag stays
selected for the follow-up probes — the persistent connection.
"""
from __future__ import annotations
import sys
from pm3py.trace import TraceFormatter, decode_15693
ISO14A_APPEND_CRC = 0x20 # pm3_cmd.h: append CRC to a raw 14a frame
# SAK → 14a family for tags without GET_VERSION (NXP AN10833 "MIFARE type identification
# procedure", Table 4). Used as the fallback when GET_VERSION isn't available/applicable.
_SAK_NAMES = {
0x00: "MIFARE Ultralight / NTAG", # -> GET_VERSION for the exact IC
0x04: "UID incomplete (cascade)",
0x08: "MIFARE Classic 1K",
0x09: "MIFARE Mini",
0x10: "MIFARE Plus 2K (SL2)",
0x11: "MIFARE Plus 4K (SL2)",
0x18: "MIFARE Classic 4K",
0x20: "ISO14443-4 (DESFire / Plus SL3 / …)",
0x28: "MIFARE Classic 1K + NFC-P2P",
0x38: "MIFARE Classic 4K + NFC-P2P",
}
# GET_VERSION byte-2 lower nibble → product family (AN10833 Table 1). Upper nibble is the HW
# type (0x0 native, 0x8 implementation, 0x9 Java Card applet, 0xA MIFARE 2GO / DUOX / X DNA).
_GETVER_PRODUCT = {
0x1: "MIFARE DESFire", 0x2: "MIFARE Plus", 0x3: "MIFARE Ultralight",
0x4: "NTAG", 0x7: "NTAG I2C", 0x8: "MIFARE DESFire Light",
}
# GET_VERSION 8-byte response → exact model. Mirrors the transponder models' `_version_bytes`
# (kept static here to avoid an import-order circular; a test asserts they stay in sync).
_VERSION_MODELS = {
"0004040101000b03": "NTAG210",
"0004040101000e03": "NTAG212",
"0004040201000f03": "NTAG213",
"0004040201001103": "NTAG215",
"0004040201001303": "NTAG216",
"0004030101000b03": "MIFARE Ultralight EV1 (MF0UL11)",
"0004030101000e03": "MIFARE Ultralight EV1 (MF0UL21)",
"0004040502021303": "NTAG I2C plus 1K (NT3H2111)",
"0004040502021503": "NTAG I2C plus 2K (NT3H2211)",
}
def _storage_range(byte: int) -> str:
"""GET_VERSION storage-size byte → memory-size string. The 7 MSBs code 2^n; the LSB set
means the size is *between* 2^n and 2^(n+1) (AN10833)."""
lo = 1 << (byte >> 1)
return f"{lo}{lo * 2} B" if byte & 1 else f"{lo} B"
def decode_version(version: bytes) -> str | None:
"""Name the chip from an 8-byte GET_VERSION response: exact match against the known models,
else a structural best-effort (product family + memory-size range, AN10833)."""
exact = _VERSION_MODELS.get(bytes(version[:8]).hex())
if exact:
return exact
if len(version) >= 7 and version[1] == 0x04: # NXP vendor
family = _GETVER_PRODUCT.get(version[2] & 0x0F, f"NXP type {version[2]:#04x}")
return f"{family} ({_storage_range(version[6])})"
return None
def name_14a(scan: dict) -> str:
sak = scan.get("sak")
if isinstance(sak, int):
return _SAK_NAMES.get(sak, f"ISO14443-A (SAK {sak:02X})")
return "ISO14443-A"
def _iso15_uid(dev) -> str | None:
"""Get a 15693 tag's UID robustly. The anticollision inventory misses often on vicinity tags,
and this link also returns the odd garbage frame — so retry both the inventory and a direct
GET_SYSTEM_INFO (0x2B, which carries the UID), validating each response. Returns display-order
UID hex, or None."""
for _ in range(3):
scan = _try(lambda: dev.hf.iso15.scan(), {"found": False})
uid = scan.get("uid")
if scan.get("found") and isinstance(uid, str) and uid.lower().startswith("e0"):
return uid
for _ in range(4): # GET_SYSTEM_INFO: [flags][info][UID(8)]..
resp = _try(lambda: dev.hf.iso15.raw(b"\x02\x2b"), None)
raw = resp.get("raw") if isinstance(resp, dict) else None
if raw and len(raw) >= 10 and raw[0] == 0x00 and raw[9] == 0xE0: # UID LSB-first ends in E0
return bytes(raw[2:10])[::-1].hex()
return None
def name_iso15(dev, uid15: str) -> str:
"""Name a 15693 tag from its UID + probes. ``E0 04`` = NXP ICODE. NTAG5 shares ``E0 04 01`` with
ICODE DNA, so it is told apart by a functional probe (see below); SLIX2 is distinguished from
plain SLIX by READ_SIGNATURE (0xBD). Non-NXP falls back to generic ISO15693."""
if not uid15.lower().startswith("e004"):
return "ISO15693"
# NTAG5 / DNA / SLIX2 all share E0 04 and answer READ_SIGNATURE, so split them with READ_CONFIG
# (0xC0), addressed + retried (these tags are finicky). Session register 0xA0 (STATUS_REG)
# succeeds only on NTAG5 (its I2C-side registers). DNA has no session registers (0xA0 errors)
# but does have config memory (block 0x00 succeeds); SLIX2 has no READ_CONFIG command at all.
# Hardware-verified: NTAG5 (E0 04 01 58) 0xA0 -> 00 01400000; DNA (E0 04 01 18) 0xA0 -> 01 0F,
# 0x00 -> 00 ...
try:
uid_wire = bytes.fromhex(uid15)[::-1] # UID LSByte-first for addressing
def _read_config(block: int):
frame = bytes([0x22, 0xC0, 0x04]) + uid_wire + bytes([block, 0x00])
for _ in range(6):
r = _try(lambda: dev.hf.iso15.raw(frame), None)
raw = r.get("raw") if isinstance(r, dict) else None
if raw and raw[0] in (0x00, 0x01): # a real ok/error response (not a miss)
return raw
return None
a0 = _read_config(0xA0)
if a0 and a0[0] == 0x00 and len(a0) >= 5: # valid STATUS_REG -> NTAG5
return "NTAG 5"
if a0 and a0[0] == 0x01: # 0xA0 errored -> not NTAG5; DNA has
cfg0 = _read_config(0x00) # config memory, SLIX2 has no 0xC0
if cfg0 and cfg0[0] == 0x00 and len(cfg0) >= 5:
return "ICODE DNA"
except ValueError:
pass
# READ_SIGNATURE (0xBD): SLIX2 returns a 32-byte ECC signature (response >= 33 bytes with the
# flags byte); plain SLIX ignores the command. Require the full length so a flaky/stale frame
# can't false-positive; probe twice in case the real signature frame is dropped.
for _ in range(2):
sig = _try(lambda: dev.hf.iso15.raw(b"\x02\xbd\x04"), None)
raw = sig.get("raw") if isinstance(sig, dict) else None
if raw is not None and len(raw) >= 33 and raw[0] == 0x00:
return "ICODE SLIX2"
return "ICODE SLIX"
def _ident_decode(direction: int, payload: bytes) -> str | None:
"""Annotator for the identify probe frames (activation + GET_VERSION)."""
if not payload:
return None
if direction == 0: # reader → tag
head = payload[:2]
if payload[:1] == b"\x52":
return "WUPA"
if payload[:1] == b"\x26":
return "REQA"
if payload[0] in (0x93, 0x95):
cl = 1 if payload[0] == 0x93 else 2
if len(payload) >= 2 and payload[1] == 0x20:
return f"ANTICOLLISION CL{cl}"
if len(payload) >= 2 and payload[1] == 0x70:
return f"SELECT CL{cl}"
if payload[0] == 0x60:
return "GET_VERSION"
if payload[0] == 0x50:
return "HALT"
else: # tag → reader
if len(payload) >= 8 and payload[0] == 0x00 and payload[1] == 0x04:
return "VERSION"
return None
def _activation_frames(scan: dict) -> list[tuple[int, bytes]]:
"""Reconstruct the ISO 14443-3A activation (WUPA/ATQA/anticollision/SELECT/SAK) from the scan
result, so the user sees the select the reader performed."""
try:
uid = bytes.fromhex(scan.get("uid") or "")
atqa = bytes.fromhex(scan.get("atqa") or "")
except ValueError:
return []
sak = scan.get("sak", 0)
frames: list[tuple[int, bytes]] = [(0, b"\x52"), (1, atqa)]
if len(uid) == 4:
bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
frames += [(0, b"\x93\x20"), (1, uid + bytes([bcc])),
(0, b"\x93\x70" + uid + bytes([bcc])), (1, bytes([sak]))]
elif len(uid) == 7:
ct = 0x88
b1 = ct ^ uid[0] ^ uid[1] ^ uid[2]
b2 = uid[3] ^ uid[4] ^ uid[5] ^ uid[6]
frames += [(0, b"\x93\x20"), (1, bytes([ct]) + uid[0:3] + bytes([b1])),
(0, b"\x93\x70" + bytes([ct]) + uid[0:3] + bytes([b1])), (1, b"\x04"),
(0, b"\x95\x20"), (1, uid[3:7] + bytes([b2])),
(0, b"\x95\x70" + uid[3:7] + bytes([b2])), (1, bytes([sak]))]
return frames
def _try(fn, default):
try:
return fn()
except Exception:
return default
def identify(session, emit=None) -> dict:
"""Probe the field and update ``session`` (field / protocol / transponder). ``emit`` receives
each annotated trace line (default: no trace). Returns a summary dict."""
emit = emit or (lambda _line: None)
dev = session.device
# clear the previous result up front — a new identify must never show stale field/transponder
session.field = None
session.transponder = None
session.protocol = "hf14a"
if dev is None:
return {"found": False, "error": "no device connected"}
# ISO 14443-A — retry: the scan can miss before it selects on a flaky link, and a miss must
# not fall through to a bogus 15693/LF hit.
scan = {"found": False}
for _ in range(3):
scan = _try(lambda: dev.hf.iso14a.scan(), {"found": False})
if scan.get("found"):
break
if scan.get("found"):
session.field, session.protocol = "hf", "hf14a"
# always emit ANSI; the app's printer renders it (and strips it off a non-terminal)
fmt = TraceFormatter(mode="reader", decoder=_ident_decode, crc_len=0, is_tty=True)
for direction, data in _activation_frames(scan):
emit(fmt.format(direction, data).strip("\n"))
model, version = None, None
if scan.get("sak") == 0x00: # UL/NTAG family — ask GET_VERSION
model, version = _probe_version(dev, fmt, emit)
session.transponder = model or name_14a(scan)
return {"found": True, "field": "hf", "protocol": "14a",
"transponder": session.transponder, "uid": scan.get("uid"),
"version": version.hex() if version else None}
# ISO 15693 — require a real E0-prefixed UID (inventory, or a direct GET_SYSTEM_INFO fallback
# for flaky vicinity anticollision), else a 14a miss reads as a bogus tag.
uid15 = _iso15_uid(dev)
if isinstance(uid15, str) and uid15.lower().startswith("e0"):
session.field, session.protocol = "hf", "hf15"
session.transponder = name_iso15(dev, uid15)
return {"found": True, "field": "hf", "protocol": "15693",
"transponder": session.transponder, "uid": uid15}
# LF: no HF tag, so demodulate the field. pm3py.lf captures the envelope and decodes it —
# what the tag emits (EM4100 ID, …) and, via a block-0 read, whether it's actually a T5577
# and what it's programmed to emulate. This is the real DSP path; the firmware only hands
# back raw samples, so there's no shortcut (lf.search() can't demodulate).
lf = _identify_lf(dev, session, emit)
if lf:
return lf
return {"found": False}
def _identify_lf(dev, session, emit):
"""Adapter onto :func:`pm3py.lf.identify_lf` — run the LF demod and fold its result into the
session + the rawcli result shape. ``label`` is the breadcrumb name (``T5577 (EM4100 /
EM4102)`` for an emulator, ``EM4100 <id>`` for a bare credential)."""
from pm3py.lf import identify_lf as _lf_identify
lf = _try(lambda: _lf_identify(dev, emit), None)
if not lf:
return None
session.field, session.protocol = "lf", "lf"
session.transponder = lf["label"]
return {"found": True, "field": "lf", "protocol": "lf",
"transponder": lf["label"], "config": lf.get("config"),
"id": (lf.get("emitted") or {}).get("id_hex")}
def _probe_version(dev, fmt, emit) -> tuple[str | None, bytes | None]:
"""Send a real GET_VERSION (0x60 +CRC), trace the exchange, decode the exact model. Retried:
a single shot can be lost on a flaky NG link, and a miss drops us to the generic SAK name —
which loses the exact-model memory map (page roles, completion). Re-selects between attempts to
recover a desynced link."""
cmd = b"\x60"
emit(fmt.format(0, cmd).strip("\n"))
for attempt in range(3):
resp = _try(lambda: dev.hf.iso14a.raw(cmd, flags=ISO14A_APPEND_CRC), None)
raw = resp.get("raw") if isinstance(resp, dict) else None
if raw and len(raw) >= 8:
version = bytes(raw[:8])
model = decode_version(version)
if model:
emit(fmt.format(1, bytes(raw)).strip("\n")) # raw() already trims to the RX length
emit(f"{model}")
return model, version
if attempt < 2:
_try(lambda: dev.hf.iso14a.scan(), None) # re-select, then retry the version
return None, None
def clear(session) -> None:
"""Forget the identified tag (drop the logical connection); the device stays open."""
session.field = None
session.transponder = None
session.protocol = "hf14a"
dev = session.device
if dev is not None:
_try(lambda: dev.hf.dropfield(), None)
def format_summary(result: dict) -> str:
if not result.get("found"):
return f"no tag found{': ' + result['error'] if result.get('error') else ''}"
if result.get("uid"):
uid = result["uid"]
detail = f"UID {uid.hex().upper() if isinstance(uid, (bytes, bytearray)) else uid}"
elif result.get("config"):
detail = f"config 0x{result['config']}"
elif result.get("id"):
detail = f"id {result['id']}"
else:
detail = ""
head = f"{result['transponder']} ({result['field'].upper()} / {result['protocol']})"
return f"{head} {detail}".rstrip()

201
pm3py/cli/rawcli/memory.py Normal file
View File

@@ -0,0 +1,201 @@
"""Per-transponder memory maps.
The point of rawcli isn't just to send bytes — it's to tell you what they *mean* on the tag in
front of you. ``page_role`` maps a page/block number to its role on the identified transponder
(user memory, CC, AUTH0, PWD, lock bytes, …), and ``landmark_pages`` lists the notable pages so the
completion menu can surface the memory map as you type. All of this shows up in the completion
dropdown — never in the bottom bar or command output.
Layouts mirror the transponder models' page attributes (``_cfg0_page`` etc.); kept static to
avoid an import-order circular, with a test guarding against drift.
"""
from __future__ import annotations
# Page layout per NTAG21x / Ultralight EV1 IC (user range inclusive; keys are identify's names).
# The static Type-2 header (pages 0-3) and PWD/PACK/config structure are shared; the family only
# differs in page 3 (CC on NTAG, OTP on Ultralight), the ASCII mirror (NTAG only), and the CFG1
# NFC-counter bits (NTAG 213/215/216 only). "lock" is the *dynamic* lock page (absent on the
# smallest parts); "cnt" flags the CFG1 NFC-counter bits.
_LAYOUTS = {
"NTAG210": {"user": (0x04, 0x0F), "cfg0": 0x10, "cfg1": 0x11, "pwd": 0x12, "pack": 0x13, "fam": "ntag"},
"NTAG212": {"user": (0x04, 0x23), "lock": 0x24, "cfg0": 0x25, "cfg1": 0x26, "pwd": 0x27, "pack": 0x28, "fam": "ntag"},
"NTAG213": {"user": (0x04, 0x27), "lock": 0x28, "cfg0": 0x29, "cfg1": 0x2A, "pwd": 0x2B, "pack": 0x2C, "fam": "ntag", "cnt": True},
"NTAG215": {"user": (0x04, 0x81), "lock": 0x82, "cfg0": 0x83, "cfg1": 0x84, "pwd": 0x85, "pack": 0x86, "fam": "ntag", "cnt": True},
"NTAG216": {"user": (0x04, 0xE1), "lock": 0xE2, "cfg0": 0xE3, "cfg1": 0xE4, "pwd": 0xE5, "pack": 0xE6, "fam": "ntag", "cnt": True},
"MIFARE Ultralight EV1 (MF0UL11)": {"user": (0x04, 0x0F), "cfg0": 0x10, "cfg1": 0x11, "pwd": 0x12, "pack": 0x13, "fam": "ul"},
"MIFARE Ultralight EV1 (MF0UL21)": {"user": (0x04, 0x23), "lock": 0x24, "cfg0": 0x25, "cfg1": 0x26, "pwd": 0x27, "pack": 0x28, "fam": "ul"},
}
# The pages guaranteed on every Type 2 tag (down to the smallest, NTAG210): the static header and
# user memory 0x04-0x0F. Used when only the NTAG/Ultralight *family* is known — GET_VERSION couldn't
# complete on a flaky link, or an original Ultralight has no GET_VERSION. Config pages differ by
# model, so they're left unnamed rather than guessed; page 3 could be CC or OTP.
_GENERIC_TYPE2 = {"user": (0x04, 0x0F)}
def _resolve_layout(transponder: str | None) -> dict | None:
"""The page layout for ``transponder`` — the exact per-IC map when identify named the model,
else the generic Type 2 map when only the NTAG / Ultralight family is known, else None."""
if not transponder:
return None
layout = _LAYOUTS.get(transponder)
if layout is not None:
return layout
up = transponder.upper()
if "NTAG" in up or "ULTRALIGHT" in up:
return _GENERIC_TYPE2
return None
def _page3_role(layout: dict) -> str:
fam = layout.get("fam")
if fam == "ul":
return "OTP (one-time programmable)"
if fam == "ntag":
return "Capability Container (CC)"
return "Capability Container (CC) / OTP" # generic: family unknown
def _cfg0_role(layout: dict) -> str:
# CFG0: AUTH0 (first protected page) + strong-modulation; NTAG adds the ASCII mirror
if layout.get("fam") == "ntag":
return "CFG0 — AUTH0 (first protected page), MIRROR (mode/page/byte), STRG_MOD_EN"
return "CFG0 — AUTH0 (first protected page), STRG_MOD_EN"
def _cfg1_role(layout: dict) -> str:
# CFG1 ACCESS byte: read/write protection, config lock, auth-retry limit; NTAG counters add
# the NFC-counter enable/protect bits
bits = "PROT, CFGLCK, " + ("NFC_CNT_EN, NFC_CNT_PWD_PROT, " if layout.get("cnt") else "") + "AUTHLIM"
return f"CFG1 — ACCESS ({bits})"
# ---- MIFARE Classic block map --------------------------------------------------------------
# 1K/Mini: 4-block sectors throughout. 4K: sectors 0-31 are 4 blocks, sectors 32-39 are 16 blocks.
# The sector trailer (last block of each sector) holds Key A / access bits+GPB / Key B; block 0 is
# the manufacturer block. Everything else is a data block.
_MFC_MAX_BLOCK = {"mini": 19, "1k": 63, "4k": 255}
def _mfc_size(transponder: str | None) -> str | None:
"""'mini' / '1k' / '4k' for a MIFARE Classic name, else None."""
if not transponder:
return None
up = transponder.upper()
if "MINI" in up and "MIFARE" in up:
return "mini"
if "CLASSIC" in up:
return "4k" if "4K" in up else "1k"
return None
def _mfc_sector(size: str, block: int) -> tuple[int, bool]:
"""(sector number, is-trailer) for ``block`` on a Classic of ``size``."""
if size == "4k" and block >= 128: # the eight 16-block sectors 32-39
sector = 32 + (block - 128) // 16
return sector, (block - 128) % 16 == 15
sector = block // 4
return sector, block % 4 == 3
def _mfc_block_role(transponder: str | None, block: int | None) -> str | None:
size = _mfc_size(transponder)
if size is None or block is None or not (0 <= block <= _MFC_MAX_BLOCK[size]):
return None
if block == 0:
return "manufacturer block — UID, BCC, SAK, ATQA, vendor data"
sector, is_trailer = _mfc_sector(size, block)
if is_trailer:
return f"sector {sector} trailer — Key A [0-5], access bits [6-8] + GPB [9], Key B [10-15]"
return f"data block (sector {sector})"
def _mfc_landmarks(transponder: str | None) -> list[tuple[int, str]]:
"""Block 0 + every sector trailer — the Classic memory-map skeleton (data blocks are uniform)."""
size = _mfc_size(transponder)
if size is None:
return []
blocks = [0] + [b for b in range(1, _MFC_MAX_BLOCK[size] + 1)
if _mfc_sector(size, b)[1]]
return [(b, _mfc_block_role(transponder, b)) for b in blocks]
def _t5577_block_role(block: int | None) -> str | None:
"""Role of a T5577 block: 0 = config, 7 = password, 1-6 = data (the emulated tag content)."""
if block is None:
return None
if block == 0:
return "config block (modulation / bit-rate / block count)"
if block == 7:
return "password block (32-bit)"
if 1 <= block <= 6:
return "data block (emulated tag content)"
return None
def _is_iso15(transponder: str | None) -> bool:
up = (transponder or "").upper()
return "SLIX" in up or "ICODE" in up
def _iso15_block_role(block: int | None) -> str | None:
"""ISO 15693 / ICODE blocks are uniform 4-byte user-memory blocks; AFI / DSFID / EAS / config
live behind commands, not block addresses."""
if block is None or not (0 <= block <= 255):
return None
return "user memory (4-byte block)"
def page_role(transponder: str | None, page: int | None) -> str | None:
"""The role of ``page``/``block`` on ``transponder`` — the full Type 2 header (UID / static
lock / CC-or-OTP), user memory, dynamic lock, CFG0/CFG1 (family-accurate fields), PWD, PACK —
or a T5577 block role, or an ISO15693 block, or None when unknown."""
if transponder and "T5577" in transponder.upper():
return _t5577_block_role(page)
if _mfc_size(transponder):
return _mfc_block_role(transponder, page)
if _is_iso15(transponder):
return _iso15_block_role(page)
layout = _resolve_layout(transponder)
if layout is None or page is None:
return None
if 0 <= page <= 1:
return "UID / serial number"
if page == 2:
return "static lock bytes + internal (locks pages 03-0F)"
if page == 3:
return _page3_role(layout)
lo, hi = layout["user"]
if lo <= page <= hi:
return "user memory"
if page == layout.get("lock"):
return "dynamic lock bytes"
if page == layout.get("cfg0"):
return _cfg0_role(layout)
if page == layout.get("cfg1"):
return _cfg1_role(layout)
if page == layout.get("pwd"):
return "PWD (32-bit password)"
if page == layout.get("pack"):
return "PACK (password acknowledge) + RFUI"
return None
def landmark_pages(transponder: str | None) -> list[tuple[int, str]]:
"""Notable pages/blocks with their datasheet role, for the completion menu's memory map. Every
named region of the identified tag — UID, static lock, CC/OTP, user, dynamic lock, CFG0/CFG1,
PWD, PACK. Ordered by page; empty when the tag has no known layout."""
if transponder and "T5577" in transponder.upper():
return [(b, _t5577_block_role(b)) for b in range(8)]
if _mfc_size(transponder):
return _mfc_landmarks(transponder)
if _is_iso15(transponder):
return [(0, "user memory (4-byte block)")] # uniform blocks; AFI/DSFID/EAS via commands
layout = _resolve_layout(transponder)
if layout is None:
return []
pages = {0, 2, 3, layout["user"][0]}
for key in ("lock", "cfg0", "cfg1", "pwd", "pack"):
if key in layout:
pages.add(layout[key])
return [(p, page_role(transponder, p)) for p in sorted(pages) if page_role(transponder, p)]

View File

@@ -0,0 +1,99 @@
"""rawcli line parsing.
Classifies an input line into a **control** command (help/identify/…), a **function-style call**
(``READ(4)``), or a **raw** byte payload, and parses loose hex/binary — intermixed ``0x``/``0b``
tokens, whitespace-insensitive, defaulting to the session's current entry mode when a token has no
prefix.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
HEX = "hex"
BIN = "bin"
#: single-word control verbs the REPL handles directly
CONTROL_VERBS = {"help", "identify", "transponder", "close", "tlv", "quit", "exit", "q"}
_CALL_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)\s*$", re.DOTALL)
@dataclass
class Command:
"""A parsed rawcli line. ``kind`` is ``"control"``, ``"call"`` or ``"raw"``."""
kind: str
name: str = ""
args: list[str] = field(default_factory=list)
payload: bytes = b""
def parse_arg_int(s: str) -> int:
"""A function-style command's numeric argument (``READ(4)``, ``WRITE(40, …)``): **base 10 by
default**, with a ``0x`` / ``0b`` / ``0o`` prefix selecting the base. This is the opposite
default from raw byte entry (which is hex) — a page number reads naturally as decimal, so
``READ(40)`` is page 40 and ``READ(0x28)`` is the same page in hex."""
s = s.strip()
if s[:2].lower() in ("0x", "0b", "0o"):
return int(s, 0)
return int(s, 10)
def parse_token(token: str, default_mode: str = HEX) -> bytes:
"""Parse one hex/binary token to bytes. A ``0x``/``0b`` prefix picks the base; otherwise
``default_mode`` is used."""
text = token
mode = default_mode
low = text.lower()
if low.startswith("0x"):
mode, text = HEX, text[2:]
elif low.startswith("0b"):
mode, text = BIN, text[2:]
if text == "":
return b""
if mode == HEX:
if len(text) % 2:
raise ValueError(f"hex token {token!r} has an odd number of digits")
try:
return bytes.fromhex(text)
except ValueError:
raise ValueError(f"invalid hex token {token!r}") from None
# binary
if len(text) % 8:
raise ValueError(f"binary token {token!r} is not a whole number of bytes "
f"({len(text)} bits)")
try:
return bytes(int(text[i:i + 8], 2) for i in range(0, len(text), 8))
except ValueError:
raise ValueError(f"invalid binary token {token!r}") from None
def parse_bytes(text: str, default_mode: str = HEX, bases: list[str] | None = None) -> bytes:
"""Parse a whitespace-separated raw payload. Each token is parsed in its own base: an explicit
``0x``/``0b`` prefix wins, else ``bases[i]`` (the base that token was typed in, when known),
else ``default_mode``. A token that isn't a valid byte raises ``ValueError`` — so a line that
mixes a command with raw bytes (``READ 30``) errors here instead of transmitting."""
out = bytearray()
for i, tok in enumerate(text.split()):
mode = bases[i] if bases and i < len(bases) else default_mode
out += parse_token(tok, mode)
return bytes(out)
def _split_args(inside: str) -> list[str]:
return [a.strip() for a in inside.split(",")] if inside.strip() else []
def parse_line(line: str, default_mode: str = HEX, bases: list[str] | None = None) -> Command:
"""Classify and parse a full input line. ``bases`` gives the per-byte base for a raw line
(from the live entry tracker); a raw line that isn't pure bytes raises ``ValueError``."""
stripped = line.strip()
m = _CALL_RE.match(stripped)
if m:
return Command("call", name=m.group(1), args=_split_args(m.group(2)))
first = stripped.split(None, 1)
verb = first[0].lower() if first else ""
if verb in CONTROL_VERBS:
rest = first[1].split() if len(first) > 1 else []
return Command("control", name=verb, args=rest)
return Command("raw", payload=parse_bytes(stripped, default_mode, bases))

View File

@@ -0,0 +1,69 @@
"""rawcli session state — the connected device, the identified RF field and transponder, and
the current numeric entry mode. Kept deliberately small and free of any prompt_toolkit / device
detail so it stays unit-testable."""
from __future__ import annotations
from typing import Any
HEX = "hex"
BIN = "bin"
# ANSI palette aligned with pm3py/trace/format.py so the prompt reads as part of the same UI
_DIM = "\033[2m"
_CYAN = "\033[36m"
_YELLOW = "\033[33m"
_MAGENTA = "\033[35m"
_BGREEN = "\033[1;32m"
_BOLD = "\033[1m"
_RESET = "\033[0m"
class RawSession:
"""Mutable rawcli session state.
``device`` is the connected client (or ``None`` when offline). ``field`` is the identified RF
field (``"hf"``/``"lf"``) and ``transponder`` the identified tag's display name — both set by
``identify`` in a later phase. ``entry_mode`` is the numeric entry mode toggled by the user.
"""
def __init__(self, device: Any = None):
self.device = device
self.field: str | None = None
self.transponder: str | None = None
self.protocol: str = "hf14a" # wire protocol for raw exchanges (set by identify)
self.entry_mode: str = HEX
self.byte_bases: list[str] = [] # base of each raw byte typed on the current line
self.connected: bool = device is not None
@property
def entry_prefix(self) -> str:
return "0x" if self.entry_mode == HEX else "0b"
def toggle_entry_mode(self) -> str:
self.entry_mode = BIN if self.entry_mode == HEX else HEX
return self.entry_mode
def breadcrumb(self) -> str:
"""The segmented prompt ``[raw / <field?> / <transponder?> / 0x|0b]`` — field and
transponder segments appear only once identified."""
segments = ["raw"]
if self.field:
segments.append(self.field)
if self.transponder:
segments.append(self.transponder)
segments.append(self.entry_prefix)
return "[" + " / ".join(segments) + "]"
def colored_breadcrumb(self) -> str:
"""The breadcrumb with ANSI color (same palette as the trace): bold ``raw``, cyan field,
bold-green identified tag, and the entry mode in yellow (hex) or magenta (binary) so the
current base is obvious at a glance. Stripping the ANSI yields :meth:`breadcrumb`."""
segments = [f"{_BOLD}raw{_RESET}"]
if self.field:
segments.append(f"{_CYAN}{self.field}{_RESET}")
if self.transponder:
segments.append(f"{_BGREEN}{self.transponder}{_RESET}")
mode_color = _YELLOW if self.entry_mode == HEX else _MAGENTA
segments.append(f"{mode_color}{self.entry_prefix}{_RESET}")
sep = f"{_DIM} / {_RESET}"
return f"{_DIM}[{_RESET}" + sep.join(segments) + f"{_DIM}]{_RESET}"

99
pm3py/cli/rawcli/tlv.py Normal file
View File

@@ -0,0 +1,99 @@
"""TLV / NDEF handling for rawcli: parse a Type-2 TLV block into a structured, indented
multi-line breakdown, and provide a multi-line editor for composing long TLVs.
NDEF message values (tag 0x03) are annotated by reusing
:func:`pm3py.trace.ndef.decode_ndef_annotation`.
"""
from __future__ import annotations
from dataclasses import dataclass
from pm3py.trace.ndef import decode_ndef_annotation
_TLV_NAMES = {
0x00: "NULL", 0x01: "LOCK CONTROL", 0x02: "MEMORY CONTROL",
0x03: "NDEF MESSAGE", 0xFD: "PROPRIETARY", 0xFE: "TERMINATOR",
}
@dataclass
class TLV:
tag: int
length: int
value: bytes
def parse_tlv(data: bytes) -> list[TLV]:
"""Walk a Type-2 TLV block (NULL/LOCK/MEMORY/NDEF/PROPRIETARY/TERMINATOR), handling the
3-byte 0xFF extended length. Stops at the terminator or when the data runs out."""
out: list[TLV] = []
i, n = 0, len(data)
while i < n:
tag = data[i]
i += 1
if tag == 0x00: # NULL — no length/value
out.append(TLV(tag, 0, b""))
continue
if tag == 0xFE: # terminator
out.append(TLV(tag, 0, b""))
break
if i >= n:
break
length = data[i]
i += 1
if length == 0xFF: # 3-byte length
if i + 2 > n:
break
length = (data[i] << 8) | data[i + 1]
i += 2
value = data[i:i + length]
i += length
out.append(TLV(tag, length, value))
return out
def _ndef_tlv(value: bytes) -> bytes:
if len(value) < 0xFF:
return bytes([0x03, len(value)]) + value
return bytes([0x03, 0xFF, (len(value) >> 8) & 0xFF, len(value) & 0xFF]) + value
def _hex_rows(value: bytes, per_row: int = 16) -> list[str]:
return [" ".join(f"{b:02X}" for b in value[i:i + per_row])
for i in range(0, len(value), per_row)]
def format_tlv(data: bytes) -> str:
"""Structured, indented multi-line breakdown of a TLV block."""
tlvs = parse_tlv(data)
if not tlvs:
return f"(no TLV structure in {len(data)} bytes)"
lines = [f"TLV breakdown ({len(data)} bytes):"]
for t in tlvs:
name = _TLV_NAMES.get(t.tag, f"0x{t.tag:02X}")
if t.tag in (0x00, 0xFE):
lines.append(f" [{t.tag:02X}] {name}")
continue
lines.append(f" [{t.tag:02X}] {name} len={t.length}")
for row in _hex_rows(t.value):
lines.append(f" {row}")
if t.tag == 0x03 and t.value:
annotation = decode_ndef_annotation(_ndef_tlv(t.value))
if annotation:
lines.append(f"{annotation}")
return "\n".join(lines)
def prompt_tlv(session) -> bytes | None:
"""Open a multi-line editor to compose a TLV as hex, gracefully spanning lines. Returns the
parsed bytes, or None if cancelled. Interactive — exercised in the live REPL."""
from prompt_toolkit import prompt
from .parser import parse_bytes
try:
text = prompt("tlv> ", multiline=True) # Esc+Enter (or Meta+Enter) to submit
except (EOFError, KeyboardInterrupt):
return None
text = text.strip()
if not text:
return None
return parse_bytes(text, session.entry_mode)

View File

@@ -0,0 +1,31 @@
"""Render a raw reader↔tag exchange as annotated, Proxmark-style trace lines, reusing the
existing :class:`pm3py.trace.TraceFormatter` and the per-protocol decoders."""
from __future__ import annotations
from pm3py.trace import TraceFormatter, decode_14443a, decode_15693
#: protocol key -> (annotation decoder, CRC byte length)
_PROTOCOLS = {
"hf14a": (decode_14443a, 2),
"hf15": (decode_15693, 2),
"lf": (None, 0),
}
def _as_bytes(value) -> bytes:
"""Accept bytes or a hex string (which is what the reader `raw()` returns as `data`)."""
if isinstance(value, str):
return bytes.fromhex(value.replace(" ", ""))
return bytes(value)
def render_exchange(command, response, *, protocol: str = "hf14a", is_tty: bool | None = None) -> str:
"""Return the annotated trace for one exchange: a ``Reader → Tag`` line for ``command`` and,
if present, a ``Tag → Reader`` line for ``response``. Both accept bytes or a hex string.
``is_tty`` forces/suppresses ANSI."""
decoder, crc_len = _PROTOCOLS.get(protocol, (None, 0))
fmt = TraceFormatter(mode="reader", decoder=decoder, crc_len=crc_len, is_tty=is_tty)
lines = [fmt.format(0, _as_bytes(command)).strip("\n")]
if response:
lines.append(fmt.format(1, _as_bytes(response)).strip("\n"))
return "\n".join(lines)

View File

@@ -1,5 +1,6 @@
"""Main Proxmark3 client with nested API."""
import asyncio
import functools
import glob
import inspect
import logging
@@ -13,6 +14,9 @@ from .hf import HFCommands
from .hf_iso14a import HF14ACommands
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__)
@@ -25,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)
@@ -70,7 +76,9 @@ class Proxmark3:
self.hf = HFCommands(self._transport)
self.hf.iso14a = HF14ACommands(self._transport)
self.hf.iso15 = HF15Commands(self._transport)
self.hf.mfc = HFMFCommands(self._transport)
self.hf.mf = HFMFCommands(self._transport)
self.hf.mfu = HFMFUCommands(self._transport)
self.flasher = Flasher(self._transport)
self.firmware = FirmwareInfo()
async def connect(self) -> None:
@@ -111,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
@@ -155,19 +167,27 @@ class _SyncProxy:
def __getattr__(self, name):
attr = getattr(self._target, name)
if isinstance(attr, (HWCommands, LFCommands, HFCommands,
HF14ACommands, HF15Commands, HFMFCommands)):
HF14ACommands, HF15Commands, HFMFCommands,
HFMFUCommands)):
return _SyncProxy(attr, self._loop)
# Sub-modules attached dynamically (e.g. hf.iso14a, hf.mfc)
if hasattr(attr, '_t'):
return _SyncProxy(attr, self._loop)
if callable(attr) and inspect.iscoroutinefunction(attr):
# functools.wraps copies __doc__/__name__ AND sets __wrapped__, so
# inspect.signature() and help() report the real params (block, uid=None)
# instead of the wrapper's (*args, **kwargs).
@functools.wraps(attr)
def sync_wrapper(*args, **kwargs):
return self._loop.run_until_complete(attr(*args, **kwargs))
sync_wrapper.__name__ = name
sync_wrapper.__doc__ = attr.__doc__
return sync_wrapper
return attr
def __dir__(self):
# Surface the wrapped target's command methods and sub-modules so
# dir()/REPL tab-completion and help() discovery work through the proxy.
return sorted(set(object.__dir__(self)) | set(dir(self._target)))
def close(self):
"""Disconnect and close the event loop."""
self._loop.run_until_complete(self._target.disconnect())

353
pm3py/core/flash.py Normal file
View File

@@ -0,0 +1,353 @@
"""Pure-Python Proxmark3 firmware flasher.
Drives the whole flash flow over the existing USB-serial link — enter bootloader,
arm the write window, stream the image block by block, reset — with no external
`pm3-flash`/DFU/JTAG tooling.
Flashing uses the legacy fixed-size OLD frame (not the NG frames the rest of the
client speaks); the OLD-frame plumbing lives in transport.py. See
docs/plans for the protocol map.
Safety: fullimage-only by default. The bootrom region ([0x100000, 0x102000)) is
never written unless ``allow_bootrom=True`` is passed explicitly — a botched
bootrom write bricks the device to JTAG/SAM-BA recovery, and fork updates only
ever change the OS image anyway.
"""
import os
import struct
import subprocess
from pathlib import Path
from .protocol import (
Cmd,
OLD_FRAME_SIZE,
DEVICE_INFO_FLAG_BOOTROM_PRESENT,
DEVICE_INFO_FLAG_OSIMAGE_PRESENT,
DEVICE_INFO_FLAG_CURRENT_MODE_BOOTROM,
DEVICE_INFO_FLAG_CURRENT_MODE_OS,
START_FLASH_MAGIC,
FLASH_START, BOOTLOADER_END, FLASH_END_256K, FLASH_END_512K,
FLASH_BLOCK_SIZE, BL_VERSION_1_0_0,
)
from .transport import PM3Transport, PM3Response, PM3Error
# ELF constants
_ELF_MAGIC = b"\x7fELF"
_ELFCLASS32 = 1
_ELFDATA2LSB = 1
_ET_EXEC = 2
_EM_ARM = 40
_PT_LOAD = 1
# Where to look for a locally-built image when none is given (dev checkout).
_DEFAULT_IMAGE_CANDIDATES = (
"firmware/armsrc/obj/fullimage.elf",
)
# Repo root in a source checkout (…/pm3py/core/flash.py → …/). Used to find the
# firmware submodule for --build; an installed wheel has no submodule here.
_PACKAGE_ROOT = Path(__file__).resolve().parents[2]
class FlashError(PM3Error):
"""Firmware flashing failed."""
def parse_elf_segments(image: bytes) -> list[tuple[int, bytes]]:
"""Extract loadable segments from a PM3 firmware ELF.
Returns a list of (physical_address, data) for every PT_LOAD program header
with a non-zero file size, sorted by address. Validates that the file is a
little-endian 32-bit ARM executable, matching the C flasher's gate.
"""
if image[:4] != _ELF_MAGIC:
raise FlashError("Not an ELF file (bad magic)")
if image[4] != _ELFCLASS32:
raise FlashError("Not a 32-bit ELF (ELFCLASS32 expected)")
if image[5] != _ELFDATA2LSB:
raise FlashError("Not a little-endian ELF (ELFDATA2LSB expected)")
e_type, e_machine = struct.unpack_from("<HH", image, 16)
if e_type != _ET_EXEC:
raise FlashError("ELF is not ET_EXEC")
if e_machine != _EM_ARM:
raise FlashError("ELF is not EM_ARM")
e_phoff = struct.unpack_from("<I", image, 28)[0]
e_phentsize, e_phnum = struct.unpack_from("<HH", image, 42)
segments: list[tuple[int, bytes]] = []
for i in range(e_phnum):
off = e_phoff + i * e_phentsize
(p_type, p_offset, p_vaddr, p_paddr,
p_filesz, p_memsz, p_flags, p_align) = struct.unpack_from("<IIIIIIII", image, off)
if p_type != _PT_LOAD or p_filesz == 0:
continue
segments.append((p_paddr, image[p_offset:p_offset + p_filesz]))
if not segments:
raise FlashError("No loadable (PT_LOAD) segments in ELF")
segments.sort()
return segments
def _align_down(x: int, a: int) -> int:
return x & ~(a - 1)
def _align_up(x: int, a: int) -> int:
return (x + a - 1) & ~(a - 1)
def build_blocks(segments: list[tuple[int, bytes]],
block_size: int = FLASH_BLOCK_SIZE) -> list[tuple[int, bytes]]:
"""Turn segments into a list of (address, block) writes.
Segments that fall in the same or overlapping flash blocks are merged into a
single buffer first, so a page shared by two adjacent segments is written
once with both segments' real data. Writing such a page twice would be a bug:
the SAM7 erase-programs each page atomically, so a second write of the shared
page (with 0xFF padding where the other segment's data lives) would wipe the
first segment's bytes. Gaps within a merged run and the aligned outer edges
are filled with 0xFF (erased flash).
"""
if not segments:
return []
segs = sorted(segments)
# Group consecutive segments whose block-aligned spans touch or overlap.
groups: list[list[tuple[int, bytes]]] = [[segs[0]]]
for paddr, data in segs[1:]:
g = groups[-1]
g_end = max(a + len(d) for a, d in g)
if paddr < _align_up(g_end, block_size):
g.append((paddr, data))
else:
groups.append([(paddr, data)])
blocks: list[tuple[int, bytes]] = []
for g in groups:
g_start = g[0][0]
g_end = max(a + len(d) for a, d in g)
astart = _align_down(g_start, block_size)
aend = _align_up(g_end, block_size)
blob = bytearray(b"\xFF" * (aend - astart))
for paddr, data in g:
off = paddr - astart
blob[off:off + len(data)] = data
for i in range(0, len(blob), block_size):
blocks.append((astart + i, bytes(blob[i:i + block_size])))
return blocks
def resolve_image(image=None) -> bytes:
"""Resolve a firmware image to raw ELF bytes.
Accepts raw bytes, a path, or ``None``. With ``None`` it searches for a
locally-built image: the ``PM3PY_FIRMWARE`` env var, then conventional build
output paths relative to the working directory. (Auto-download of the pinned
release is a separate, later layer that also feeds this function.)
"""
if isinstance(image, (bytes, bytearray)):
return bytes(image)
if image is not None:
p = Path(image)
if not p.exists():
raise FlashError(f"Firmware image not found: {p}")
return p.read_bytes()
candidates = []
env = os.environ.get("PM3PY_FIRMWARE")
if env:
candidates.append(Path(env))
candidates.extend(Path(c) for c in _DEFAULT_IMAGE_CANDIDATES)
for p in candidates:
if p.exists():
return p.read_bytes()
raise FlashError(
"No firmware image given and no local build found. Build the firmware "
"(make) and/or set PM3PY_FIRMWARE, or pass image=<path to fullimage.elf>."
)
def find_firmware_src(firmware_dir=None) -> Path | None:
"""Locate the firmware source tree (the proxmark3-pm3py submodule) to build.
Checks the explicit arg, then ``$PM3PY_FIRMWARE_SRC``, then the repo root
relative to this package. Returns None if no buildable tree is found — e.g. an
installed wheel carries no submodule. Deliberately does NOT search the current
working directory: build_firmware runs ``make`` on the result, and picking up a
stray ``./firmware`` would execute an untrusted Makefile.
"""
candidates = []
if firmware_dir:
candidates.append(Path(firmware_dir))
env = os.environ.get("PM3PY_FIRMWARE_SRC")
if env:
candidates.append(Path(env))
candidates.append(_PACKAGE_ROOT / "firmware")
for c in candidates:
if (c / "Makefile").exists() and (c / "armsrc" / "Makefile").exists():
return c
return None
def build_firmware(platform: str, firmware_dir=None) -> Path:
"""Cross-compile the fork's fullimage.elf for a PM3 platform and return its path.
``platform`` is the PM3 build platform and must be given explicitly
(``PM3GENERIC`` for the Easy/generic target, ``PM3RDV4`` for the RDV4, ...).
Never infer it from a connected device: the running firmware's ``is_rdv4``
flag reflects the firmware it was built for, not the physical board.
"""
if not platform or not platform.replace("_", "").isalnum():
raise FlashError(f"Invalid PLATFORM {platform!r} "
f"(expected e.g. PM3GENERIC or PM3RDV4)")
src = find_firmware_src(firmware_dir)
if src is None:
raise FlashError(
"Firmware source not found — --build needs a source checkout with the "
"firmware/ submodule (an installed wheel has none). Set "
"PM3PY_FIRMWARE_SRC, run from the repo, or use --image with a prebuilt "
"fullimage.elf.")
cmd = ["make", "-C", str(src), f"PLATFORM={platform}", "armsrc/all"]
try:
proc = subprocess.run(cmd) # inherits stdio so the build streams to the user
except OSError as e: # make missing / not executable
raise FlashError(f"could not run make: {e}")
if proc.returncode != 0:
raise FlashError(f"firmware build failed (make exited {proc.returncode})")
elf = src / "armsrc" / "obj" / "fullimage.elf"
if not elf.exists():
raise FlashError(f"build reported success but {elf} is missing")
return elf
class Flasher:
"""Flash Proxmark3 firmware over the wire (OLD-frame bootloader protocol)."""
def __init__(self, transport: PM3Transport):
self._t = transport
@staticmethod
def _is(resp: PM3Response, cmd: int) -> bool:
"""True if an OLD reply carries the given command (low 16 bits)."""
return (resp.cmd & 0xFFFF) == cmd
async def device_info(self) -> dict:
"""Probe device mode/capabilities via CMD_DEVICE_INFO."""
resp = await self._t.send_old(Cmd.DEVICE_INFO)
flags = resp.oldarg[0] if resp.oldarg else 0
return {
"flags": flags,
"in_bootrom": bool(flags & DEVICE_INFO_FLAG_CURRENT_MODE_BOOTROM),
"in_os": bool(flags & DEVICE_INFO_FLAG_CURRENT_MODE_OS),
"bootrom_present": bool(flags & DEVICE_INFO_FLAG_BOOTROM_PRESENT),
"osimage_present": bool(flags & DEVICE_INFO_FLAG_OSIMAGE_PRESENT),
}
async def bl_version(self) -> int:
"""Read the bootloader version code (packed BL_MAKE_VERSION)."""
resp = await self._t.send_old(Cmd.BL_VERSION)
return resp.oldarg[0] if resp.oldarg else 0
async def enter_bootloader(self, reopen: bool = True) -> dict:
"""Put the device into the bootloader, ready to accept flash writes.
If already in the bootrom, returns immediately. From the OS this issues
the automatic CMD_START_FLASH handover (no button press) and re-opens the
port after the device re-enumerates.
"""
info = await self.device_info()
if info["in_bootrom"]:
return info
if info["in_os"] and not info["bootrom_present"]:
raise FlashError(
"Device is in the OS but reports no bootrom — cannot auto-enter "
"the bootloader; hold the button while replugging to flash."
)
# Modern handover: CMD_START_FLASH with zero args -> reset into bootrom.
await self._t.send_old_no_response(Cmd.START_FLASH)
if reopen:
await self._t.reopen()
info = await self.device_info()
if not info["in_bootrom"]:
raise FlashError("Device did not enter the bootloader after handover")
return info
async def flash_image(self, image, *, allow_bootrom: bool = False,
on_progress=None) -> dict:
"""Write an image to flash. Device must already be in the bootloader.
Returns {'success', 'blocks', 'bytes', 'flash_end'}.
"""
raw = resolve_image(image)
segments = parse_elf_segments(raw)
# Safety gate: never touch the bootrom region unless explicitly allowed.
for paddr, data in segments:
if paddr < BOOTLOADER_END and not allow_bootrom:
raise FlashError(
f"Image writes the bootrom region (0x{paddr:08X}); refusing. "
f"Pass allow_bootrom=True only if you really mean to reflash "
f"the bootloader (brick risk)."
)
blocks = build_blocks(segments)
# Choose the write window. Only reach into the second bank (>256 KB) on a
# bootloader new enough to drive it.
blv = await self.bl_version()
flash_end = FLASH_END_512K if blv >= BL_VERSION_1_0_0 else FLASH_END_256K
low = FLASH_START if allow_bootrom else BOOTLOADER_END
magic = START_FLASH_MAGIC if allow_bootrom else 0
for addr, block in blocks:
if addr < low or addr + len(block) > flash_end:
raise FlashError(
f"Block at 0x{addr:08X} is outside the flash window "
f"[0x{low:08X}, 0x{flash_end:08X}); wrong image for this device?"
)
# Arm the write window.
resp = await self._t.send_old(Cmd.START_FLASH, low, flash_end, magic)
if not self._is(resp, Cmd.ACK):
raise FlashError(f"START_FLASH not acknowledged (reply cmd=0x{resp.cmd:X})")
written = 0
total = len(blocks)
for i, (addr, block) in enumerate(blocks):
resp = await self._t.send_old(Cmd.FINISH_WRITE, addr, 0, 0, block)
if self._is(resp, Cmd.NACK):
sr = resp.oldarg[0] if resp.oldarg else 0
raise FlashError(
f"Write failed at 0x{addr:08X} (EFC status 0x{sr:X})")
if not self._is(resp, Cmd.ACK):
raise FlashError(
f"Unexpected reply writing 0x{addr:08X} (cmd=0x{resp.cmd:X})")
written += len(block)
if on_progress:
on_progress({"block": i + 1, "total": total,
"addr": addr, "bytes": written})
return {"success": True, "blocks": total, "bytes": written,
"flash_end": flash_end}
async def reset(self) -> None:
"""Reboot the device (bootrom then jumps into the OS image)."""
await self._t.send_old_no_response(Cmd.HARDWARE_RESET)
async def flash(self, image=None, *, allow_bootrom: bool = False,
on_progress=None, reset: bool = True) -> dict:
"""End-to-end: enter bootloader, write the image, reboot.
``image`` may be raw ELF bytes, a path to a ``fullimage.elf``, or ``None``
to use a locally-built image (see resolve_image).
"""
raw = resolve_image(image)
await self.enter_bootloader()
result = await self.flash_image(raw, allow_bootrom=allow_bootrom,
on_progress=on_progress)
if reset:
await self.reset()
return result

View File

@@ -13,12 +13,21 @@ class HFCommands:
# Sub-modules attached by client.py
self.iso14a = None # type: ignore
self.iso15 = None # type: ignore
self.mfc = None # type: ignore
self.mf = None # MIFARE Classic # type: ignore
self.mfu = None # MIFARE Ultralight / NTAG # type: ignore
async def tune(self, on_progress: ProgressCallback = None) -> dict:
"""Measure HF antenna voltage."""
resp = await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_HF, timeout=10.0)
voltage = struct.unpack_from("<I", resp.data, 0)[0] if len(resp.data) >= 4 else 0
"""Measure HF antenna voltage.
CMD_MEASURE_ANTENNA_TUNING_HF is a START/MEASURE/STOP state machine that
requires a 1-byte mode arg (firmware rejects length!=1 with EINVARG):
mode 1 = START (spins up the HF FPGA), mode 2 = MEASURE (returns uint16 mV),
mode 3 = STOP. Voltage is a uint16_t, not uint32_t.
"""
await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_HF, bytes([1]), timeout=10.0) # START
resp = await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_HF, bytes([2]), timeout=10.0) # MEASURE
voltage = struct.unpack_from("<H", resp.data, 0)[0] if len(resp.data) >= 2 else 0
await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_HF, bytes([3]), timeout=10.0) # STOP
result = {
"voltage_mV": voltage,
"voltage_V": round(voltage / 1000.0, 3),
@@ -62,14 +71,30 @@ class HFCommands:
results["found"] = len(results) > 0
return results
async def sniff(self, samples: int = 0, skip: int = 0,
async def sniff(self, samples_to_skip: int = 0, triggers_to_skip: int = 0,
skip_mode: int = 0, skip_ratio: int = 0,
on_progress: ProgressCallback = None) -> dict:
"""Sniff HF traffic."""
payload = struct.pack("<II", samples, skip) if samples else b""
"""Sniff HF traffic.
Firmware reads a 10-byte PACKED struct
``{uint32 samplesToSkip; uint32 triggersToSkip; uint8 skipMode;
uint8 skipRatio}`` and, when the capture ends, replies with a
``{uint16 len}`` sample count. The struct must always be sent whole
(matching the C client's ``sizeof(params)``); under-sending leaves
the firmware reading uninitialised BigBuf bytes.
"""
payload = struct.pack("<IIBB", samples_to_skip, triggers_to_skip,
skip_mode, skip_ratio)
resp = await self._t.send_ng(Cmd.HF_SNIFF, payload, timeout=30.0)
return {"status": resp.status, "data": resp.data.hex()}
samples = struct.unpack_from("<H", resp.data, 0)[0] if len(resp.data) >= 2 else 0
return {"status": resp.status, "samples": samples}
async def dropfield(self) -> dict:
"""Turn off HF field."""
resp = await self._t.send_ng(Cmd.HF_DROPFIELD)
return {"status": resp.status}
"""Turn off HF field.
The firmware handler is a bare ``hf_field_off(); break;`` with no
``reply_ng``, so waiting for a response just blocks until timeout.
Fire-and-forget instead.
"""
await self._t.send_ng_no_response(Cmd.HF_DROPFIELD)
return {"status": 0}

View File

@@ -63,34 +63,72 @@ class HF14ACommands:
payload=data,
timeout=5.0,
)
# The firmware replies with its whole receive buffer; oldarg[0] is the number of bytes
# actually received (0 = the tag didn't answer). Trim to it so a no-response is empty,
# not a buffer of zeros.
received = resp.oldarg[0] if resp.oldarg else 0
payload = resp.data[:received] if resp.data else b""
return {
"status": resp.status,
"data": resp.data.hex() if resp.data else None,
"raw": resp.data,
"received": received,
"data": payload.hex() if payload else None,
"raw": payload,
}
async def sniff(self, param: int = 0) -> dict:
"""Sniff ISO14443A traffic."""
resp = await self._t.send_mix(
Cmd.HF_ISO14443A_SNIFF, arg0=param, timeout=30.0,
resp = await self._t.send_ng(
Cmd.HF_ISO14443A_SNIFF, payload=bytes([param]), timeout=30.0,
)
return {"status": resp.status}
async def sim(self, uid: bytes, sak: int = 0x08, atqa: bytes = b"\x04\x00",
flags: int = 0) -> dict:
"""Simulate ISO14443A card."""
async def sim(self, tagtype: int = 1, uid: bytes = b"", exit_after: int = 0,
flags: int = 0, rats: bytes = b"",
ulauth_1a1: bytes = b"", ulauth_1a2: bytes = b"",
ulauth_1a2_mirror: bool = False) -> dict:
"""Simulate an ISO14443A tag.
tagtype selects the emulated tag: 1=MIFARE Classic 1k, 2=Ultralight,
3=DESFire, 4=ISO14443-4, 5=Tnp3xxx, 6=MF Mini, 7=MFU EV1/NTAG215,
8=MFC 4k, 9=FM11RF005SH, 10=ST25TA, 11=JCOP, 12=Seos, 13=ULC,
14=ULAES, 15=MF Plus.
uid: 4, 7, or 10 raw bytes. Empty = take UID from emulator memory.
exit_after: stop after N reader reads (0 = infinite).
ulauth_1a1/ulauth_1a2: ULC (8 byte) / ULAES (16 byte) auth replies.
"""
# Fold the UID length into flags per FLAG_SET_UID_IN_DATA (pm3_cmd.h).
if uid:
if len(uid) == 4:
uid_flag = 0x0010
uid_flag = 0x0010 # FLAG_4B_UID_IN_DATA
elif len(uid) == 7:
uid_flag = 0x0020
uid_flag = 0x0020 # FLAG_7B_UID_IN_DATA
elif len(uid) == 10:
uid_flag = 0x0030
uid_flag = 0x0030 # FLAG_10B_UID_IN_DATA
else:
raise ValueError("UID must be 4, 7, or 10 bytes")
else:
uid_flag = 0x0000 # FLAG_UID_IN_EMUL
flag_val = flags | uid_flag | 0x0001 # FLAG_INTERACTIVE
payload = atqa + bytes([sak]) + uid
resp = await self._t.send_mix(
Cmd.HF_ISO14443A_SIMULATE, arg0=flag_val, timeout=60.0, payload=payload,
flag_val = (flags & ~0x0030) | uid_flag # FLAG_MASK_UID = 0x0030
# struct { u8 tagtype; u16 flags; u8 uid[10]; u8 exitAfter;
# u8 rats[20]; u8 ulauth_1a1_len; u8 ulauth_1a2_len;
# u8 ulauth_1a1[16]; u8 ulauth_1a2[16]; bool mirror; } PACKED
payload = struct.pack(
"<BH10sB20sBB16s16sB",
tagtype & 0xFF,
flag_val & 0xFFFF,
uid.ljust(10, b"\x00")[:10],
exit_after & 0xFF,
rats.ljust(20, b"\x00")[:20],
len(ulauth_1a1),
len(ulauth_1a2),
ulauth_1a1.ljust(16, b"\x00")[:16],
ulauth_1a2.ljust(16, b"\x00")[:16],
1 if ulauth_1a2_mirror else 0,
)
resp = await self._t.send_ng(
Cmd.HF_ISO14443A_SIMULATE, payload=payload, timeout=60.0,
)
return {"status": resp.status}

View File

@@ -1,6 +1,6 @@
"""ISO 15693 commands: hf.iso15.*"""
import struct
from .protocol import Cmd
from .protocol import Cmd, crc15, crc15_bytes
from .transport import PM3Transport, PM3Error
# Re-export trace symbols for backward compat with existing test imports
@@ -11,12 +11,57 @@ from ..trace import (
format_sniff_line,
)
# ISO15 PM3 flags
# ISO15 PM3 transport flags — values MUST match firmware include/iso15.h
ISO15_CONNECT = 0x01
ISO15_NO_DISCONNECT = 0x02
ISO15_RAW = 0x04
ISO15_APPEND_CRC = 0x08
ISO15_READ_RESPONSE = 0x10
ISO15_APPEND_CRC = 0x08 # legacy: ARM firmware ignores it — CRC is added host-side
ISO15_HIGH_SPEED = 0x10
ISO15_READ_RESPONSE = 0x20
ISO15_LONG_WAIT = 0x40
# Default transport flags for read/inventory (C client CmdHF15Info/getUID).
_READ_FLAGS = ISO15_CONNECT | ISO15_HIGH_SPEED | ISO15_READ_RESPONSE # 0x31
# Write needs the long (~22ms) tag-ack timeout and does not set HIGH_SPEED
# (mirrors the C client's hf_15_write_blk).
_WRITE_FLAGS = ISO15_CONNECT | ISO15_READ_RESPONSE | ISO15_LONG_WAIT # 0x61
# Command bytes that program the tag and so need the long tag-ack window (raw() auto-detects these
# from iso_cmd[1]): std write/lock, EAS writes, password/protect/destroy/privacy.
_WRITE_CMDS = frozenset({0x21, 0x22, 0x24, 0x27, 0x28, 0x29, 0x2A,
0xA2, 0xA3, 0xA4, 0xA6, 0xA7,
0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB9, 0xBA})
def _iso15_payload(iso_cmd: bytes, flags: int = _READ_FLAGS) -> bytes:
"""Build a HF_ISO15693_COMMAND payload from a raw ISO15693 command.
Appends the ISO15693 CRC host-side (the ARM firmware ignores ISO15_APPEND_CRC —
the client must add it, like the C client's AddCrc15) and prefixes the PM3 transport
flags. READ_RESPONSE (0x20) makes the firmware actually listen for the tag's answer;
without it the firmware passes a NULL rx buffer and replies empty.
"""
raw = iso_cmd + crc15_bytes(iso_cmd)
return struct.pack("<BH", flags, len(raw)) + raw
# ISO15693 request-flags byte (raw[0]) — the protocol flags, NOT the PM3 transport flags
REQ_DATARATE_HIGH = 0x02
REQ_INVENTORY = 0x04
REQINV_AFI = 0x10 # an AFI byte follows the INVENTORY command
REQINV_SLOT1 = 0x20 # set = 1 slot, clear = 16-slot anti-collision
def _parse_inv_record(rec: bytes) -> dict | None:
"""Parse one inventory response ``[resp_flags][dsfid][uid(8)][crc(2)]``.
Returns ``{'uid', 'dsfid', 'flags'}`` (uid as display-order hex), or None if
the record is too short or the CRC is bad (i.e. a collision/garbled slot).
"""
if len(rec) < 12 or crc15(rec[:-2]) != (rec[-2] | (rec[-1] << 8)):
return None
return {"uid": rec[2:10][::-1].hex(), "dsfid": rec[1], "flags": rec[0]}
class HF15Commands:
@@ -27,10 +72,13 @@ class HF15Commands:
async def scan(self) -> dict:
"""Scan for ISO15693 tags using inventory command."""
# ISO15693 inventory: flags=0x06 (high data rate + inventory), cmd=0x01
iso_cmd = bytes([0x06, 0x01, 0x00]) # flags, inventory cmd, mask_len=0
flags = ISO15_CONNECT | ISO15_READ_RESPONSE | ISO15_APPEND_CRC
payload = struct.pack("<BH", flags, len(iso_cmd)) + iso_cmd
# ISO15693 inventory: flags=0x26 (high data rate + inventory + 1-slot), cmd=0x01.
# SLOT1 (0x20) is required: without it the firmware runs a 16-slot anti-collision
# round and replies with an iso15_slot_result_t struct, not [flags][dsfid][uid],
# so this parser would read the struct header as a zeroed UID. 1-slot gives the
# simple single-tag response this method expects. Use hf 15 reader --all for many.
iso_cmd = bytes([0x26, 0x01, 0x00]) # flags, inventory cmd, mask_len=0
payload = _iso15_payload(iso_cmd)
resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=5.0)
if resp.status != 0 or len(resp.data) < 10:
@@ -47,19 +95,87 @@ class HF15Commands:
"response_flags": resp_flags,
}
async def raw_inventory(self, inv_flags: int = 0x26) -> dict:
"""Diagnostic: send a raw ISO15693 inventory with a caller-chosen request-flags
byte and return the raw device response (no parsing). Use to isolate 1-slot vs
16-slot behavior: inv_flags=0x26 → 1 slot (SLOT1 set), 0x06 → 16 slots.
"""
iso_cmd = bytes([inv_flags, 0x01, 0x00]) # inv flags, INVENTORY cmd, mask_len=0
payload = _iso15_payload(iso_cmd)
resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=5.0)
return {"inv_flags": hex(inv_flags), "status": resp.status,
"len": len(resp.data), "data": resp.data.hex()}
async def inventory(self, slots: int = 16, afi: int | None = None,
dsfid: int | None = None):
"""Perform an ISO15693 inventory (anti-collision).
slots=16 (default): one 16-slot anti-collision round → **list** of tag
dicts (empty list if none). Handles up to 16 non-colliding tags per
round; slots that collide are dropped (CRC fail).
slots=1: single-slot → **one** tag dict, or None if no/garbled response.
afi: request-level Application Family Identifier — only tags with this AFI
answer (sent in the inventory command).
dsfid: client-side filter — keep only tags whose DSFID equals this.
Each tag: ``{'uid': str, 'dsfid': int, 'flags': int[, 'slot': int]}``.
(`scan()` remains the quick "is one tag present" convenience.)
"""
req = REQ_DATARATE_HIGH | REQ_INVENTORY
if slots == 1:
req |= REQINV_SLOT1
if afi is not None:
req |= REQINV_AFI
iso_cmd = bytes([req, 0x01]) # request flags, INVENTORY command
if afi is not None:
iso_cmd += bytes([afi & 0xFF]) # AFI byte precedes the mask
iso_cmd += bytes([0x00]) # mask length = 0
resp = await self._t.send_ng(
Cmd.HF_ISO15693_COMMAND, _iso15_payload(iso_cmd), timeout=5.0)
def _keep(tag):
return tag is not None and (dsfid is None or tag["dsfid"] == dsfid)
if slots == 1:
if resp.status != 0:
return None
tag = _parse_inv_record(bytes(resp.data))
return tag if _keep(tag) else None
# 16-slot: iso15_inventory_response_t = [slot_count][16x{status,len}][data]
tags = []
data = bytes(resp.data)
if resp.status != 0 or len(data) < 1 + 16 * 2:
return tags
slot_count = data[0]
off = 1 + slot_count * 2 # concatenated slot data starts here
for s in range(slot_count):
status = data[1 + s * 2]
rlen = data[1 + s * 2 + 1]
rec = data[off:off + rlen]
off += rlen
if status != 1 or rlen == 0: # 0=empty, 2=collision
continue
tag = _parse_inv_record(rec)
if _keep(tag):
tag["slot"] = s
tags.append(tag)
return tags
async def rdbl(self, block: int, uid: str | None = None) -> dict:
"""Read a single block."""
# OPTION (0x40) makes the tag return the block-security (lock) byte this parser
# expects: addressed 0x22|0x40=0x62, unaddressed 0x02|0x40=0x42.
if uid:
uid_bytes = bytes.fromhex(uid)[::-1] # reverse for wire
iso_cmd = bytes([0x22, 0x20]) + uid_bytes + bytes([block])
iso_cmd = bytes([0x62, 0x20]) + uid_bytes + bytes([block])
else:
iso_cmd = bytes([0x02, 0x20, block]) # addressed without UID
iso_cmd = bytes([0x42, 0x20, block]) # unaddressed
flags = ISO15_CONNECT | ISO15_READ_RESPONSE | ISO15_APPEND_CRC
payload = struct.pack("<BH", flags, len(iso_cmd)) + iso_cmd
payload = _iso15_payload(iso_cmd)
resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=5.0)
if resp.status != 0 or len(resp.data) < 2:
if resp.status != 0 or len(resp.data) < 4:
return {"success": False, "error": resp.status}
resp_flags = resp.data[0]
@@ -67,7 +183,7 @@ class HF15Commands:
return {"success": False, "error_flag": resp_flags}
lock_status = resp.data[1]
block_data = resp.data[2:]
block_data = resp.data[2:-2] # strip the 2-byte ISO15693 CRC the firmware relays
return {
"success": True,
@@ -88,13 +204,30 @@ class HF15Commands:
else:
iso_cmd = bytes([0x02, 0x21, block]) + data
flags = ISO15_CONNECT | ISO15_READ_RESPONSE | ISO15_APPEND_CRC
payload = struct.pack("<BH", flags, len(iso_cmd)) + iso_cmd
payload = _iso15_payload(iso_cmd, flags=_WRITE_FLAGS) # LONG_WAIT for the ~22ms write ack
resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=5.0)
success = resp.status == 0 and len(resp.data) > 0 and resp.data[0] == 0
return {"success": success, "block": block}
async def raw(self, iso_cmd: bytes | str, long_wait: bool | None = None,
timeout: float = 5.0) -> dict:
"""Send a raw ISO15693 request frame and return the tag's response.
``iso_cmd`` is the request **without** the CRC (flags | command | [mfg] | [UID] | params);
the ISO15693 CRC is appended host-side. ``long_wait`` uses the write-length tag-ack timeout
(needed for WRITE/LOCK-class commands); ``None`` (default) auto-selects it from the command
byte. The returned ``raw`` is the firmware-relayed tag response (response-flags + data +
its CRC)."""
if isinstance(iso_cmd, str):
iso_cmd = bytes.fromhex(iso_cmd.replace(" ", ""))
if long_wait is None:
long_wait = (len(iso_cmd) > 1 and iso_cmd[1] in _WRITE_CMDS)
payload = _iso15_payload(iso_cmd, flags=_WRITE_FLAGS if long_wait else _READ_FLAGS)
resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=timeout)
data = bytes(resp.data)
return {"status": resp.status, "raw": data, "data": data.hex()}
async def sniff(self, timeout: float = 60.0) -> dict:
"""Start ISO15693 sniff. Blocks until button press or timeout.

View File

@@ -7,6 +7,16 @@ MF_KEY_A = 0
MF_KEY_B = 1
MFBLOCK_SIZE = 16
# value-block actions (firmware MifareValue datain[9])
MF_VALUE_INCREMENT = 0
MF_VALUE_DECREMENT = 1
MF_VALUE_RESTORE = 2
def _sector_of(block: int) -> int:
"""Sector number for a block: sectors 0-31 are 4 blocks, 32-39 are 16 blocks."""
return block // 4 if block < 128 else 32 + (block - 128) // 16
class HFMFCommands:
"""MIFARE Classic commands."""
@@ -49,8 +59,9 @@ class HFMFCommands:
raise ValueError(f"Block data must be {MFBLOCK_SIZE} bytes")
# MIX format: arg0=blockno, arg1=keytype
# payload = key(6) + reserved(10) + data(16) = 32 bytes
payload = key_bytes + b"\x00" * 10 + data
# firmware reads key at asBytes[0], block_data at asBytes[10]
# payload = key(6) + reserved(4) + data(16) = 26 bytes
payload = key_bytes + b"\x00" * 4 + data
resp = await self._t.send_mix(
Cmd.HF_MIFARE_WRITEBL, arg0=block, arg1=key_type,
payload=payload, timeout=5.0,
@@ -59,6 +70,45 @@ class HFMFCommands:
success = resp.oldarg[0] > 0 if resp.oldarg else resp.status == 0
return {"success": success, "block": block}
async def value(self, block: int, action: int, value: int = 0,
transfer_block: int | None = None,
key: str | bytes = "FFFFFFFFFFFF", key_type: int = MF_KEY_A,
transfer_key: str | bytes = "FFFFFFFFFFFF",
transfer_key_type: int | None = None) -> dict:
"""MIFARE value-block operation (increment / decrement / restore + transfer/commit).
``action``: 0=increment, 1=decrement, 2=restore (see ``MF_VALUE_*``). ``value`` is the
signed increment/decrement operand. The op writes the internal transfer buffer and commits
it to ``transfer_block`` — or back to ``block`` in place when None. A transfer that crosses
into another sector nested-auths there with ``transfer_key`` / ``transfer_key_type``.
Wire opcodes 0xC1/0xC0/0xC2 (op) then 0xB0 (transfer) are issued by the firmware.
"""
key_bytes = self._parse_key(key)
transfer_key_bytes = self._parse_key(transfer_key)
if transfer_key_type is None:
transfer_key_type = key_type
# firmware: transferBlk==0 means "transfer in place" (commit to `block`)
tblk = 0 if transfer_block is None else transfer_block
commit_block = tblk if tblk != 0 else block
# cmddata[34]: key[0:6], action[9], transferBlk[10], value-block[11:27] (operand at 11:15),
# transferKey[27:33], needNestedAuth[33] (mirrors client CmdHF14AMfValue)
cmddata = bytearray(34)
cmddata[0:6] = key_bytes
cmddata[9] = action & 0xFF
cmddata[10] = tblk & 0xFF
struct.pack_into("<i", cmddata, 11, value)
cmddata[27:33] = transfer_key_bytes
if _sector_of(commit_block) != _sector_of(block):
cmddata[33] = 1
resp = await self._t.send_mix(
Cmd.HF_MIFARE_VALUE, arg0=block, arg1=key_type, arg2=transfer_key_type,
payload=bytes(cmddata), timeout=5.0,
)
success = resp.oldarg[0] > 0 if resp.oldarg else resp.status == 0
return {"success": success, "block": commit_block}
async def rdsc(self, sector: int, key: str | bytes = "FFFFFFFFFFFF",
key_type: int = MF_KEY_A) -> dict:
"""Read an entire MIFARE Classic sector."""
@@ -96,14 +146,24 @@ class HFMFCommands:
continue
return {"found": False}
async def sniff(self) -> dict:
"""Sniff MIFARE traffic."""
resp = await self._t.send_ng(Cmd.HF_MIFARE_SNIFF, timeout=30.0)
async def sniff(self, param: int = 0) -> dict:
"""Sniff MIFARE (ISO14443-A) traffic.
Firmware has no dedicated MIFARE sniff handler; the C client routes
``hf mf sniff`` through the ISO14443-A sniffer.
"""
resp = await self._t.send_mix(
Cmd.HF_ISO14443A_SNIFF, arg0=param, timeout=30.0,
)
return {"status": resp.status}
async def sim(self, uid: str | bytes, size: str = "1k") -> dict:
async def sim(self, uid: str | bytes, size: str = "1k",
atqa: str | bytes | None = None, sak: int | None = None,
exit_after: int = 0, interactive: bool = False) -> dict:
"""Simulate MIFARE Classic card.
size: "mini", "1k", "2k", "4k"
size: "mini", "1k", "2k", "4k". Optional atqa (2 bytes) / sak (1 byte)
override the defaults; exit_after N stops after N auth attempts (0=never).
"""
if isinstance(uid, str):
uid = bytes.fromhex(uid)
@@ -116,23 +176,70 @@ class HFMFCommands:
if uid_flag is None:
raise ValueError("UID must be 4, 7, or 10 bytes")
flags = 0x0001 | uid_flag | size_flags[size] # FLAG_INTERACTIVE
resp = await self._t.send_mix(
Cmd.HF_MIFARE_SIMULATE, arg0=flags, payload=uid, timeout=60.0,
flags = uid_flag | size_flags[size]
if interactive:
flags |= 0x0001 # FLAG_INTERACTIVE
atqa_val = 0
if atqa is not None:
if isinstance(atqa, str):
atqa = bytes.fromhex(atqa)
if len(atqa) != 2:
raise ValueError("ATQA must be 2 bytes")
atqa_val = (atqa[1] << 8) | atqa[0]
flags |= 0x0002 # FLAG_ATQA_IN_DATA
sak_val = 0
if sak is not None:
sak_val = sak & 0xFF
flags |= 0x0004 # FLAG_SAK_IN_DATA
# struct { u16 flags; u8 exitAfter; u8 uid[10]; u16 atqa; u8 sak }
payload = struct.pack("<HB", flags, exit_after & 0xFF)
payload += uid + b"\x00" * (10 - len(uid))
payload += struct.pack("<HB", atqa_val, sak_val)
resp = await self._t.send_ng(
Cmd.HF_MIFARE_SIMULATE, payload, timeout=60.0,
)
return {"status": resp.status}
async def nested(self, block: int, key: str | bytes = "FFFFFFFFFFFF",
key_type: int = MF_KEY_A,
target_block: int = 0, target_key_type: int = MF_KEY_A) -> dict:
target_block: int = 0, target_key_type: int = MF_KEY_A,
calibrate: bool = True) -> dict:
"""Run nested attack to recover keys."""
key_bytes = self._parse_key(key)
payload = struct.pack("<BB", block, key_type) + key_bytes
payload += struct.pack("<BB", target_block, target_key_type)
# struct { block, keytype, target_block, target_keytype, bool calibrate, key[6] }
payload = struct.pack("<BBBBB", block, key_type, target_block,
target_key_type, 1 if calibrate else 0) + key_bytes
resp = await self._t.send_ng(Cmd.HF_MIFARE_NESTED, payload, timeout=30.0)
return {"status": resp.status, "data": resp.data.hex()}
async def cident(self) -> dict:
"""Identify magic (gen1/gen2) card type."""
resp = await self._t.send_ng(Cmd.HF_MIFARE_CIDENT, timeout=5.0)
return {"status": resp.status, "data": resp.data.hex() if resp.data else None}
# response struct { int16 isOK; u8 block; u8 keytype; u8 cuid[4];
# u8 nt_a[4]; u8 ks_a[4]; u8 nt_b[4]; u8 ks_b[4] }
if len(resp.data) < 24:
return {"success": False, "status": resp.status}
is_ok, r_block, r_keytype = struct.unpack_from("<hBB", resp.data, 0)
return {
"success": is_ok == 0,
"is_ok": is_ok,
"block": r_block,
"key_type": r_keytype,
"cuid": resp.data[4:8].hex(),
"nt_a": resp.data[8:12].hex(),
"ks_a": resp.data[12:16].hex(),
"nt_b": resp.data[16:20].hex(),
"ks_b": resp.data[20:24].hex(),
}
async def cident(self, is_mfc: bool = True, key_type: int = MF_KEY_A,
key: str | bytes = "FFFFFFFFFFFF") -> dict:
"""Identify magic (gen1/gen2/...) card type."""
key_bytes = self._parse_key(key)
# struct { uint8 is_mfc; uint8 keytype; uint8 key[6] }
payload = bytes([1 if is_mfc else 0, key_type & 0xFF]) + key_bytes
resp = await self._t.send_ng(Cmd.HF_MIFARE_CIDENT, payload, timeout=5.0)
magic = 0
if resp.status == 0 and len(resp.data) >= 2:
magic = struct.unpack_from("<H", resp.data, 0)[0]
return {"status": resp.status, "magic": magic, "is_magic": magic != 0}

64
pm3py/core/hf_mfu.py Normal file
View File

@@ -0,0 +1,64 @@
"""MIFARE Ultralight / NTAG commands: hf.mfu.*
ISO14443-3A tags with 4-byte pages. A READ (0x30) returns 4 consecutive
pages (16 bytes). keytype selects the auth scheme:
0 = none, 1 = UL-C (16-byte 3DES key), 2 = UL-EV1/NTAG (4-byte pwd),
3 = UL-AES (16-byte key).
"""
import struct
from .protocol import Cmd
from .transport import PM3Transport
def _key_bytes(key) -> bytes:
if key is None:
return b""
if isinstance(key, str):
return bytes.fromhex(key)
return bytes(key)
class HFMFUCommands:
"""MIFARE Ultralight / NTAG commands."""
def __init__(self, transport: PM3Transport):
self._t = transport
async def rdbl(self, block: int, key=None, keytype: int = 0) -> dict:
"""Read starting at page ``block``; returns 16 bytes (4 pages)."""
kb = _key_bytes(key)
# mful_readblock_t { bool use_schann; u8 block_no; u8 num_of_blocks;
# u8 keytype; u8 keylen; u8 key[16] }
payload = struct.pack("<BBBBB16s", 0, block & 0xFF, 1, keytype & 0xFF,
len(kb), kb.ljust(16, b"\x00")[:16])
resp = await self._t.send_ng(Cmd.HF_MIFAREU_READBL, payload, timeout=5.0)
if resp.status != 0:
return {"success": False, "block": block, "error": resp.status}
return {"success": True, "block": block,
"data": resp.data.hex(), "raw": bytes(resp.data)}
async def wrbl(self, block: int, data, key=None, keytype: int = 0) -> dict:
"""Write 4 bytes to page ``block``."""
if isinstance(data, str):
data = bytes.fromhex(data)
kb = _key_bytes(key)
# mful_writeblock_t { bool use_schann; u8 block_no; u8 keytype;
# u8 keylen; u8 key[16]; u8 data[16] }
payload = struct.pack("<BBBB16s16s", 0, block & 0xFF, keytype & 0xFF,
len(kb), kb.ljust(16, b"\x00")[:16],
bytes(data).ljust(16, b"\x00")[:16])
resp = await self._t.send_ng(Cmd.HF_MIFAREU_WRITEBL, payload, timeout=5.0)
return {"success": resp.status == 0, "block": block}
async def dump(self, start: int = 0, pages: int = 16) -> dict:
"""Read ``pages`` pages from ``start`` (each READ returns 4 pages)."""
out = bytearray()
page = start
while page < start + pages:
r = await self.rdbl(page)
if not r.get("success"):
break
out += r["raw"][:16]
page += 4
return {"success": len(out) > 0, "pages": len(out) // 4,
"data": out.hex(), "raw": bytes(out)}

View File

@@ -198,10 +198,22 @@ class HWCommands:
"""Send break to stop long-running operations."""
await self._t.send_ng_no_response(Cmd.BREAK_LOOP)
async def standalone(self) -> dict:
"""Start standalone mode."""
resp = await self._t.send_ng(Cmd.STANDALONE, timeout=5.0)
return {"status": resp.status}
async def standalone(self, arg: int = 1, mode: str | bytes = b"") -> dict:
"""Start standalone mode.
Firmware casts the payload to a PACKED ``{uint8 arg; uint8 mlen;
uint8 mode[10]}``. With ``mlen == 0`` it stashes ``arg`` at
``BigBuf_get_EM_addr()[0]``; otherwise it copies ``mode`` (e.g.
``"14a"``, ``"15"``, ``"iclass"`` for UniSniff). It then calls
``RunMod()`` and never replies, so this is fire-and-forget.
"""
if isinstance(mode, str):
mode = mode.encode("ascii")
if len(mode) > 10:
raise ValueError("standalone mode string max 10 bytes")
payload = struct.pack("<BB", arg & 0xFF, len(mode)) + mode + bytes(10 - len(mode))
await self._t.send_ng_no_response(Cmd.STANDALONE, payload)
return {"status": 0}
async def reset(self) -> None:
"""Reset the device."""

View File

@@ -25,35 +25,73 @@ class T55xxCommands:
}
async def writebl(self, block: int, data: int, page: int = 0,
password: int | None = None, downlink_mode: int = 0) -> dict:
"""Write a T55xx block."""
password: int | None = None, downlink_mode: int = 0,
test: bool = False) -> dict:
"""Write a T55xx block.
``test`` uses the test-mode write (downlink opcode 01) instead of the standard opcode 10.
Test mode is the datasheet's reconfiguration path (§5.10.3) — it can rewrite a tag that a
normal write can't (a corrupted/locked config), provided the master key still permits it
(allowed for key 9, denied once key 6 is set). Use it for recovery, not routine writes.
"""
pwd = password if password is not None else 0
pwd_mode = 1 if password is not None else 0
payload = struct.pack("<IIBBBB", data, pwd, block, page, pwd_mode, downlink_mode)
# Firmware t55xx_write_block_t: data(4)+pwd(4)+blockno(1)+flags(1).
# flags: 0x01 PwdMode, 0x02 Page, 0x04 test, 0x18 downlink_mode<<3.
flags = 0
if password is not None:
flags |= 0x01
if page:
flags |= 0x02
if test:
flags |= 0x04
flags |= (downlink_mode & 0x03) << 3
payload = struct.pack("<IIBB", data, pwd, block, flags)
resp = await self._t.send_ng(Cmd.LF_T55XX_WRITEBL, payload, timeout=5.0)
return {"status": resp.status}
async def wakeup(self, password: int = 0) -> dict:
async def wakeup(self, password: int = 0, downlink_mode: int = 0) -> dict:
"""Wake up T55xx with password."""
payload = struct.pack("<I", password)
# Firmware struct {uint32 password; uint8 flags}; flags = downlink_mode<<3.
flags = (downlink_mode & 0x03) << 3
payload = struct.pack("<IB", password, flags)
resp = await self._t.send_ng(Cmd.LF_T55XX_WAKEUP, payload)
return {"status": resp.status}
async def reset(self, downlink_mode: int = 0) -> dict:
"""Send the T55xx reset command (downlink opcode 00) and read the stream.
Resets the tag's modem to the start of its bitstream so a subsequent read starts
aligned. The firmware (T55xxResetRead) takes a single flags byte carrying the
downlink mode; the captured stream lands in BigBuf.
"""
flags = (downlink_mode & 0x03) << 3
payload = struct.pack("<B", flags)
resp = await self._t.send_ng(Cmd.LF_T55XX_RESET_READ, payload)
return {"status": resp.status}
# Default T55xx downlink-mode timing table (generic/PM3 Easy build), stored
# as raw struct values (field-clocks x 8). Mirrors T55xx_Timing in
# armsrc/lfops.c. Firmware exposes no GET, and CMD_LF_T55XX_SET_CONFIG is
# write-only (never replies), so this config is maintained client-side.
_DEFAULT_MODES = (
{"start_gap": 248, "write_gap": 160, "write_0": 144, "write_1": 400,
"read_gap": 120, "write_2": 0, "write_3": 0}, # Fixed
{"start_gap": 248, "write_gap": 160, "write_0": 144, "write_1": 400,
"read_gap": 120, "write_2": 0, "write_3": 0}, # Long Leading Ref.
{"start_gap": 248, "write_gap": 160, "write_0": 144, "write_1": 320,
"read_gap": 120, "write_2": 0, "write_3": 0}, # Leading 0
{"start_gap": 248, "write_gap": 160, "write_0": 144, "write_1": 272,
"read_gap": 120, "write_2": 400, "write_3": 528}, # 1 of 4
)
async def config(self) -> dict:
"""Get T55xx timing configuration."""
resp = await self._t.send_ng(Cmd.LF_T55XX_SET_CONFIG)
# Response is t55xx_configurations_t: 4 modes x 7 uint16_t each
modes = []
for i in range(4):
offset = i * 14
if offset + 14 <= len(resp.data):
fields = struct.unpack_from("<HHHHHHH", resp.data, offset)
modes.append({
"start_gap": fields[0], "write_gap": fields[1],
"write_0": fields[2], "write_1": fields[3],
"read_gap": fields[4], "write_2": fields[5], "write_3": fields[6],
})
return {"modes": modes}
"""Return T55xx downlink timing configuration.
The firmware exposes no GET for the T55xx timing table, and
CMD_LF_T55XX_SET_CONFIG is write-only (never replies), so the default
table is maintained client-side.
"""
return {"modes": [dict(m) for m in self._DEFAULT_MODES]}
class LFCommands:
@@ -69,12 +107,12 @@ class LFCommands:
divisor: LF frequency divisor (19-255). 95=125kHz, 88=134kHz.
Freq = 12000/(divisor+1) kHz.
"""
# Send init
payload = struct.pack("<BBB", 1, divisor, 0)
# Send init — firmware requires length == 2 ({mode, divisor})
payload = struct.pack("<BB", 1, divisor)
await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_LF, payload, timeout=5.0)
# Poll for result
payload = struct.pack("<BBB", 2, divisor, 0)
# Measure — reply carries the antenna voltage as a uint32 (mV)
payload = struct.pack("<BB", 2, divisor)
resp = await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_LF, payload, timeout=5.0)
voltage = struct.unpack_from("<I", resp.data, 0)[0] if len(resp.data) >= 4 else 0
@@ -94,23 +132,32 @@ class LFCommands:
return result
async def read(self, samples: int = 12288, verbose: bool = False,
on_progress: ProgressCallback = None) -> dict:
"""Read LF ADC samples into device buffer."""
val = (samples & 0x3FFFFFFF)
async def _acquire(self, cmd: int, samples: int, verbose: bool = False) -> dict:
"""Capture LF ADC samples into BigBuf. Shared by read (reader field ON) and sniff
(field OFF). Payload is lf_sample_payload_t — a PACKED bitfield: samples:30, realtime:1,
verbose:1, cotag:1 (5 bytes)."""
word = (samples & 0x3FFFFFFF)
if verbose:
val |= (1 << 31)
payload = struct.pack("<I", val)
resp = await self._t.send_ng(Cmd.LF_SNIFF_RAW_ADC, payload, timeout=10.0)
word |= (1 << 31) # bit 31 = verbose; bit 30 = realtime (0)
payload = struct.pack("<IB", word, 0) # trailing byte carries cotag (0)
resp = await self._t.send_ng(cmd, payload, timeout=10.0)
return {
"status": resp.status,
"samples": struct.unpack_from("<I", resp.data, 0)[0] if len(resp.data) >= 4 else 0,
}
async def read(self, samples: int = 12288, verbose: bool = False,
on_progress: ProgressCallback = None) -> dict:
"""Reader-mode LF acquisition: turn the reader field ON and sample the tag's load
modulation (this is ``lf read`` — CMD_LF_ACQ_RAW_ADC). Use this before download_samples
to read an EM4100/T5577/HID. (SNIFF, field-off, captures nothing from a tag.)"""
return await self._acquire(Cmd.LF_ACQ_RAW_ADC, samples, verbose)
async def sniff(self, samples: int = 12288,
on_progress: ProgressCallback = None) -> dict:
"""Sniff LF signal."""
return await self.read(samples=samples, on_progress=on_progress)
"""Sniff LF: reader field OFF, capture an external reader<->tag exchange
(CMD_LF_SNIFF_RAW_ADC)."""
return await self._acquire(Cmd.LF_SNIFF_RAW_ADC, samples)
async def sim(self, gap: int = 0, data: bytes = b"",
on_progress: ProgressCallback = None) -> dict:
@@ -155,41 +202,26 @@ class LFCommands:
trigger_threshold if trigger_threshold is not None else current.get("trigger_threshold", 0),
samples_to_skip if samples_to_skip is not None else current.get("samples_to_skip", 0),
0)
resp = await self._t.send_ng(Cmd.LF_SAMPLING_SET_CONFIG, payload)
return {"status": resp.status, **await self.config()}
# Firmware never replies to LF_SAMPLING_SET_CONFIG — fire-and-forget
await self._t.send_ng_no_response(Cmd.LF_SAMPLING_SET_CONFIG, payload)
return {"status": 0, **await self.config()}
async def download_samples(self, start: int = 0, count: int = 30000,
on_progress: ProgressCallback = None) -> bytes:
"""Download raw ADC samples from the device's BigBuf.
Must call read() or search() first to capture samples.
Returns raw bytes (1 byte per sample, unsigned 0-255).
Must call read() or search() first to capture samples. Returns raw bytes (1 byte per
sample, unsigned 0-255). The device streams the data as OLD-format CMD_DOWNLOADED_BIGBUF
frames then a final ACK — handled by the transport's bulk-download reader.
"""
buf = bytearray()
remaining = count
offset = start
while remaining > 0:
chunk = min(remaining, PM3_CMD_DATA_SIZE)
resp = await self._t.send_mix(
Cmd.DOWNLOAD_BIGBUF, offset, chunk, 0, timeout=5.0,
data = await self._t.download_bulk(
Cmd.DOWNLOAD_BIGBUF, start, count, Cmd.DOWNLOADED_BIGBUF, Cmd.ACK, timeout=5.0,
)
# Device responds with CMD_DOWNLOADED_BIGBUF containing the data
dl_resp = await self._t.read_response(timeout=5.0)
if dl_resp.data:
buf.extend(dl_resp.data)
if on_progress:
ret = on_progress({
"type": "download", "downloaded": len(buf), "total": count,
})
ret = on_progress({"type": "download", "downloaded": len(data), "total": count})
if asyncio.iscoroutine(ret):
await ret
offset += chunk
remaining -= chunk
return bytes(buf[:count])
return data
async def search(self, on_progress: ProgressCallback = None) -> "LFSearchResult":
"""Search for LF tags. Captures samples and returns a result with callable next steps.

View File

@@ -25,6 +25,42 @@ RESP_POSTAMBLE_SIZE = 2
MIX_ARGS_FMT = "<QQQ"
MIX_ARGS_SIZE = 24
# --- Bootloader / flashing: legacy OLD frame ---
# PacketCommandOLD / PacketResponseOLD: uint64 cmd + uint64 arg[3] + uint8 d[512].
# No preamble magic, no CRC, no length field — always this fixed size on the wire.
OLD_FRAME_FMT = "<QQQQ" # cmd + arg0 + arg1 + arg2 (32 bytes), then 512 data bytes
OLD_FRAME_HDR_SIZE = 32
OLD_FRAME_SIZE = OLD_FRAME_HDR_SIZE + PM3_CMD_DATA_SIZE # 544
# CMD_DEVICE_INFO reply flags (carried in arg[0]), from pm3_cmd.h
DEVICE_INFO_FLAG_BOOTROM_PRESENT = 1 << 0
DEVICE_INFO_FLAG_OSIMAGE_PRESENT = 1 << 1
DEVICE_INFO_FLAG_CURRENT_MODE_BOOTROM = 1 << 2
DEVICE_INFO_FLAG_CURRENT_MODE_OS = 1 << 3
DEVICE_INFO_FLAG_UNDERSTANDS_START_FLASH = 1 << 4
DEVICE_INFO_FLAG_UNDERSTANDS_CHIP_INFO = 1 << 5
DEVICE_INFO_FLAG_UNDERSTANDS_VERSION = 1 << 6
DEVICE_INFO_FLAG_UNDERSTANDS_READ_MEM = 1 << 7
# Required in CMD_START_FLASH arg[2] to unlock writes to the bootrom region ("DOIT")
START_FLASH_MAGIC = 0x54494F44
# AT91SAM7 flash geometry / memory map (bytes)
FLASH_START = 0x00100000 # start of internal flash
BOOTLOADER_END = 0x00102000 # end of bootrom / start of OS image (osimage origin)
FLASH_END_256K = 0x00140000 # window end on a 256 KB part
FLASH_END_512K = 0x00180000 # window end on a 512 KB part
FLASH_PAGE_SIZE = 256 # SAM7 flash page
FLASH_BLOCK_SIZE = 512 # one CMD_FINISH_WRITE programs 2 pages = one 512-byte block
def bl_version_code(major: int, minor: int, patch: int) -> int:
"""Pack a bootloader version the way the firmware does (BL_MAKE_VERSION)."""
return (major << 22) | (minor << 12) | patch
BL_VERSION_1_0_0 = bl_version_code(1, 0, 0)
# --- CRC-16/A (ISO 14443-3) ---
_CRC_TABLE: list[int] = []
@@ -68,6 +104,23 @@ def crc16_a(data: bytes) -> int:
return crc
def crc15(data: bytes) -> int:
"""ISO 15693 / NFC Type 5 CRC (CRC-16/X-25): poly=0x8408 (reflected), init=0xFFFF,
refin/refout=true, xorout=0xFFFF. Check value crc15(b"123456789") == 0x906E."""
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0x8408 if (crc & 1) else (crc >> 1)
return (~crc) & 0xFFFF
def crc15_bytes(data: bytes) -> bytes:
"""ISO 15693 CRC as appended on the wire (LSB byte first)."""
c = crc15(data)
return bytes([c & 0xFF, (c >> 8) & 0xFF])
# --- Error codes ---
class PM3Status(IntEnum):
SUCCESS = 0
@@ -140,7 +193,8 @@ class Cmd(IntEnum):
TIA = 0x0117
BREAK_LOOP = 0x0118
SET_TEAROFF = 0x0119
LED_CONTROL = 0x011A
SET_HF_FIELD_TIMEOUT = 0x011A
LED_CONTROL = 0x011B # our firmware fork (0x011A is upstream's SET_HF_FIELD_TIMEOUT)
GET_DBGMODE = 0x0120
# FPGA
@@ -202,6 +256,10 @@ class Cmd(IntEnum):
HF_ISO15693_CSETUID = 0x0316
HF_ISO15693_EML_SETMEM = 0x0331
HF_SNIFF_STREAM = 0x0337
HF_SNIFF_STATUS = 0x0338
HF_ISO14443A_SIM_TRACE = 0x0339 # FW->Host: live 14a sim trace (fire-and-forget)
# Sim table commands (firmware response table)
SIM_TABLE_UPLOAD = 0x0900
SIM_TABLE_CLEAR = 0x0901
@@ -265,3 +323,14 @@ class Cmd(IntEnum):
FPGAMEM_DOWNLOADED = 0x0803
UNKNOWN = 0xFFFF
# Sniff streaming flags
SNIFF_FLAG_STREAMING = 0x01
SNIFF_FLAG_BUTTON_TOGGLE = 0x02
# Sniff states
SNIFF_STATE_STARTED = 0
SNIFF_STATE_PAUSED = 1
SNIFF_STATE_RESUMED = 2
SNIFF_STATE_STOPPED = 3

View File

@@ -11,8 +11,31 @@ from .protocol import (
CMD_PREAMBLE_SIZE, CMD_POSTAMBLE_SIZE,
RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE,
PM3_CMD_DATA_SIZE, MIX_ARGS_SIZE,
OLD_FRAME_FMT, OLD_FRAME_HDR_SIZE, OLD_FRAME_SIZE,
)
# Frames the device emits asynchronously (debug output), independent of any command. They can
# arrive in the middle of a command's response stream on debug-heavy firmware, so the response
# readers skip them to return the real reply (mirrors the C client routing debug to its logger).
_ASYNC_FRAME_CMDS = frozenset({
Cmd.DEBUG_PRINT_STRING, Cmd.DEBUG_PRINT_INTEGERS, Cmd.DEBUG_PRINT_BYTES,
})
_MAX_ASYNC_SKIP = 64 # bound the skip loop so a chatty/looping device can't hang a read
def _wtx_ms(resp) -> int | None:
"""Waiting-Time-eXtension value (ms) if ``resp`` is a CMD_WTX control frame, else ``None``.
The firmware emits CMD_WTX to ask the host to *keep waiting* — notably on FPGA bitstream
reload (``send_wtx(FPGA_LOAD_WAIT_TIME)``, 1500ms), which the first HF command after connect
triggers. It is NOT a command reply. The C client extends its response timeout by this value
and reads on (comms.c). pm3py must do the same: otherwise the WTX frame is returned as the
reply, and — because every later read then grabs the *previous* command's response — one WTX
permanently desyncs the whole session (the bug behind the '15693 garbage/shift' reports)."""
if resp.cmd == Cmd.WTX and len(resp.data) >= 2:
return struct.unpack_from("<H", resp.data)[0]
return None
def _crc_to_wire(crc: int) -> int:
"""Byte-swap CRC to match PM3 wire format: (low << 8) | high."""
@@ -93,6 +116,30 @@ def decode_response_frame(data: bytes) -> dict:
return result
def encode_old_frame(cmd: int, arg0: int = 0, arg1: int = 0, arg2: int = 0,
data: bytes = b"") -> bytes:
"""Encode a legacy OLD command frame (used by the bootloader / flasher).
Fixed 544 bytes: uint64 cmd + uint64 arg[3] + uint8 d[512]. No magic, no CRC.
"""
if len(data) > PM3_CMD_DATA_SIZE:
raise ValueError(f"OLD frame data too large: {len(data)} > {PM3_CMD_DATA_SIZE}")
header = struct.pack(OLD_FRAME_FMT, cmd, arg0, arg1, arg2)
return header + data + bytes(PM3_CMD_DATA_SIZE - len(data))
def decode_old_frame(raw: bytes) -> dict:
"""Decode a 544-byte OLD response frame into a dict (cmd, oldarg, data)."""
if len(raw) < OLD_FRAME_SIZE:
raise ValueError(f"OLD frame too short: {len(raw)} < {OLD_FRAME_SIZE}")
cmd, arg0, arg1, arg2 = struct.unpack_from(OLD_FRAME_FMT, raw, 0)
return {
"cmd": cmd,
"oldarg": [arg0, arg1, arg2],
"data": raw[OLD_FRAME_HDR_SIZE:OLD_FRAME_SIZE],
}
class PM3Error(Exception):
"""Proxmark3 communication error."""
def __init__(self, message: str, status: int = 0):
@@ -130,6 +177,10 @@ class PM3Transport:
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._lock = asyncio.Lock()
# set when a read times out: the command's response may still be in flight and would be
# misread as the *next* command's reply (the "04 BB = stale UID" desync). The next send
# drains it first. Cheap: only fires after a timeout, which is rare on a healthy link.
self._resync_needed = False
async def connect(self) -> None:
import serial_asyncio
@@ -159,23 +210,40 @@ class PM3Transport:
async def send_frame(self, frame: bytes) -> None:
if not self._writer:
raise PM3Error("Not connected")
if self._resync_needed: # a prior command timed out; clear its late response first
await self._drain_input()
self._resync_needed = False
self._writer.write(frame)
await self._writer.drain()
async def read_response(self, timeout: float = 2.0) -> PM3Response:
"""Read and decode one response frame from the device."""
"""Read one command reply, skipping async debug-print frames the device interleaves.
Debug-heavy firmware emits DEBUG_PRINT_* frames asynchronously, which can land in the
middle of a command's response stream. The C client routes those to its logger and keeps
reading for the real reply; pm3py must do the same, or it returns a debug line misparsed
as the reply — the cause of garbage sample counts and desynced downloads."""
if not self._reader:
raise PM3Error("Not connected")
extra = 0.0 # accumulated WTX wait-extension (seconds)
for _ in range(_MAX_ASYNC_SKIP):
try:
# Read until we find response magic
raw = await asyncio.wait_for(
self._read_response_frame(), timeout=timeout,
)
d = decode_response_frame(raw)
return PM3Response.from_dict(d)
raw = await asyncio.wait_for(self._read_response_frame(), timeout=timeout + extra)
except asyncio.TimeoutError:
self._resync_needed = True # late response would desync the next read — drain it
raise PM3Error("Response timeout", status=-4)
try:
resp = PM3Response.from_dict(decode_response_frame(raw))
except ValueError:
continue # malformed frame — resync on the next magic
wtx = _wtx_ms(resp)
if wtx is not None:
extra += wtx / 1000.0 # device asked to wait longer — keep reading
continue
if resp.cmd in _ASYNC_FRAME_CMDS:
continue # device debug output — not our reply
return resp
raise PM3Error("Too many async frames without a reply", status=-4)
async def _read_response_frame(self) -> bytes:
"""Read a complete response frame, scanning for magic bytes."""
@@ -234,6 +302,157 @@ class PM3Transport:
frame = encode_ng_frame(cmd, payload)
await self.send_frame(frame)
async def send_old(self, cmd: int, arg0: int = 0, arg1: int = 0, arg2: int = 0,
data: bytes = b"", timeout: float = 5.0) -> PM3Response:
"""Send a legacy OLD frame (bootloader/flasher) and read one reply.
The reply may be an OLD frame (from the bootrom) or an NG frame (from the
OS) — the reader branches on the leading magic.
"""
async with self._lock:
frame = encode_old_frame(cmd, arg0, arg1, arg2, data)
await self.send_frame(frame)
return await self._read_any_response(timeout=timeout)
async def send_old_no_response(self, cmd: int, arg0: int = 0, arg1: int = 0,
arg2: int = 0, data: bytes = b"") -> None:
"""Send an OLD frame without waiting (e.g. START_FLASH handover, RESET)."""
async with self._lock:
frame = encode_old_frame(cmd, arg0, arg1, arg2, data)
await self.send_frame(frame)
async def _drain_input(self, quiet: float = 0.08) -> int:
"""Discard buffered/stale bytes (a prior partial transaction, or another process that
touched the port) so the next request/response starts byte-aligned. OLD-format frames
carry no magic to resync on, so a bulk download MUST begin on a clean stream. Returns the
number of bytes dropped."""
if not self._reader:
return 0
try: # clear the OS/pyserial buffer too
tr = self._writer.transport
if hasattr(tr, "serial"):
tr.serial.reset_input_buffer()
except Exception:
pass
dropped = 0
while True:
try:
chunk = await asyncio.wait_for(self._reader.read(4096), timeout=quiet)
except (asyncio.TimeoutError, Exception):
break
if not chunk:
break
dropped += len(chunk)
return dropped
async def download_bulk(self, cmd: int, start: int, count: int, data_cmd: int,
ack_cmd: int, timeout: float = 5.0, on_bytes=None) -> bytes:
"""Run a bulk BigBuf/EML/SPIFFS download and return the concatenated bytes.
The firmware answers one download request (a MIX frame) by streaming ``count`` bytes as
a run of *OLD*-format ``data_cmd`` frames (no magic — that's why the plain NG reader
can't see them and the naive download hangs), then a final ``ack_cmd`` frame. We flush
any stale input first (OLD frames can't resync mid-stream), then read with the NG/OLD-
agnostic reader until the ACK, or until we've collected ``count`` bytes (draining the
trailing ACK best-effort so it can't poison the next command)."""
async with self._lock:
await self._drain_input() # start byte-aligned — no stale frames
await self.send_frame(encode_mix_frame(cmd, start, count, 0))
buf = bytearray()
while True:
try:
resp = await self._read_any_response(timeout=timeout)
except PM3Error:
break # timeout (e.g. ACK lost) — return what we have
if resp.cmd == ack_cmd:
break
if resp.cmd == data_cmd and resp.data:
length = resp.oldarg[1] if resp.oldarg and len(resp.oldarg) > 1 else len(resp.data)
buf.extend(resp.data[:max(0, length)])
if on_bytes is not None:
on_bytes(len(buf))
if count and len(buf) >= count:
try: # got all the data; consume the trailing ACK
await self._read_any_response(timeout=1.0)
except PM3Error:
pass
break
return bytes(buf[:count]) if count else bytes(buf)
async def _read_any_response(self, timeout: float = 5.0) -> PM3Response:
"""Read one NG-or-OLD frame, skipping async debug-print frames (as read_response does)."""
if not self._reader:
raise PM3Error("Not connected")
extra = 0.0 # accumulated WTX wait-extension (seconds)
for _ in range(_MAX_ASYNC_SKIP):
try:
raw = await asyncio.wait_for(self._read_any_frame(), timeout=timeout + extra)
except asyncio.TimeoutError:
self._resync_needed = True # late response would desync the next read — drain it
raise PM3Error("Response timeout", status=-4)
magic = struct.unpack_from("<I", raw, 0)[0]
try:
if magic == RESP_PREAMBLE_MAGIC:
resp = PM3Response.from_dict(decode_response_frame(raw))
else:
d = decode_old_frame(raw)
resp = PM3Response(cmd=d["cmd"], status=0, reason=0, ng=False,
data=d["data"], oldarg=d["oldarg"])
except ValueError:
continue
wtx = _wtx_ms(resp)
if wtx is not None:
extra += wtx / 1000.0 # device asked to wait longer — keep reading
continue
if resp.cmd in _ASYNC_FRAME_CMDS:
continue # device debug output — not our reply
return resp
raise PM3Error("Too many async frames without a reply", status=-4)
async def _read_any_frame(self) -> bytes:
"""Read a complete frame, branching NG vs OLD on the leading 4-byte magic.
Unlike _read_response_frame this does NOT scan/discard — the bootloader
is queried on a freshly (re)opened port with no in-flight junk.
"""
assert self._reader is not None
head = await self._reader.readexactly(4)
magic = struct.unpack_from("<I", head, 0)[0]
if magic == RESP_PREAMBLE_MAGIC:
buf = bytearray(head)
buf.extend(await self._reader.readexactly(RESP_PREAMBLE_SIZE - 4))
payload_len = struct.unpack_from("<H", buf, 4)[0] & 0x7FFF
buf.extend(await self._reader.readexactly(payload_len + RESP_POSTAMBLE_SIZE))
return bytes(buf)
# OLD frame — fixed 544 bytes total, no magic/CRC/length
rest = await self._reader.readexactly(OLD_FRAME_SIZE - 4)
return head + rest
async def reopen(self, wait: float = 1.0, retries: int = 60,
retry_delay: float = 1.0, port: str | None = None) -> None:
"""Close and reopen the serial port, tolerating USB re-enumeration.
The device drops off the bus during the OS->bootloader handover and after
HARDWARE_RESET, then reappears (VID/PID or path may shift). Mirrors the C
client's close / wait / poll-reopen sequence.
"""
try:
await self.disconnect()
except Exception:
pass
if port is not None:
self.port = port
await asyncio.sleep(wait)
last_err: Exception | None = None
for _ in range(retries):
try:
await self.connect()
return
except Exception as e: # port not back yet
last_err = e
await asyncio.sleep(retry_delay)
raise PM3Error(f"Device did not reappear after reset: {last_err}")
async def download_bigbuf(self, offset: int = 0, length: int = PM3_CMD_DATA_SIZE,
timeout: float = 4.0) -> tuple[bytes, int]:
"""Download data from device BigBuf.

129
pm3py/flash_cli.py Normal file
View File

@@ -0,0 +1,129 @@
"""``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, build_firmware
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)")
src = p.add_mutually_exclusive_group()
src.add_argument("--image", help="path to fullimage.elf "
"(default: $PM3PY_FIRMWARE or local build)")
src.add_argument("--build", metavar="PLATFORM",
help="build the firmware for PLATFORM (e.g. PM3GENERIC for "
"Easy, PM3RDV4) from the submodule, then flash it")
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)
if args.build:
try:
args.image = str(build_firmware(args.build))
except FlashError as e:
print(f"error: {e}", file=sys.stderr)
return 2
# --build is an explicit "put what I just built on the device" — flash it
# regardless of the pin. A dirty/iterated build keeps the same base SHA, so
# matches_pin can't tell the new image apart from the old firmware.
args.force = True
return asyncio.run(_run(args))
if __name__ == "__main__":
sys.exit(main())

22
pm3py/lf/__init__.py Normal file
View File

@@ -0,0 +1,22 @@
"""Low-frequency signal demodulation.
The Proxmark firmware returns *raw ADC envelope samples* for LF reads — the actual
demodulation (ASK/FSK/PSK, Manchester/biphase, protocol framing) is the client's job. The C
client has a large DSP stack for this; pm3py historically had none, so LF ``identify`` could
never see a tag. This package is that missing stack, in Python.
Layers:
* :mod:`pm3py.lf.dsp` — modulation-agnostic primitives (threshold, clock recovery, ASK
binarize, Manchester/biphase decode) that turn an envelope into a bit stream.
* :mod:`pm3py.lf.protocols` — per-tag framing (EM4100, T55xx config, HID, ...) that turns a
bit stream into a decoded credential, reusing the transponder models' own validators.
* :mod:`pm3py.lf.capture` — pull an envelope off a live device (or accept one for tests) and
run every decoder, returning the best match.
Everything is testable without hardware: the LF transponder models already *encode* these
protocols, so a synthetic envelope built from an encoder round-trips through the demod.
"""
from .capture import demod_samples, demod_raw, read_config, identify_lf
__all__ = ["demod_samples", "demod_raw", "read_config", "identify_lf"]

167
pm3py/lf/ask.py Normal file
View File

@@ -0,0 +1,167 @@
"""ASK demodulation — a faithful port of the Proxmark ASK stack (firmware/common/lfdemod.c).
Real LF envelopes are asymmetric: the tag load-modulation gives a sharp fall but the antenna
recovers on a slow RC ramp, and heavily-coupled tags saturate the rails. A naive threshold +
fixed-step resample drifts and bit-slips on that. The C client instead:
1. computeSignalProperties -> high/low/mean (robust middle mean).
2. getHiLo(75) -> clip thresholds 75% of the way mean->rail (25% clip), giving hysteresis.
3. cleanAskRawDemod -> walk rail-to-rail waves, quantising each to a full- or half-clock symbol
(so the ramp between rails never triggers a false edge, and every wave re-syncs the clock).
4. manrawdecode -> Manchester the raw stream to data bits.
This module ports 1-4. Framing (EM4100 header/parity, T55xx config) stays in protocols.py.
"""
from __future__ import annotations
from . import dsp
_CLOCKS = (8, 16, 32, 40, 50, 64, 100, 128)
def trim_to_signal(samples, win: int = 256, thresh: int = 40):
"""Drop the leading field-settle transient (low-swing) and trailing unfilled zeros — the
parts of a live capture that carry no modulation and would poison thresholding/clocking."""
n = len(samples)
start = 0
for i in range(0, max(1, n - win), 32):
w = samples[i:i + win]
if w and max(w) - min(w) > thresh:
start = i
break
end = n
while end > start and samples[end - 1] == 0:
end -= 1
return samples[start:end]
def signal_props(samples):
"""Port of computeSignalProperties: (high, low, mean) where high/low are the rails and mean
is the robust mean of the middle 80% (ignoring the top/bottom 10% so rails don't skew it)."""
if not samples:
return 0, 0, 0.0
s = sorted(samples)
n = len(s)
lo10 = s[int(n * 0.1)]
hi90 = s[int(n * 0.9)]
mid = [v for v in samples if lo10 <= v <= hi90]
mean = (sum(mid) / len(mid)) if mid else (sum(samples) / n)
return max(samples), min(samples), mean
def get_hilo(high, low, mean, pct: int = 75):
"""Port of getHiLo: clip the thresholds ``pct``% of the way from the mean to each rail (the
default 25% clip), so partially-unclipped waves still register as high/low."""
clip_hi = mean + (high - mean) * pct / 100.0
clip_lo = mean - (mean - low) * pct / 100.0
return clip_hi, clip_lo
def detect_clk(samples) -> int:
"""Full-bit clock (samples per data bit), snapped to the T55xx rate set. ``dsp.detect_clock``
finds the shortest symbol (a Manchester half-bit); the data-bit clock is twice that."""
half = dsp.detect_clock(dsp.binarize(samples))
if not half:
return 0
return min(_CLOCKS, key=lambda c: abs(c - 2 * half))
def clean_ask_raw_demod(samples, clk: int, high: float, low: float, invert: int = 0):
"""Port of ``cleanAskRawDemod``: envelope -> raw half-bit stream by measuring rail-to-rail
wave widths (full clock -> 2 like symbols, half clock -> 1), ignoring the ramp between rails.
Phase errors (over-long waves) become ``7`` markers."""
out: list[int] = []
n = len(samples)
cl_4, cl_2 = clk // 4, clk // 2
wave_high = True
pos = 0
while pos < n and samples[pos] < high: # getNextHigh
pos += 1
smpl = 1
if cl_2 - cl_4 - 1 < pos <= clk + cl_4 + 1: # do not skip the first transition
out.append(invert ^ 1)
for i in range(pos, n):
v = samples[i]
if v >= high and wave_high:
smpl += 1
elif v <= low and not wave_high:
smpl += 1
elif (v >= high and not wave_high) or (v <= low and wave_high):
if smpl > clk - cl_4 - 1: # full-clock wave -> two like symbols
if smpl > clk + cl_4 + 1:
out.append(7)
elif wave_high:
out += [invert, invert]
else:
out += [invert ^ 1, invert ^ 1]
wave_high = not wave_high
smpl = 0
elif smpl > cl_2 - cl_4 - 1: # half-clock wave -> one symbol
if smpl > cl_2 + cl_4 + 1:
out.append(7)
out.append(invert if wave_high else invert ^ 1)
wave_high = not wave_high
smpl = 0
else:
smpl += 1
else:
smpl += 1 # in the ramp — haven't reached a rail yet
return out
def manchester_bits(samples):
"""Yield candidate data-bit streams for an ASK/Manchester envelope, over every raw-invert and
Manchester phase/polarity alignment — the caller keeps whichever frames + validates cleanly.
Empty if the capture is noise or won't clock."""
core = trim_to_signal(samples)
if len(core) < 64 or dsp.is_flat(core):
return
high, low, mean = signal_props(core)
clip_hi, clip_lo = get_hilo(high, low, mean)
clk = detect_clk(core)
if clk < 8:
return
for cinv in (0, 1):
raw = clean_ask_raw_demod(core, clk, clip_hi, clip_lo, cinv)
raw = [b for b in raw if b != 7] # drop phase-error markers before pairing
for phase in (0, 1):
for minv in (False, True):
yield dsp.manchester_decode(raw, phase, minv)
def nrz_bits(samples):
"""Yield candidate raw NRZ / direct bit streams. In NRZ (the T5577 "direct" line code) the
data bit is output with no coding — the envelope sampled once per bit *is* the data — so the
bit clock is the shortest run itself, not twice a half-bit as in Manchester. Yields both
polarities over a few phase offsets; the caller keeps whichever repeats cleanly."""
core = trim_to_signal(samples)
if len(core) < 64 or dsp.is_flat(core):
return
binary = dsp.binarize(core)
half = dsp.detect_clock(binary)
if not half or half < 8:
return
clk = min(_CLOCKS, key=lambda c: abs(c - half)) # NRZ: one bit = the shortest run
for offset in (0.5, 0.0, 0.25, 0.75):
raw = dsp.resample(binary, clk, offset)
if len(raw) < 32:
continue
yield raw
yield [b ^ 1 for b in raw]
def biphase_bits(samples):
"""Yield candidate raw bit streams for a biphase / differential-Manchester ASK envelope — the
FDX-B line code (also Indala, Gallagher, …). Resample to half-bit resolution and run the
biphase decoder for both polarities; phase-error markers are dropped."""
core = trim_to_signal(samples)
if len(core) < 64 or dsp.is_flat(core):
return
binary = dsp.binarize(core)
half = dsp.detect_clock(binary)
if not half or half < 4:
return
raw = dsp.resample(binary, half)
for invert in (0, 1):
bits, _err = dsp.biphase_raw_decode(raw, 0, invert)
yield [b for b in bits if b != 7]

273
pm3py/lf/capture.py Normal file
View File

@@ -0,0 +1,273 @@
"""Live-device LF capture + identify.
``demod_samples`` is the hardware-free heart: an envelope in, a decoded credential out — every
protocol decoder tried in turn. ``identify_lf`` drives a real device: it captures what the tag
*emits* (a plain read) and, separately, probes for a T55xx by reading config block 0, then
combines the two into "is it a T5577, and what is it (emulating)?".
"""
from __future__ import annotations
import inspect
from . import ask
from . import dsp
from . import protocols
# how many envelope samples to pull from BigBuf after a capture (a T55xx/EM frame is a few
# thousand samples at RF/64; this spans several repeats for a clean lock)
_SAMPLE_COUNT = 15000
# every emitted-credential decoder, tried in order (headered/most-specific first)
_DECODERS = (
protocols.decode_em4100,
protocols.decode_hid,
protocols.decode_awid,
protocols.decode_fdxb,
)
def credential_label(c: dict) -> str:
"""One-line name for a decoded credential, per protocol (each decoder returns its own fields)."""
p = c.get("protocol", "?")
if p == "EM4100":
return f"EM4100 {c['id_hex']}"
if p == "HID Prox":
return f"HID Prox {c['format']} FC {c['facility_code']} Card {c['card_number']}"
if p == "AWID":
return f"{c['format']} FC {c['facility_code']} Card {c['card_number']}"
if p == "FDX-B":
return f"FDX-B {c['id']}" + (" (animal)" if c.get("animal") else "")
return p
def demod_samples(samples) -> dict | None:
"""Decode a raw LF envelope to a credential by trying each protocol demod in turn.
Hardware-free and the single point every decoder funnels through — feed it a synthetic
envelope from a transponder encoder in tests, or a BigBuf download at runtime."""
if not samples:
return None
for decode in _DECODERS:
result = decode(samples)
if result is not None:
return result
return None
def _transition_density(bits) -> float:
return sum(1 for i in range(1, len(bits)) if bits[i] != bits[i - 1]) / max(1, len(bits) - 1)
def _frame_period(bits, min_p: int = 16) -> int | None:
"""The smallest bit-period the stream repeats at (its frame length), or None.
Guards against the failure mode where the wrong line code decodes a signal into a mostly
*constant* run (e.g. biphase-decoding a non-biphase tag emits a short prefix then all-1s):
such a stream trivially "repeats" at any period. So we reject a near-constant stream up front,
require room for a few real repeats, and reject a frame that is itself near-constant or
trivially sub-periodic (an alternating 0101 fill)."""
n = len(bits)
if n < 3 * min_p:
return None
if _transition_density(bits) < 0.10: # mostly constant -> not a real frame
return None
# find the *smallest true* period, from 2 up — so a trivial fill (0101 -> period 2, 0011 ->
# period 4) is caught as tiny and rejected, instead of being reported at the first p >= min_p.
for p in range(2, n // 3 + 1):
span = n - p
if sum(1 for i in range(span) if bits[i] == bits[i + p]) >= span * 0.97:
return p if p >= min_p else None # real tag frame is >= min_p bits
return None
def demod_raw(samples) -> dict | None:
"""Physical-layer fallback for an ASK tag no named decoder recognises: recover the raw
repeating frame under the NRZ and biphase line codes so the tag still yields its bits.
Returns whichever encodings produced a clean repeating frame (bit string + hex + period in
bits) plus the recovered bit clock, or None if nothing repeats cleanly. This is deliberately
*below* the credential decoders — it names no format, it just hands back the bits an expert
(or a future format decoder) can read."""
core = ask.trim_to_signal(samples)
if len(core) < 64 or dsp.is_flat(core):
return None
half = dsp.detect_clock(dsp.binarize(core))
if not half:
return None
encs: dict = {}
for name, gen in (("nrz", ask.nrz_bits), ("biphase", ask.biphase_bits)):
best = None
for bits in gen(samples):
p = _frame_period(bits)
if p and (best is None or p < best[0]):
best = (p, bits[:p])
if best:
p, frame = best
s = "".join(str(b) for b in frame)
encs[name] = {"period": p, "bits": s, "hex": f"{int(s, 2):0{-(-p // 4)}X}"}
if not encs:
return None
clk = min(ask._CLOCKS, key=lambda c: abs(c - half))
return {"protocol": "raw", "shortest_run": round(half, 1), "rf": f"RF/{clk}",
"encodings": encs}
def raw_label(raw: dict) -> str:
"""One-line name for a raw physical-layer dump (no credential decoded)."""
parts = [f"{k} {v['hex']} ({v['period']}b)" for k, v in raw["encodings"].items()]
return f"unknown LF ({raw['rf']}) — " + "; ".join(parts)
# download a fixed, safe span rather than trusting lf.read()'s reported count — on a flaky NG
# link that count is sometimes garbage, and over-reading spills into BigBuf memory past the
# samples. 12000 is under a normal acquisition, enough frames for any LF frame to repeat.
_DOWNLOAD_BYTES = 12000
# markers that a "sample" buffer is actually desynced BigBuf memory, not an envelope
_POISON = b"\xef\xbe\xad\xde" # 0xDEADBEEF — firmware stack canary
_RESP_MAGIC = b"\x50\x4d\x33\x62" # 0x62334d50 — a PM3 response frame sitting in the buffer
def _try(fn, default):
try:
return fn()
except Exception:
return default
def _looks_like_samples(data: bytes) -> bool:
"""Reject a download that is clearly desynced BigBuf memory rather than an LF envelope: the
DEADBEEF stack canary, an embedded PM3 response magic, or a mostly-zero (unwritten) buffer."""
if len(data) < 256:
return False
if _POISON in data or _RESP_MAGIC in data:
return False
return data.count(0) < len(data) // 2
def _download(device, count: int = _DOWNLOAD_BYTES) -> bytes:
"""Pull the last capture's envelope out of the device BigBuf (best-effort -> b'')."""
try:
return device.lf.download_samples(count=count) or b""
except Exception:
return b""
def _capture(device, do_read, count: int = _DOWNLOAD_BYTES, tries: int = 3) -> bytes:
"""Read + download a clean LF envelope, retrying past the intermittent desyncs a flaky NG
link produces. Returns validated sample bytes, or ``b''`` if every attempt came back as
memory (device likely needs a power-cycle / lower debug level)."""
for _ in range(tries):
if do_read() is None:
continue
data = _download(device, count=count)
if _looks_like_samples(data):
return data
return b""
def read_emitted(device) -> dict | None:
"""Capture and decode whatever the tag is emitting in the field (no downlink) — the EM4100
ID a tag (native or T5577-emulated) streams continuously."""
data = _capture(device, lambda: _try(lambda: device.lf.read(samples=15000), None))
return demod_samples(data) if data else None
def read_config(device, password: int | None = None) -> dict | None:
"""Probe for a T55xx by reading config block 0 and demodulating the response. A clean,
sane config both proves the chip is a T5577 and says what it's programmed to emulate.
``password`` (when set) reads a password-protected tag in password mode."""
data = _capture(device, lambda: _try(lambda: device.lf.t55.readbl(0, password=password), None))
return protocols.decode_t55xx_config(data) if data else None
def read_t55xx_block(device, block: int, page: int = 0, password: int | None = None) -> int | None:
"""Best-effort read of a T55xx 32-bit data block: send the block read, demod the first
repeating 32-bit word. A bare block has no header/CRC, so the bit alignment (rotation) can't
be verified — this is inherently best-effort. Block 0 (config) is the reliable one; read it
via read_config, which resolves the rotation against known presets. ``page`` 1 reaches the
traceability data; ``password`` reads a password-protected tag in password mode."""
data = _capture(device, lambda: _try(
lambda: device.lf.t55.readbl(int(block), page=int(page), password=password), None))
if not data:
return None
for bits in ask.manchester_bits(data):
clean = [b for b in bits if b is not None]
for word in protocols._repeating_words(clean, 32):
return word
return None
def identify_lf(device, emit=None) -> dict | None:
"""Full LF identify against a live device. Returns a result dict (``found``/``field``/
``label``/``chip``/``config``/``emulating``/``emitted``) or ``None`` if the device is
unusable. ``emit`` streams human-readable progress lines to the caller's trace."""
emit = emit or (lambda _l: None)
if device is None:
return None
# capture drives the device *synchronously* (as Proxmark3.sync()'s proxy exposes it). A raw
# async Proxmark3() returns un-awaited coroutines here — fail with an actionable message
# instead of a cryptic "coroutine has no len()" deep in the demod.
if inspect.iscoroutinefunction(getattr(getattr(device, "lf", None), "read", None)):
raise TypeError(
"identify_lf needs a synchronous device — use Proxmark3.sync(), not Proxmark3(). "
"(A bare Proxmark3() is the async API and isn't connected.)"
)
# Force the device debug level to NONE for the duration: on debug-heavy firmware a raised
# g_dbglevel makes LF acquisition emit Dbprintf frames over USB, which interleave with the
# sample download and desync it. This is the device-side knob (not the C client's g_debugMode
# spam), and pm3py's own reads are what matter here.
_try(lambda: device.hw.dbg(0), None)
config = read_config(device) # T55xx? -> chip + emulation
emitted = read_emitted(device) # what's on the air -> EM4100 ID (etc.)
# nothing named decoded off the air -> try the physical-layer fallback (NRZ/biphase) so a tag
# no credential decoder handles still yields its raw bits rather than reading as "nothing".
raw = None
if emitted is None:
data = _capture(device, lambda: _try(lambda: device.lf.read(samples=_SAMPLE_COUNT), None))
raw = demod_raw(data) if data else None
if config is None and emitted is None and raw is None:
return None
chip = None
emulating = None
if config is not None:
chip = "T5577"
emulating = config["emulating"]
emit(f" T55xx block 0 = 0x{config['config_hex']} "
f"({config['modulation']}, RF/{config['data_bit_rate']}, {config['max_block']} blocks)"
f" -> {emulating}")
if emitted is not None:
emit(f" emitted: {credential_label(emitted)}")
elif raw is not None:
emit(f" emitted (raw, no format decoded): {raw_label(raw)}")
# label: name the chip and what it's actually emitting (the reliable, decoded credential);
# fall back to the raw physical dump, then the config's emulation guess.
if chip and emitted:
label = f"{chip} ({credential_label(emitted)})"
elif chip and raw:
label = f"{chip} ({raw_label(raw)})"
elif chip:
label = f"{chip} ({emulating})"
elif emitted:
label = credential_label(emitted)
else:
label = raw_label(raw)
return {
"found": True,
"field": "lf",
"protocol": "lf",
"chip": chip,
"config": config["config_hex"] if config else None,
"emulating": emulating,
"emitted": emitted,
"raw": raw,
"label": label,
}

168
pm3py/lf/dsp.py Normal file
View File

@@ -0,0 +1,168 @@
"""Modulation-agnostic LF DSP primitives.
Input everywhere is a Proxmark LF *envelope*: one unsigned byte (0-255) per carrier period
(one "field clock"), as returned by ``lf.download_samples``. So a tag clocked at RF/64 spans
64 samples per data bit, RF/32 spans 32, and so on — the RF/n divider *is* the samples-per-bit.
The functions here recover a bit stream from that envelope without knowing the protocol:
threshold to binary, detect the bit clock, then slice/decode (Manchester or biphase). Framing
(headers, parity, credential fields) lives in :mod:`pm3py.lf.protocols`.
"""
from __future__ import annotations
# T55xx standard data-bit-rate set (RF/n) — samples-per-bit candidates when the clock is
# unknown. x-mode allows any even n; the reader still recovers those via the generic detector.
RF_RATES = (8, 16, 32, 40, 50, 64, 100, 128)
def _percentile(sorted_vals: list[int], frac: float) -> float:
"""Value at ``frac`` (0..1) through an already-sorted list; robust to spikes."""
if not sorted_vals:
return 0.0
idx = min(len(sorted_vals) - 1, max(0, round(frac * (len(sorted_vals) - 1))))
return float(sorted_vals[idx])
def threshold(samples) -> float:
"""A robust high/low decision level for an ASK/OOK envelope.
The midpoint between the 5th and 95th percentiles, so a few saturated or dropped samples
don't drag it (a plain min/max would, and the duty cycle isn't reliably 50% so the mean
can bias). Returns 0.0 for an empty/flat capture."""
if not samples:
return 0.0
s = sorted(samples)
lo, hi = _percentile(s, 0.05), _percentile(s, 0.95)
return (lo + hi) / 2.0
def is_flat(samples, min_swing: int = 16) -> bool:
"""True if the capture has too little dynamic range to carry data (no tag / dead field)."""
if len(samples) < 8:
return True
s = sorted(samples)
return (_percentile(s, 0.95) - _percentile(s, 0.05)) < min_swing
def binarize(samples, level: float | None = None) -> list[int]:
"""Envelope -> list of 0/1 by thresholding (ASK/OOK)."""
if level is None:
level = threshold(samples)
return [1 if v > level else 0 for v in samples]
def _edges(binary: list[int]) -> list[int]:
return [i for i in range(1, len(binary)) if binary[i] != binary[i - 1]]
def detect_clock(binary: list[int], min_run: int = 3, support: float = 0.2) -> float | None:
"""Estimate the shortest symbol width (samples) from the edge-interval distribution.
For Manchester this is the *half-bit* (there is always a mid-bit transition, so runs are
one or two half-bits); for plain OOK it is the bit itself. Runs shorter than ``min_run``
are treated as noise/jitter and ignored. Returns the mean of the shortest interval cluster,
or ``None`` when there aren't enough clean edges to decide.
The base symbol is the shortest interval with *real support* — a count at least ``support``×
the most-common interval's count — not the raw minimum. A real ADC capture always carries a
few stray sub-bit edges; keying off the bare minimum let a single 3-sample outlier collapse a
clean RF/64 tag's estimate from 32 to ~3 (hardware-observed on a strongly-coupled EM4100),
snapping the data clock to 8 and killing the demod. The dominant interval is robust to that."""
edges = _edges(binary)
if len(edges) < 4:
return None
intervals = [edges[i] - edges[i - 1] for i in range(1, len(edges)) if edges[i] - edges[i - 1] >= min_run]
if len(intervals) < 3:
return None
counts: dict[int, int] = {}
for d in intervals:
counts[d] = counts.get(d, 0) + 1
floor = max(counts.values()) * support
base = min(d for d, n in counts.items() if n >= floor) # shortest well-supported interval
# average the cluster around the base (0.75x1.5x) -> the fundamental symbol width
cluster = [d for d in intervals if base * 0.75 <= d <= base * 1.5]
return sum(cluster) / len(cluster)
def resample(binary: list[int], symbol: float, offset: float = 0.5) -> list[int]:
"""Sample ``binary`` once per ``symbol``-wide window (majority vote), starting at
``offset`` symbols in. Turns a per-sample stream into a per-symbol (e.g. per-half-bit)
stream at the recovered clock."""
if symbol <= 0:
return []
out: list[int] = []
n = len(binary)
pos = offset * symbol
while pos < n:
lo = max(0, int(pos - symbol / 4))
hi = min(n, int(pos + symbol / 4) + 1)
window = binary[lo:hi] or [binary[min(int(pos), n - 1)]]
out.append(1 if sum(window) * 2 >= len(window) else 0)
pos += symbol
return out
def manchester_decode(halfbits: list[int], phase: int = 0, invert: bool = False):
"""Decode a half-bit stream as Manchester: each data bit is a pair of opposite half-bits.
``phase`` (0/1) picks the pairing alignment; ``invert`` swaps the polarity convention
(``10`` -> 1 vs ``01`` -> 1). Pairs that are ``00``/``11`` are clock slips — returned as
``None`` markers so callers can resync. Returns the decoded bit list (with ``None`` gaps).
Callers typically try both phases/polarities and keep the alignment that frames cleanly."""
bits: list[int | None] = []
for i in range(phase, len(halfbits) - 1, 2):
a, b = halfbits[i], halfbits[i + 1]
if a == b:
bits.append(None) # not a valid Manchester symbol -> clock slip
else:
bit = 1 if a == 1 else 0 # (1,0) -> 1, (0,1) -> 0
bits.append(bit ^ 1 if invert else bit)
return bits
def biphase_raw_decode(bits: list[int], offset: int = 0, invert: int = 0):
"""Faithful port of ``lfdemod.c`` ``BiphaseRawDecode`` — decode biphase / differential
Manchester (FDX-B, Indala). Consumes the half-bit stream in pairs: ``01``/``10`` -> ``1``,
``00``/``11`` -> ``0`` (xor ``invert``); a missing bit-boundary transition marks a phase
error (emitted as ``7``). Auto-nudges the pairing offset by one when the first 48 samples
show the other phase is clean. Returns ``(databits, err_count)``."""
n = len(bits)
if n < 51:
return [], -1
if offset < 0:
offset = 0
# phase-fault check: if pairing at offset is faulty but offset+1 is clean, shift by one
offset_a = offset_b = True
i = offset
while i < offset + 48 and i + 3 < n:
if bits[i + 1] == bits[i + 2]:
offset_a = False
if bits[i + 2] == bits[i + 3]:
offset_b = False
i += 2
if not offset_a and offset_b:
offset += 1
out: list[int] = []
err = 0
i = offset
while i < n - 1:
if i + 2 < n and bits[i + 1] == bits[i + 2]:
out.append(7) # missing boundary transition -> phase error
err += 1
a, b = bits[i], bits[i + 1]
if a != b:
out.append(1 ^ invert)
else:
out.append(invert)
i += 2
return out, err
def bits_to_int(bits: list[int]) -> int:
"""MSB-first bit list -> integer."""
v = 0
for b in bits:
v = (v << 1) | (b & 1)
return v

207
pm3py/lf/fsk.py Normal file
View File

@@ -0,0 +1,207 @@
"""FSK demodulation — a faithful Python port of the Proxmark FSK stack.
Ported function-for-function from ``firmware/common/lfdemod.c`` (``fsk_wave_demod`` +
``aggregate_bits`` = ``fskdemod``, ``preambleSearch``, ``HIDdemodFSK``, ``detectAWID``) so it
tracks the C reference — and therefore real-tag output — rather than being a fresh reinvention.
The C thresholds against the signal mean and counts samples between 0->1 transitions: a short
wave (~fc/8) is one symbol, a long wave (~fc/10) the other; ``aggregate_bits`` then collapses
runs of like waves into clock-resolution bits. HID/AWID are FSK2a (fc/10 high, fc/8 low, RF/50).
Everything downstream (Wiegand fields, parity) is validated by the shipped ``HIDProxTag`` model.
"""
from __future__ import annotations
# lfdemod.h NOISE_AMPLITUDE_THRESHOLD — below this peak-to-mean swing the capture is "noise".
# Conservative; a real hardware capture tunes this alongside the ASK path.
NOISE_AMPLITUDE_THRESHOLD = 8
def _mean(samples) -> float:
return sum(samples) / len(samples) if samples else 0.0
def is_noise(samples) -> bool:
"""Port of signalprop.isnoise: too little amplitude above the mean to carry data."""
if len(samples) < 64:
return True
return (max(samples) - _mean(samples)) < NOISE_AMPLITUDE_THRESHOLD
def find_mod_start(src, mean: float, exp_wave: int) -> int:
"""Port of ``findModStart`` — skip leading noise/slow chip start-up to the first clean wave."""
n = len(src)
if n < 21:
return 0
i = 1
wave_cnt = 0
thresh_cnt = 0
above = src[0] >= mean
while i < n - 20:
if src[i] < mean and above:
thresh_cnt += 1
if thresh_cnt > 2 and wave_cnt < exp_wave + 1:
break
above = False
wave_cnt = 0
elif src[i] >= mean and not above:
thresh_cnt += 1
if thresh_cnt > 2 and wave_cnt < exp_wave + 1:
break
above = True
wave_cnt = 0
else:
wave_cnt += 1
if thresh_cnt > 10:
break
i += 1
return i
def fsk_wave_demod(samples, fchigh: int = 10, fclow: int = 8):
"""Port of ``fsk_wave_demod``: envelope -> one bit per FSK wave (1 = short/fc-low wave,
0 = long/fc-high wave), counting samples between consecutive 0->1 transitions. Returns
``(bits, start_idx)``."""
n = len(samples)
if n < 1024:
return [], 0
mean = _mean(samples)
thr = [0 if s < mean else 1 for s in samples]
idx = find_mod_start(samples, mean, fchigh)
last_transition = idx
idx += 1
out: list[int] = []
start_idx = 0
pre_last = last = curr = 0
while idx < n - 20:
if thr[idx - 1] < thr[idx]: # 0 -> 1 transition
pre_last, last, curr = last, curr, idx - last_transition
if curr < fclow - 2:
pass # garbage noise
elif curr < fchigh - 1: # short wave -> 1
if len(out) > 1 and last > fchigh - 2 and pre_last < fchigh - 1:
out[-1] = 1 # fix a 9 surrounded by 8s
out.append(1)
if out and start_idx == 0:
start_idx = idx - fclow
elif curr > fchigh + 1 and len(out) < 3:
out = [] # leading garbage -> reset
elif curr == fclow + 1 and last == fclow - 1: # 7 then 9 -> two 8s
out.append(1)
if out and start_idx == 0:
start_idx = idx - fclow
else: # long wave -> 0
out.append(0)
if out and start_idx == 0:
start_idx = idx - fchigh
last_transition = idx
idx += 1
return out, start_idx
def aggregate_bits(bits, clk: int, invert: int, fchigh: int, fclow: int):
"""Port of ``aggregate_bits``: collapse runs of like waves into clock-resolution data bits
(a run of n waves at field-clock fc spans n*fc/clk bits)."""
if not bits:
return []
out: list[int] = []
lastval = bits[0]
n = 1
hclk = clk // 2
i = 1
for i in range(1, len(bits)):
n += 1
if bits[i] == lastval:
continue
if bits[i - 1] == 1:
n = (n * fclow + hclk) // clk
else:
n = (n * fchigh + hclk) // clk
if n == 0:
n = 1
out.extend([bits[i - 1] ^ invert] * n)
n = 0
lastval = bits[i]
if n > clk // fchigh and len(bits) >= 2: # trailing same-frequency run
if bits[i - 1] == 1:
n = (n * fclow + hclk) // clk
else:
n = (n * fchigh + hclk) // clk
out.extend([bits[-1] ^ invert] * n)
return out
def fskdemod(samples, rflen: int = 50, invert: int = 1, fchigh: int = 10, fclow: int = 8):
"""Full FSK demod: envelope -> decoded 1s/0s (``fsk_wave_demod`` + ``aggregate_bits``)."""
if is_noise(samples):
return []
waves, _ = fsk_wave_demod(list(samples), fchigh, fclow)
return aggregate_bits(waves, rflen, invert, fchigh, fclow)
def preamble_search(bits, preamble):
"""Port of ``preambleSearch``: find ``preamble`` in ``bits``. Returns ``(found, start_idx,
size)`` where ``size`` is the gap to the second occurrence (the frame length) when there is
one, else the remaining length."""
plen = len(preamble)
size = len(bits)
if size <= plen:
return (False, 0, size)
found = 0
start = 0
for idx in range(size - plen):
if bits[idx:idx + plen] == preamble:
found += 1
if found == 1:
start = idx
elif found == 2:
return (True, start, idx - start)
return (found > 0, start, size)
# HID: 00011101 start-of-frame, then Manchester (10 -> 1, 01 -> 0)
HID_PREAMBLE = [0, 0, 0, 1, 1, 1, 0, 1]
# AWID: 00000001 preamble, 96-bit frame
AWID_PREAMBLE = [0, 0, 0, 0, 0, 0, 0, 1]
_M32 = 0xFFFFFFFF
def hid_demod_fsk(samples):
"""Port of ``HIDdemodFSK``: FSK2a demod + preamble + Manchester -> ``(hi2, hi, lo, nbits)``
96-bit shift register plus the number of data bits Manchester-decoded (needed to tell the
header-less 37-bit format from the 26-bit one), or ``None``. Field extraction is the caller's."""
bits = fskdemod(samples, 50, 1, 10, 8)
if len(bits) < 96 * 2:
return None
found, start, size = preamble_search(bits, HID_PREAMBLE)
if not found:
return None
num_start = start + len(HID_PREAMBLE)
hi2 = hi = lo = 0
nbits = 0
idx = num_start
while (idx - num_start) < size - len(HID_PREAMBLE) and idx + 1 < len(bits):
a, b = bits[idx], bits[idx + 1]
if a == b:
break # not Manchester -> end of frame
hi2 = ((hi2 << 1) | (hi >> 31)) & _M32
hi = ((hi << 1) | (lo >> 31)) & _M32
lo = ((lo << 1) | (1 if (a and not b) else 0)) & _M32
nbits += 1
idx += 2
if hi == 0 and lo == 0:
return None
return (hi2, hi, lo, nbits)
def awid_demod_fsk(samples):
"""Port of ``detectAWID``: FSK2a demod + 00000001 preamble -> the 96 frame bits, or ``None``."""
bits = fskdemod(samples, 50, 1, 10, 8)
if len(bits) < 96:
return None
found, start, size = preamble_search(bits, AWID_PREAMBLE)
if not found or size != 96:
return None
return bits[start:start + 96]

301
pm3py/lf/protocols.py Normal file
View File

@@ -0,0 +1,301 @@
"""Per-tag LF framing: bit stream -> decoded credential.
Each decoder takes a raw envelope, runs the generic :mod:`pm3py.lf.dsp` pipeline (trying both
Manchester phase/polarity alignments), and validates candidate frames with the *transponder
model's own* checker — ``EM4100Code.valid``, ``T5577Config`` sanity — so framing rules live in
exactly one place. A decoder returns a structured dict on a clean, validated frame, else ``None``.
"""
from __future__ import annotations
from . import dsp
from . import fsk
from . import ask
# Import the models through the ``pm3py.sim`` package (not their deep module paths): sim's
# __init__ re-exports them, and going through it forces sim to fully initialize first, avoiding
# a circular import (em4100 -> pm3py.sim.frame -> sim.__init__ -> em4100) when pm3py.lf is the
# first thing loaded.
from pm3py.sim import EM4100Code, T5577Config, HIDProxTag
# config word -> preset name (invert T5577Config._PRESETS) for "emulating ..." labelling
_PRESET_BY_WORD = {word: name for name, word in T5577Config._PRESETS.items()}
def _manchester_bits(samples):
"""Yield candidate data-bit streams for an ASK/Manchester envelope. Delegates to the faithful
C ASK port (:mod:`pm3py.lf.ask`) — hysteresis rail-to-rail demod that survives the asymmetric,
ramped, sometimes-saturated shape of real LF captures, where a naive resample bit-slips."""
yield from ask.manchester_bits(samples)
def decode_em4100(samples) -> dict | None:
"""Demodulate an EM4100/EM4102 envelope (ASK + Manchester) to its 40-bit ID.
Searches the demodulated stream for a 64-bit frame whose header, ten row parities, four
column parities and stop bit all check out (``EM4100Code.valid``) — the frame repeats, so a
clean copy is found even if the capture starts mid-frame."""
for bits in _manchester_bits(samples):
code = _find_em4100(bits)
if code is not None:
return {
"protocol": "EM4100",
"tag_id": code.tag_id,
"id_hex": f"{code.tag_id:010X}",
"customer_id": code.customer_id,
"card_number": code.card_number,
}
return None
def _find_em4100(bits) -> EM4100Code | None:
n = len(bits)
for start in range(max(0, n - 63)):
window = bits[start:start + 64]
if len(window) < 64 or None in window:
continue
code = EM4100Code(dsp.bits_to_int(window))
if code.valid:
return code
return None
def _hid_field(full: int, fmt: str, nbits: int) -> dict | None:
"""Extract the low ``nbits`` as a ``fmt`` Wiegand credential, accepting only if re-encoding
the decoded facility+card reproduces the bits exactly (proves both parities)."""
wiegand = [(full >> (nbits - 1 - i)) & 1 for i in range(nbits)]
dec = HIDProxTag.decode_wiegand(wiegand, fmt)
fc, cn = dec["facility_code"], dec["card_number"]
if HIDProxTag._encode_wiegand(fc, cn, fmt) != wiegand:
return None
return {
"protocol": "HID Prox", "format": fmt, "facility_code": fc, "card_number": cn,
"wiegand": f"{full & ((1 << nbits) - 1):0{(nbits + 3) // 4}X}",
}
def decode_hid(samples) -> dict | None:
"""Demodulate an HID Prox envelope (FSK2a + Manchester) to its Wiegand credential.
Format is told by frame *structure*, not parity alone (2 parity bits can't separate 26- from
37-bit): the 26-bit H10301 wire frame carries a 0x20 header at bit 37 and a sentinel 1 at
bit 26 with the Wiegand below; the 37-bit H10304 frame is header-less, so a 37-bit Manchester
count with no 0x20 marks it. Each candidate is still parity-validated by re-encoding through
the shipped ``HIDProxTag``."""
r = fsk.hid_demod_fsk(samples)
if r is None:
return None
hi2, hi, lo, nbits = r
full = (hi << 32) | lo # low 64 bits cover 26- and 37-bit formats
if (hi & 0x20) and (lo & (1 << 26)): # 26-bit standard header + sentinel
got = _hid_field(full, "H10301", 26)
if got:
return got
if nbits == 37 and not (hi & 0x20): # header-less 37-bit frame
got = _hid_field(full, "H10304", 37)
if got:
return got
return None
# AWID format length -> (facility offset/width, card offset/width) in the 66 de-parity'd bits
# (cmdlfawid.c index map). fmtLen is bits[0:8]; bit 8 is a Wiegand parity.
_AWID_FORMATS = {
26: ((9, 8), (17, 16)),
34: ((9, 8), (17, 24)),
36: ((14, 11), (25, 18)),
37: ((9, 13), (22, 18)),
50: ((9, 16), (25, 32)),
}
def _strip_parity(bits, group: int = 4, want_odd: bool = True):
"""Port of ``removeParity`` for AWID: each ``group`` bits are (group-1) data + 1 parity;
verify the parity and keep the data. Returns the compacted bits, or ``None`` on a parity
fault (the check that makes AWID trustworthy without a CRC)."""
out = []
for i in range(0, len(bits) - group + 1, group):
g = bits[i:i + group]
if (sum(g) & 1) != (1 if want_odd else 0):
return None
out.extend(g[:group - 1])
return out
def decode_awid(samples) -> dict | None:
"""Demodulate an AWID envelope (FSK2a, no Manchester) to its Wiegand credential.
``detectAWID`` gives the 96 frame bits; the 88 after the preamble are 22 groups of 3 data +
1 odd-parity bit, so stripping parity yields 66 bits whose first byte is the format length
(26/34/36/37/50). The 22 parity checks stand in for AWID's missing CRC — a bad frame fails
them — so this doesn't false-positive."""
frame = fsk.awid_demod_fsk(samples)
if frame is None:
return None
data = _strip_parity(frame[8:8 + 88], group=4, want_odd=True)
if data is None or len(data) != 66:
return None
fmt_len = dsp.bits_to_int(data[0:8])
spec = _AWID_FORMATS.get(fmt_len)
if spec is None:
return None
(fc_off, fc_w), (cn_off, cn_w) = spec
fc = dsp.bits_to_int(data[fc_off:fc_off + fc_w])
cardnum = dsp.bits_to_int(data[cn_off:cn_off + cn_w])
return {
"protocol": "AWID",
"format": f"AWID-{fmt_len}",
"facility_code": fc,
"card_number": cardnum,
}
def _reflect(v: int, width: int) -> int:
r = 0
for i in range(width):
if v & (1 << i):
r |= 1 << (width - 1 - i)
return r
def crc16(data: bytes, init: int = 0, poly: int = 0x1021, refin: bool = False,
refout: bool = False) -> int:
"""Parametric bitwise CRC-16 (Rocksoft model), matching the firmware ``crc16_fast``."""
crc = init
for byte in data:
b = _reflect(byte, 8) if refin else byte
crc ^= b << 8
for _ in range(8):
crc = ((crc << 1) ^ poly) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
return _reflect(crc, 16) if refout else crc
def crc16_fdxb(data: bytes) -> int:
"""FDX-B (ISO 11784/85) CRC: poly 0x1021, init 0, refin=false, refout=true."""
return crc16(data, 0x0000, 0x1021, refin=False, refout=True)
_FDXB_PREAMBLE = [0] * 10 + [1] # ten 0x00 then 0x01
def decode_fdxb(samples) -> dict | None:
"""Demodulate an FDX-B (ISO 11784/85) envelope — ASK + biphase, 128-bit frame.
After biphase decode we find the ``00000000001`` preamble, de-interleave the 8-bit data
groups (every 9th bit is a control 1), and reassemble the 10-bit country + 38-bit national
code. Accepted only if the CRC-16 over the eight data bytes matches the stored one — a strong
check, so FDX-B never false-positives. Tries both biphase polarities."""
if dsp.is_flat(samples):
return None
binary = dsp.binarize(samples)
half = dsp.detect_clock(binary)
if not half:
return None
raw = dsp.resample(binary, half) # half-bit-resolution ASK bits
for invert in (0, 1):
bits, _err = dsp.biphase_raw_decode(raw, 0, invert)
got = _fdxb_frame(bits)
if got is not None:
return got
return None
def _fdxb_frame(bits) -> dict | None:
n = len(bits)
for start in range(max(0, n - 128)):
if bits[start:start + 11] != _FDXB_PREAMBLE:
continue
frame = bits[start:start + 128]
if len(frame) < 100 or 7 in frame[:100]: # need the data+CRC region clean
continue
got = _fdxb_parse(frame)
if got is not None:
return got
return None
def _group(frame, i: int) -> int | None:
"""The i-th 8-bit data group (LSB-first), skipping the control bit before each group."""
seg = frame[11 + i * 9: 11 + i * 9 + 8]
if len(seg) < 8 or 7 in seg:
return None
return sum(bit << k for k, bit in enumerate(seg))
def _fdxb_parse(frame) -> dict | None:
groups = [_group(frame, i) for i in range(10)] # 0-7 data, 8-9 CRC
if any(g is None for g in groups):
return None
national = (groups[0] | groups[1] << 8 | groups[2] << 16 | groups[3] << 24
| (groups[4] & 0x3F) << 32)
country = ((groups[4] >> 6) | (groups[5] << 2)) & 0x3FF
stored_crc = groups[8] | (groups[9] << 8)
if stored_crc != crc16_fdxb(bytes(groups[0:8])):
return None # CRC mismatch -> not a valid FDX-B frame
return {
"protocol": "FDX-B",
"country_code": country,
"national_code": national,
"id": f"{country:03d}-{national:012d}",
"animal": bool(frame[81]),
}
def decode_t55xx_config(samples) -> dict | None:
"""Recover a T55xx block-0 config from a block-0 read response (ASK/Manchester modes).
A block read streams the 32-bit config with no header, so the demod recovers it at *some* bit
rotation. We collect every 32-bit word that repeats one block-period later and accept one only
when a bit-rotation of it exactly matches a known config preset (EM4100, HID, Indala, FDX-B,
Viking, default). That both resolves the rotation ambiguity (a real capture came back as
0x000A4020 = the EM4100 word 0x00148040 rotated by one) and rejects the spurious repeats
(all-0, all-1, a rotation of the tag's own emission) that a bare "looks like a sane config"
score waved through as false positives. Returns ``None`` when nothing rotates to a preset —
the emitted-stream decode carries the result instead."""
for bits in _manchester_bits(samples):
clean = [b for b in bits if b is not None]
for word in _repeating_words(clean, 32):
for rot in range(32):
rv = ((word << rot) | (word >> (32 - rot))) & 0xFFFFFFFF
if rv in _PRESET_BY_WORD:
cfg = T5577Config(rv)
return {
"protocol": "T5577",
"config": rv,
"config_hex": f"{rv:08X}",
"modulation": cfg.modulation if isinstance(cfg.modulation, str)
else f"0x{cfg.modulation:02X}",
"data_bit_rate": cfg.data_bit_rate,
"max_block": cfg.max_block,
"emulating": emulation_name(rv, cfg),
}
return None
def _repeating_words(bits, width: int):
"""Yield the distinct ``width``-bit windows that equal the next ``width`` bits (a block-read
candidate repeating one block-period later)."""
n = len(bits)
seen = set()
for start in range(max(0, n - 2 * width)):
w = bits[start:start + width]
if w == bits[start + width:start + 2 * width]:
val = dsp.bits_to_int(w)
if val not in seen:
seen.add(val)
yield val
def emulation_name(word: int, cfg: T5577Config | None = None) -> str:
"""Human label for what a T5577 config emulates: an exact preset match if we have one
(EM4100, HID, Indala, FDX-B, Viking, default), else the modulation/rate family."""
exact = _PRESET_BY_WORD.get(word)
if exact:
return {"em4100": "EM4100 / EM4102", "hid": "HID Prox", "indala": "Indala",
"fdxb": "FDX-B", "viking": "Viking", "default": "raw / default"}.get(exact, exact)
cfg = cfg or T5577Config(word)
mod = cfg.modulation if isinstance(cfg.modulation, str) else "?"
family = ("EM/ASK" if mod == "ASK"
else "HID/AWID" if mod.startswith("FSK")
else "Indala/PSK" if mod.startswith("PSK")
else "FDX-B/Nedap" if "biphase" in mod
else mod)
return f"{family} · {mod} RF/{cfg.data_bit_rate}"

206
pm3py/pyws_plugin.py Normal file
View File

@@ -0,0 +1,206 @@
"""pyws workspace plugin for pm3py.
Turns pm3py into a pyws workspace: connects the Proxmark3 (reader-mode) or opens a live
``SimSession`` (sim-mode), and injects the transponder / sim classes into the session
namespace. Discovered by the pyws engine via the ``pyws.plugins`` entry point (see
pyproject.toml). pyws must be installed alongside pm3py.
A workspace uses one connection mode — ``reader`` OR ``sim`` — never both, since they contend
on the serial port. The sim is armed explicitly (``sim.start(tag)``); opening the workspace
only opens the port. See docs/plans/2026-07-05-pm3py-pyws-plugin-plan.md.
"""
from __future__ import annotations
import logging
from typing import Any
from pyws import Resource, ResourceStatus, WorkspacePlugin
log = logging.getLogger("pm3py.pyws")
#: names injected into the workspace namespace from pm3py.sim (guarded by hasattr)
_SIM_EXPORTS = [
"SimSession",
"NTAG210", "NTAG212", "NTAG213", "NTAG215", "NTAG216",
"MifareUltralight", "MifareUltralightC", "MifareUltralightEV1",
"MifareClassicTag", "NfcType2Tag", "NfcType4Tag",
"Tag15693", "NfcType5Tag", "IcodeSlix2Tag",
"xNT", "xM1", "NExT", "FlexDF",
"ndef_uri", "ndef_text", "ndef_mime",
]
class ReaderResource(Resource):
"""A connected ``Proxmark3`` client, exposed as ``reader`` in the namespace.
Opens the device on session start and drops it on stop. A missing / unreachable device is
non-fatal — the resource reports ``disconnected`` so you can still work offline. ``client``
is injectable for tests.
"""
kind = "reader"
def __init__(self, name: str = "reader", config: dict | None = None, *, client: Any = None):
super().__init__(name, obj=None, config=config or {})
self._client = client
async def start(self) -> None:
self.status = ResourceStatus.CONNECTING
try:
if self._client is None:
from pm3py import Proxmark3
self._client = Proxmark3(
self.config.get("port"), self.config.get("baudrate", 115200)
)
await self._client.connect()
except Exception as exc: # no device / connect failure — non-fatal
self.status = ResourceStatus.DISCONNECTED
self.detail = f"no device ({type(exc).__name__}: {exc})"
self._client = None
return
self.obj = self._client
self.status = ResourceStatus.CONNECTED
fw = getattr(self._client, "firmware", None)
self.detail = str(getattr(fw, "version_string", "") or "connected")
async def stop(self) -> None:
if self._client is not None:
try:
await self._client.disconnect()
except Exception:
pass
self.status = ResourceStatus.DISCONNECTED
class Sim:
"""User-facing live-sim controls, bound as ``sim`` in the namespace.
Arming is explicit: opening the workspace opens the port, but the sim goes live only when
you call ``sim.start(tag)``. Push model edits to a running sim with ``sim.push(tag)``.
"""
def __init__(self, session: Any):
self._sess = session
def start(self, tag: Any, **kwargs: Any):
"""Arm the live sim for ``tag`` — dispatches 14a vs 15693 by tag type."""
from pm3py.sim import Tag14443A, Tag15693
if isinstance(tag, Tag15693):
return self._sess.start_15693(tag, **kwargs)
if isinstance(tag, Tag14443A):
return self._sess.start_14a(tag, **kwargs)
raise TypeError(f"cannot simulate {type(tag).__name__}: not a 14a or 15693 tag")
def stop(self):
return self._sess.stop()
def push(self, tag: Any):
"""Push model edits to the running sim (``tag.sync()``)."""
return tag.sync()
@property
def frames(self):
"""Decoded reader↔tag trace frames from the running sim."""
return self._sess.entries
@property
def session(self):
return self._sess
def __repr__(self) -> str:
return f"<Sim session={self._sess!r}>"
class SimResource(Resource):
"""A live ``SimSession``, exposed as ``sim`` in the namespace.
Opens the port on session start; the sim is armed explicitly via ``sim.start(tag)``. A
missing device is non-fatal (disconnected). ``session`` is injectable for tests.
"""
kind = "sim"
def __init__(self, name: str = "sim", config: dict | None = None, *, session: Any = None):
super().__init__(name, obj=None, config=config or {})
self._sess = session
async def start(self) -> None:
self.status = ResourceStatus.CONNECTING
try:
if self._sess is None:
from pm3py.sim import SimSession
self._sess = SimSession.open(
self.config.get("port", "/dev/ttyACM0"),
self.config.get("baudrate", 115200),
)
except Exception as exc: # no device — non-fatal
self.status = ResourceStatus.DISCONNECTED
self.detail = f"no device ({type(exc).__name__}: {exc})"
self._sess = None
return
self.obj = Sim(self._sess)
self.status = ResourceStatus.CONNECTED
self.detail = "ready (not armed — call sim.start(tag))"
async def stop(self) -> None:
if self._sess is not None:
try:
self._sess.close() # stop() + close serial + unbind tag
except Exception:
pass
self.status = ResourceStatus.DISCONNECTED
class Pm3pyPlugin(WorkspacePlugin):
name = "pm3py"
version = "0.1.0"
#: the first connection resource created wins the serial port (reader OR sim, not both)
_mode: str | None = None
#: replay-safety effect patterns (pyws safe-by-default deny-list): write / field / sim /
#: destructive are withheld on autoreplay; connect / read and pure model edits replay.
#: Patterns are matched against the FULL dotted call path (e.g. reader.hf.mfu.wrbl).
effects = {
"connect": ["reader.connect", "reader.disconnect", "sim.open", "sim.close"],
"read": [
"reader.hf.*.scan", "reader.hf.*.rdbl", "reader.hf.*.rdsc",
"reader.hf.mfu.rdbl", "reader.hf.*.inventory", "reader.lf.*.read", "*.read_block*",
],
"field": ["reader.hf.tune", "reader.hf.dropfield", "reader.lf.tune", "reader.hf.search"],
"write": [
"reader.hf.*.wrbl", "reader.hf.*.writebl", "reader.lf.*.writebl",
"*.sync", "sim.push", "*.write_block*",
],
"sim": ["sim.start", "sim.stop", "sim.start_14a", "sim.start_15693", "reader.hf.*.sim"],
"destructive": ["*.format*", "*.lock*"],
}
def namespace(self, session) -> dict[str, Any]:
import pm3py
import pm3py.sim as sim
ns: dict[str, Any] = {"Proxmark3": pm3py.Proxmark3}
for name in _SIM_EXPORTS:
obj = getattr(sim, name, None)
if obj is not None:
ns[name] = obj
return ns
def create_resource(self, name: str, config: dict, session):
if name in ("reader", "sim"):
if self._mode is not None and self._mode != name:
log.warning(
"pm3py: workspace declares both %r and %r, which contend on the serial "
"port; skipping %r",
self._mode, name, name,
)
return None
self._mode = name
if name == "reader":
return ReaderResource(name, config)
if name == "sim":
return SimResource(name, config)
return None

View File

@@ -16,29 +16,27 @@ from .mcu_bridge import McuBridge
from .dual_session import DualInterfaceSession
# --- Transponder models (canonical home: pm3py.transponders) ---
from pm3py.transponders.hf.iso14443a.base import (
Tag14443A, Tag14443A_3, Tag14443A_4, Reader14443A, State14443A,
)
from pm3py.transponders.hf.iso14443a.nxp.crypto1 import Crypto1
from pm3py.transponders.hf.iso14443a.nxp.mifare_classic import MifareClassicTag, MifareClassicReader
from pm3py.transponders.hf.iso14443a.nxp.desfire import DesfireTag, DesfireReader
from pm3py.transponders.hf.iso14443a.ndef import NfcType2Tag, NfcType4Tag
from pm3py.transponders.hf.iso15693.base import Tag15693, Reader15693, State15693
from pm3py.transponders.hf.iso15693.type5 import NfcType5Tag, ndef_text, ndef_uri, ndef_mime
from pm3py.transponders.hf.iso15693.nxp.nxp_icode import NxpIcodeTag
from pm3py.transponders.hf.iso15693.nxp.icode_slix import IcodeSlixTag
from pm3py.transponders.hf.iso15693.nxp.icode_slix2 import IcodeSlix2Tag
from pm3py.transponders.hf.iso15693.nxp.icode3 import Icode3Tag
from pm3py.transponders.hf.iso15693.nxp.icode_dna import IcodeDnaTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_platform import Ntag5PlatformTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_switch import Ntag5SwitchTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_link import Ntag5LinkTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_boost import Ntag5BoostTag
from pm3py.transponders.lf.base import TagLF, ReaderLF, Modulation
from pm3py.transponders.lf.em.em4100 import EM4100Tag, EM4100Reader
from pm3py.transponders.lf.hid.hid import HIDProxTag, HIDReader
from pm3py.transponders.lf.atmel.t5577 import T5577Tag, T5577Reader
from pm3py.transponders.implants import xEM, xNT, xM1, FlexDF, NExT, MagicMifareClassicTag
# Loaded lazily (PEP 562) from ._models. Those models import back into pm3py.sim for the
# Transponder/Reader/RFFrame/Medium primitives, so importing them at package-init time would
# create a transponders<->sim circular import (breaking e.g. `from pm3py.transponders...` as a
# first import). On first access they load from ._models and cache into this module's globals.
def __getattr__(name):
if name.startswith("__") and name.endswith("__"):
raise AttributeError(name) # don't trigger a model import for dunder probes
import importlib
models = importlib.import_module(f"{__name__}._models")
try:
value = getattr(models, name)
except AttributeError:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
globals()[name] = value # cache: subsequent access is a direct lookup
return value
def __dir__():
return sorted(set(globals()) | set(__all__))
__all__ = [
"RFFrame",
@@ -48,17 +46,39 @@ __all__ = [
"Reader", "ScriptedReader", "InteractiveReader", "ReaderStep", "StepResult",
"Tag14443A", "Tag14443A_3", "Tag14443A_4", "Reader14443A", "State14443A",
"Crypto1",
"MifareClassicTag", "MifareClassicReader",
"MifareClassicTag", "MifareClassicReader", "MifareAccessConditions",
"Tag15693", "Reader15693", "State15693",
"TagLF", "ReaderLF", "Modulation",
"EM4100Tag", "EM4100Reader",
"EM4100Tag", "EM4100Reader", "EM4100Code",
"HIDProxTag", "HIDReader",
"T5577Tag", "T5577Reader",
"T5577Tag", "T5577Reader", "T5577Config",
"Hitag2Tag", "Hitag2Reader",
"BitField", "Register",
"NfcType2Tag", "NfcType4Tag", "NfcType5Tag",
"NxpType2Tag", "Type2Config",
"Ntag21x", "NTAG210", "NTAG212", "NTAG213", "NTAG215", "NTAG216", "Ntag21xConfig",
"MifareUltralight", "MifareUltralightC", "MifareUltralightEV1",
"MF0UL11", "MF0UL21", "UL_C_DEFAULT_KEY",
"UltralightEV1Config", "UltralightCConfig",
"NtagI2C", "NT3H2111", "NT3H2211",
"NtagI2CConfig", "NtagI2CSession", "NtagI2CPtI2C",
"ST25TN", "ST25TN512", "ST25TN01K",
"ST25TA", "ST25TA512B", "ST25TA02KB", "ST25TA16K", "ST25TA64K",
"OptigaAuthenticateNBT",
"ST25TV", "ST25TV512C", "ST25TV02KC",
"ST25DV", "ST25DV04K", "ST25DV16K", "ST25DV64K", "St25dvRfAreaSS",
"MydVicinity", "SRF55V02P", "SRF55V10P",
"Tag14443B", "StateB", "Tag14443B_4", "StateB4",
"ST25TB", "ST25TB512AC", "ST25TB512AT", "ST25TB02K", "ST25TB04K",
"RF430CL330H",
"TagItHFIPlus",
"RF430FRL15xH", "RF430FRL152H", "RF430FRL153H", "RF430FRL154H",
"ndef_text", "ndef_uri", "ndef_mime",
"NxpIcodeTag", "IcodeSlixTag", "IcodeSlix2Tag",
"Icode3Tag", "IcodeDnaTag",
"Ntag5PlatformTag", "Ntag5SwitchTag", "Ntag5LinkTag", "Ntag5BoostTag",
"Ntag5StatusReg", "Ntag5ConfigReg",
"NxpKeyPrivileges", "IcodeFeatureFlags",
"DesfireTag", "DesfireReader",
"xEM", "xNT", "xM1", "FlexDF", "NExT", "MagicMifareClassicTag",
"MutationFuzzer", "GrammarFuzzer",

71
pm3py/sim/_models.py Normal file
View File

@@ -0,0 +1,71 @@
"""Transponder-model re-exports for :mod:`pm3py.sim`.
Loaded lazily by the package's PEP 562 ``__getattr__`` — these models import back into
``pm3py.sim`` (for the ``Transponder`` / ``Reader`` / ``RFFrame`` / ``Medium`` primitives), so
importing them at ``sim`` package-init time creates a circular import. Their canonical home is
``pm3py.transponders``; this module just re-exposes them under ``pm3py.sim``.
"""
from __future__ import annotations
from pm3py.transponders.hf.iso14443a.base import (
Tag14443A, Tag14443A_3, Tag14443A_4, Reader14443A, State14443A,
)
from pm3py.transponders.hf.iso14443a.nxp.crypto1 import Crypto1
from pm3py.transponders.hf.iso14443a.nxp.mifare_classic import (
MifareClassicTag, MifareClassicReader, MifareAccessConditions,
)
from pm3py.transponders.hf.iso14443a.nxp.desfire import DesfireTag, DesfireReader
from pm3py.transponders.hf.iso14443a.ndef import NfcType2Tag, NfcType4Tag
from pm3py.transponders.hf.iso14443a.nxp.type2 import NxpType2Tag, Type2Config
from pm3py.transponders.hf.iso14443a.nxp.ntag21x import (
Ntag21x, NTAG210, NTAG212, NTAG213, NTAG215, NTAG216, Ntag21xConfig,
)
from pm3py.transponders.hf.iso14443a.nxp.ultralight import (
MifareUltralight, MifareUltralightC, MifareUltralightEV1, MF0UL11, MF0UL21,
UL_C_DEFAULT_KEY, UltralightEV1Config, UltralightCConfig,
)
from pm3py.transponders.hf.iso14443a.nxp.ntag_i2c import (
NtagI2C, NT3H2111, NT3H2211, NtagI2CConfig, NtagI2CSession, NtagI2CPtI2C,
)
from pm3py.transponders.hf.iso14443a.st.st25tn import ST25TN, ST25TN512, ST25TN01K
from pm3py.transponders.hf.iso14443a.infineon.optiga_nbt import OptigaAuthenticateNBT
from pm3py.transponders.hf.iso14443a.st.st25ta import (
ST25TA, ST25TA512B, ST25TA02KB, ST25TA16K, ST25TA64K,
)
from pm3py.transponders.hf.iso15693.base import Tag15693, Reader15693, State15693
from pm3py.transponders.hf.iso15693.type5 import NfcType5Tag, ndef_text, ndef_uri, ndef_mime
from pm3py.transponders.hf.iso15693.nxp.nxp_icode import NxpIcodeTag
from pm3py.transponders.hf.iso15693.nxp.icode_slix import IcodeSlixTag
from pm3py.transponders.hf.iso15693.nxp.icode_slix2 import IcodeSlix2Tag
from pm3py.transponders.hf.iso15693.nxp.icode3 import Icode3Tag, IcodeFeatureFlags
from pm3py.transponders.hf.iso15693.nxp.icode_dna import IcodeDnaTag
from pm3py.transponders.hf.iso15693.nxp.auth_aes import NxpKeyPrivileges
from pm3py.transponders.hf.iso15693.nxp.ntag5_platform import (
Ntag5PlatformTag, Ntag5StatusReg, Ntag5ConfigReg,
)
from pm3py.transponders.hf.iso15693.nxp.ntag5_switch import Ntag5SwitchTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_link import Ntag5LinkTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_boost import Ntag5BoostTag
from pm3py.transponders.hf.iso15693.st.st25tv import ST25TV, ST25TV512C, ST25TV02KC
from pm3py.transponders.hf.iso15693.st.st25dv import (
ST25DV, ST25DV04K, ST25DV16K, ST25DV64K, St25dvRfAreaSS,
)
from pm3py.transponders.hf.iso15693.infineon.myd_vicinity import (
MydVicinity, SRF55V02P, SRF55V10P,
)
from pm3py.transponders.hf.iso14443b.base import Tag14443B, StateB, Tag14443B_4, StateB4
from pm3py.transponders.hf.iso14443b.st.st25tb import (
ST25TB, ST25TB512AC, ST25TB512AT, ST25TB02K, ST25TB04K,
)
from pm3py.transponders.hf.iso14443b.ti.rf430cl330h import RF430CL330H
from pm3py.transponders.hf.iso15693.ti.tagit import TagItHFIPlus
from pm3py.transponders.hf.iso15693.ti.rf430frl import (
RF430FRL15xH, RF430FRL152H, RF430FRL153H, RF430FRL154H,
)
from pm3py.transponders.lf.base import TagLF, ReaderLF, Modulation
from pm3py.transponders.lf.em.em4100 import EM4100Tag, EM4100Reader, EM4100Code
from pm3py.transponders.lf.hid.hid import HIDProxTag, HIDReader
from pm3py.transponders.bitfield import BitField, Register
from pm3py.transponders.lf.atmel.t5577 import T5577Tag, T5577Reader, T5577Config
from pm3py.transponders.lf.nxp.hitag2 import Hitag2Tag, Hitag2Reader
from pm3py.transponders.implants import xEM, xNT, xM1, FlexDF, NExT, MagicMifareClassicTag

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.auth_aes import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.auth_password import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso14443a.nxp.crypto1 import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso14443a.nxp.desfire import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.lf.em.em4100 import * # noqa: F401,F403

View File

@@ -6,7 +6,6 @@ from typing import Iterator
from bitarray import bitarray
from .frame import RFFrame
from .iso14443a import CL1, NVB_SELECT, _compute_bcc
class MutationFuzzer:
@@ -76,6 +75,9 @@ class GrammarFuzzer:
return None
def _gen_14443a(self, command: str, **overrides) -> RFFrame | None:
# deferred: iso14443a.base imports back into pm3py.sim, so a module-level import here would
# create a transponders<->sim circular import when 14443a is the first thing imported
from pm3py.transponders.hf.iso14443a.base import CL1, NVB_SELECT, _compute_bcc
match command:
case "REQA":
return RFFrame.from_bytes(b"\x26")

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.lf.hid.hid import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.icode3 import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.icode_dna import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.icode_slix import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.icode_slix2 import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.implants import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso14443a.base import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.base import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.lf.base import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso14443a.nxp.mifare_classic import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso14443a.ndef import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.ntag5_boost import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.ntag5_link import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.ntag5_platform import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.ntag5_switch import * # noqa: F401,F403

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.nxp_icode import * # noqa: F401,F403

View File

@@ -10,7 +10,9 @@ import serial
from .frame import RFFrame
from .table_compiler import ResponseTable, TableCompiler
from .trace_fmt import TraceFormatter
from pm3py.trace.format import TraceFormatter
from pm3py.trace.decode_iso14a import decode_14443a
from pm3py.trace.output import emit
from .transponder import Transponder
from ..core.protocol import Cmd
from ..core.transport import encode_ng_frame, decode_response_frame, RESP_PREAMBLE_MAGIC, RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE
@@ -20,6 +22,20 @@ CMD_SIM_TABLE_UPLOAD = 0x0950
CMD_SIM_TABLE_CLEAR = 0x0951
CMD_SIM_TABLE_UPDATE = 0x0952
CMD_HF_ISO15693_SIM_TRACE = 0x0336
CMD_HF_ISO14443A_SIM_TRACE = 0x0339
# 14a sim flag bit (pm3_cmd.h FLAG_SIM_TRACE): stream live reader<->tag trace.
FLAG_SIM_TRACE = 0x2000
# Sim-trace command id -> protocol tag used in trace entries (0 = ISO 15693,
# 1 = ISO 14443-A). The firmware ring format is identical across protocols:
# byte 0 = dir (low nibble) | crc_fail (0x80) | 0x03 ADC report (15693 only),
# followed by the raw frame bytes. The active protocol's decoder lives in the
# session formatter, so this only needs the protocol tag.
_TRACE_CMDS = {
CMD_HF_ISO15693_SIM_TRACE: 0,
CMD_HF_ISO14443A_SIM_TRACE: 1,
}
# Max NG payload per frame
MAX_PAYLOAD = 512
@@ -50,6 +66,10 @@ class SimSession:
self._tag_model: Transponder | None = None
self._formatter: TraceFormatter | None = None
self.on_field_strength: callable | None = None # callback(adc_mv: int)
self._entries: list[dict] = []
self._on_frame = None
self._quiet = False
self._print_frames = False
@classmethod
def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SimSession":
@@ -94,35 +114,118 @@ class SimSession:
"""Clear the firmware response table."""
await self._t.send_ng(CMD_SIM_TABLE_CLEAR, b"")
async def start_14a(self, tag, compile: bool = True) -> None:
"""Compile table, upload, start 14443-A sim with WTX relay loop."""
self._tag_model = tag
if compile:
if hasattr(tag, '_keys_a'):
table = TableCompiler.compile_mifare(tag)
else:
table = TableCompiler.compile_14a(tag)
await self.upload_table(table)
def start_14a(self, tag, tagtype: int = 7, trace: bool = False,
on_frame=None, quiet: bool = False, exit_after: int = 0) -> None:
"""Start an ISO 14443-A (Layer 3) sim, optionally streaming a live trace.
# Start sim (fire-and-forget — response comes when sim ends)
Synchronous, mirroring :meth:`start_15693`: the firmware runs
anticollision and the tagtype's built-in command set autonomously; this
sends the sim-start frame over raw serial and, when tracing, spawns the
reader thread that decodes each reader↔tag frame.
Args:
tagtype: firmware emulation type (see ``hf.iso14a.sim``; 7 = MFU
EV1/NTAG215, 1 = MIFARE Classic 1k, 2 = Ultralight, ...).
trace: if True, print live reader↔tag communication to the console.
on_frame: optional callback(frame: dict) invoked per decoded frame
(see the ``entries`` property for the dict shape). Passing it
auto-enables trace streaming.
quiet: if True, don't print frames (callback + ``entries`` still fill).
exit_after: stop the sim after N reader reads (0 = run until stopped).
Note: this starts anticollision + protocol handling from the tagtype's
firmware model. Loading the tag's memory contents into emulator RAM (so
reads return model data) is a separate step and is not performed here;
the trace still shows every reader↔tag frame regardless.
14a-4 (ISO-DEP / WTX relay) reuses the same firmware trace but routes it
through the async relay loop — see the plan's I/O model decision.
"""
# Local import: pm3py.transponders.hf.iso14443a.base pulls in pm3py.sim,
# so a module-level import here would be circular.
from pm3py.transponders.hf.iso14443a.base import Tag14443A
if not isinstance(tag, Tag14443A):
raise TypeError(
f"start_14a requires an ISO 14443-A transponder (a Tag14443A "
f"subclass), got {type(tag).__name__}. Use start_15693() for "
f"ISO 15693 tags."
)
self._tag_model = tag
self._on_frame = on_frame
self._quiet = quiet
self._print_frames = trace and not quiet
stream = trace or (on_frame is not None)
# Resolve the raw serial port
if self._port:
ser = self._port
elif self._t and self._t._writer:
ser = self._t._writer.transport.serial
else:
raise RuntimeError("No serial port available")
tag._serial = ser
# Fold the UID length into flags per FLAG_SET_UID_IN_DATA (pm3_cmd.h).
uid = tag._uid
flag_val = 0x0001 # FLAG_INTERACTIVE
if len(uid) == 4:
flag_val |= 0x0010
uid_flag = 0x0010 # FLAG_4B_UID_IN_DATA
elif len(uid) == 7:
flag_val |= 0x0020
uid_flag = 0x0020 # FLAG_7B_UID_IN_DATA
elif len(uid) == 10:
flag_val |= 0x0030
atqa = tag.atqa if hasattr(tag, 'atqa') else b"\x04\x00"
sak = tag.sak if hasattr(tag, 'sak') else 0x08
payload = atqa + bytes([sak]) + uid
await self._t.send_ng_no_response(Cmd.HF_ISO14443A_SIMULATE)
# TODO: use send_mix for 14a sim (MIX frame format)
uid_flag = 0x0030 # FLAG_10B_UID_IN_DATA
else:
raise ValueError("UID must be 4, 7, or 10 bytes")
flag_val = uid_flag
if stream:
flag_val |= FLAG_SIM_TRACE
# HF_ISO14443A_SIMULATE NG payload — same layout as hf.iso14a.sim():
# struct { u8 tagtype; u16 flags; u8 uid[10]; u8 exitAfter;
# u8 rats[20]; u8 ulauth_1a1_len; u8 ulauth_1a2_len;
# u8 ulauth_1a1[16]; u8 ulauth_1a2[16]; bool mirror; } PACKED
payload = struct.pack(
"<BH10sB20sBB16s16sB",
tagtype & 0xFF,
flag_val & 0xFFFF,
uid.ljust(10, b"\x00")[:10],
exit_after & 0xFF,
b"\x00" * 20,
0, 0,
b"\x00" * 16, b"\x00" * 16,
0,
)
# Load the model's memory into emulator RAM BEFORE starting the sim, so the
# reader sees real page data (UID pages, CC, NDEF, PWD, PACK) instead of a
# stale/empty buffer. The MFU/NTAG sim reads pages + version + signature from
# this mfu_dump image (firmware is data-driven for tagtype 2/7). Handled by
# the firmware command dispatch here (sim not yet running); tag.sync() reuses
# the same push to update a running sim.
if hasattr(tag, "_build_mfu_dump"):
tag.sync()
time.sleep(0.05)
frame = encode_ng_frame(Cmd.HF_ISO14443A_SIMULATE, payload)
ser.write(frame)
time.sleep(0.2) # let firmware start the sim before streaming
self._active = True
self._relay_task = asyncio.create_task(self._relay_loop(tag))
def start_15693(self, tag, compile: bool = True, trace: bool = False) -> None:
if stream:
decoder = getattr(tag, "decode_trace", None) or decode_14443a
self._formatter = TraceFormatter(mode="sim", decoder=decoder, crc_len=2)
self._trace_thread = threading.Thread(
target=self._trace_reader, args=(ser,), daemon=True
)
self._trace_thread.start()
print(f"[SimSession] 14a sim started, tagtype={tagtype}, UID={uid.hex()}"
+ (" (trace ON)" if stream else ""))
def start_15693(self, tag, compile: bool = True, trace: bool = False,
on_frame=None, quiet: bool = False) -> None:
"""Start 15693 sim (synchronous).
Firmware handles standard commands (inventory, read, write) autonomously.
@@ -131,8 +234,26 @@ class SimSession:
Args:
trace: If True, print live reader↔tag communication to console.
on_frame: optional callback(frame: dict) invoked per decoded frame from
the reader thread — for scripting (see the `entries` property for the
dict shape). Passing it auto-enables trace streaming.
quiet: if True, don't print frames (callback + `entries` still fill).
"""
# Local import: pm3py.transponders.hf.iso15693.base pulls in pm3py.sim,
# so a module-level import here would be circular.
from pm3py.transponders.hf.iso15693.base import Tag15693
if not isinstance(tag, Tag15693):
raise TypeError(
f"start_15693 requires an ISO 15693 transponder (a Tag15693 "
f"subclass), got {type(tag).__name__}. Use start_14a() for "
f"ISO 14443-A tags."
)
self._tag_model = tag
self._on_frame = on_frame
self._quiet = quiet
self._print_frames = trace and not quiet
stream = trace or (on_frame is not None)
# Resolve the raw serial port
if self._port:
@@ -147,7 +268,7 @@ class SimSession:
# Start sim with UID and block_size — same format as PM3 client
# Client sends UID as-is (firmware reverses internally)
flags = 0x02 if trace else 0x00
flags = 0x02 if stream else 0x00
block_size = tag._block_size if hasattr(tag, '_block_size') else 4
payload = tag._uid + bytes([block_size, flags])
frame = encode_ng_frame(Cmd.HF_ISO15693_SIMULATE, payload)
@@ -168,7 +289,7 @@ class SimSession:
if compile:
self._compile_and_upload_table(tag, ser)
if trace:
if stream:
self._formatter = TraceFormatter(mode="sim", decoder=tag.decode_trace, crc_len=2)
self._trace_thread = threading.Thread(
target=self._trace_reader, args=(ser,), daemon=True
@@ -176,7 +297,7 @@ class SimSession:
self._trace_thread.start()
print(f"[SimSession] 15693 sim started, UID={tag._uid.hex()}"
+ (" (trace ON)" if trace else ""))
+ (" (trace ON)" if stream else ""))
print(f"[SimSession] Modify tag in REPL, then call tag.sync()")
def _compile_and_upload_table(self, tag, ser) -> None:
@@ -184,10 +305,10 @@ class SimSession:
table = ResponseTable(entries=[])
# Check most specific IC first, fall back to base
from .icode3 import Icode3Tag
from .icode_dna import IcodeDnaTag
from .icode_slix2 import IcodeSlix2Tag
from .ntag5_platform import Ntag5PlatformTag
from pm3py.transponders.hf.iso15693.nxp.icode3 import Icode3Tag
from pm3py.transponders.hf.iso15693.nxp.icode_dna import IcodeDnaTag
from pm3py.transponders.hf.iso15693.nxp.icode_slix2 import IcodeSlix2Tag
from pm3py.transponders.hf.iso15693.nxp.ntag5_platform import Ntag5PlatformTag
if isinstance(tag, Ntag5PlatformTag):
# NTAG 5 Switch/Link/Boost — use DNA compiler (same platform)
table = TableCompiler.compile_icode_dna(tag)
@@ -268,19 +389,45 @@ class SimSession:
resp = decode_response_frame(bytes(buf[:frame_len]))
buf = buf[frame_len:]
if resp["cmd"] == CMD_HF_ISO15693_SIM_TRACE:
if resp["cmd"] == Cmd.HF_MIFARE_SIMULATE:
# 14a sim tore down (button press, host stop, or
# exit_after). The firmware starts the sim with
# HF_ISO14443A_SIMULATE (0x0384) but its teardown reply
# is HF_MIFARE_SIMULATE (0x0610) — watch for the latter.
self._active = False
emit(f"[SimSession] 14a sim ended, status={resp['status']}\n")
return
if resp["cmd"] in _TRACE_CMDS:
protocol = _TRACE_CMDS[resp["cmd"]]
data = resp["data"]
if len(data) >= 2:
direction = data[0] & 0x0F
if direction == 0x03 and len(data) >= 3:
# ADC field strength report
if protocol == 0 and direction == 0x03 and len(data) >= 3:
# ADC field strength report (ISO 15693 only)
adc_mv = (data[1] << 8) | data[2]
if self.on_field_strength:
self.on_field_strength(adc_mv)
else:
crc_fail = bool(data[0] & 0x80)
payload = data[1:]
self._formatter.print(direction, payload, crc_fail=crc_fail)
entry = {
"protocol": protocol,
"direction": direction,
"timestamp": None,
"duration": None,
"data": payload,
"data_hex": payload.hex(),
"decoded": self._formatter.decode(
direction, payload, crc_fail),
"crc_fail": crc_fail,
}
self._entries.append(entry)
if self._on_frame is not None:
self._on_frame(entry)
if self._print_frames:
self._formatter.print(
direction, payload, crc_fail=crc_fail)
except Exception:
buf = buf[4:] # skip bad magic, try again
@@ -305,8 +452,9 @@ class SimSession:
if resp is None:
continue
elif resp.cmd == Cmd.HF_ISO15693_SIMULATE:
# Sim ended (firmware sent final reply)
elif resp.cmd in (Cmd.HF_ISO15693_SIMULATE, Cmd.HF_MIFARE_SIMULATE):
# Sim ended (firmware sent final reply). 15693 replies with
# HF_ISO15693_SIMULATE; 14a tears down with HF_MIFARE_SIMULATE.
print(f"[SimSession] Sim ended, status={resp.status}")
self._active = False
break
@@ -361,6 +509,20 @@ class SimSession:
payload = struct.pack("<IH", offset, len(data)) + data
await self._t.send_ng_no_response(Cmd.HF_ISO15693_EML_SETMEM, payload)
@property
def entries(self) -> list[dict]:
"""Decoded reader↔tag frames captured while streaming (a copy).
Each: {'protocol', 'direction', 'timestamp', 'duration', 'data': bytes,
'data_hex': str, 'decoded': str|None, 'crc_fail': bool}. Populated when
the sim runs with trace=True or an on_frame callback.
"""
return list(self._entries)
def clear(self) -> None:
"""Discard accumulated frames."""
self._entries.clear()
def stop(self) -> None:
"""Stop simulation."""
self._active = False

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.lf.atmel.t5577 import * # noqa: F401,F403

View File

@@ -288,7 +288,7 @@ class TableCompiler:
first_block = 128 + (sector - 32) * 16
# Pre-pick a tag nonce
from .crypto1 import Crypto1
from pm3py.transponders.hf.iso14443a.nxp.crypto1 import Crypto1
crypto = Crypto1(key)
nt = crypto.generate_nonce()

View File

@@ -1,4 +0,0 @@
"""Backward compat shim — moved to pm3py.trace."""
from pm3py.trace.decode_iso15 import decode_15693, decode_15693_nxp # noqa: F401
from pm3py.trace.decode_iso14a import decode_14443a # noqa: F401
from pm3py.trace.format import TraceFormatter # noqa: F401

View File

@@ -128,3 +128,8 @@ class Transponder(abc.ABC):
for r in self.regions.values():
if r.eml_offset >= 0:
self._eml_push_region(r)
@property
def uid(self) -> bytes:
"""Current tag UID."""
return self._parse_uid(self._uid)

View File

@@ -1,2 +0,0 @@
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.type5 import * # noqa: F401,F403

View File

@@ -1,75 +1,297 @@
"""Sniff sessions — start/stop/download per protocol."""
import sys
"""Sniff sessions — live streaming with button-toggle, or legacy batch."""
import struct
import threading
import time
from ..core.protocol import Cmd, PM3_CMD_DATA_SIZE
from ..core.transport import PM3Transport, encode_ng_frame
import serial
from ..core.protocol import (
Cmd, PM3_CMD_DATA_SIZE,
SNIFF_FLAG_STREAMING, SNIFF_FLAG_BUTTON_TOGGLE,
SNIFF_STATE_STARTED, SNIFF_STATE_PAUSED,
SNIFF_STATE_RESUMED, SNIFF_STATE_STOPPED,
)
from ..core.transport import (
encode_ng_frame, decode_response_frame,
RESP_PREAMBLE_MAGIC, RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE,
)
from ..trace import parse_tracelog
from ..trace.format import format_sniff_line
from ..trace.decode_iso15 import decode_15693
from ..trace.format import TraceFormatter, format_sniff_line
from ..trace.output import emit
# Default serial port (same as SimSession)
DEFAULT_PORT = "/dev/ttyACM0"
_STATE_NAMES = {
SNIFF_STATE_STARTED: "sniffing",
SNIFF_STATE_PAUSED: "paused",
SNIFF_STATE_RESUMED: "sniffing",
SNIFF_STATE_STOPPED: "stopped",
}
_STATE_ICONS = {
SNIFF_STATE_STARTED: ">>",
SNIFF_STATE_PAUSED: "||",
SNIFF_STATE_RESUMED: ">>",
SNIFF_STATE_STOPPED: "[]",
}
_STATE_LABELS = {
SNIFF_STATE_STARTED: "Started",
SNIFF_STATE_PAUSED: "Paused",
SNIFF_STATE_RESUMED: "Resumed",
SNIFF_STATE_STOPPED: "Stopped",
}
class SniffSession:
"""Sniff session that manages start/download/decode lifecycle.
"""Persistent sniff session with live streaming and button-toggle.
Takes a PM3Transport (not the client), since sniff has its own
lifecycle that doesn't fit the stateless command pattern.
For live mode (default):
session = SniffSession.open()
session.start_15693() # streams trace live, button toggles pause
session.stop() # or let button cycles accumulate
session.entries # all captured frames
For legacy mode:
session.start_15693(live=False) # blocks until button, batch download
"""
def __init__(self, transport: PM3Transport):
self._t = transport
def __init__(self, ser):
self._ser = ser
self._active = False
self._entries: list[dict] = []
self._reader_thread: threading.Thread | None = None
self._formatter: TraceFormatter | None = None
self._state = "idle"
self._on_frame = None
self._quiet = False
async def _sniff_15(self, timeout: float = 60.0) -> dict:
"""Start ISO 15693 sniff. Blocks until button press or timeout."""
async with self._t._lock:
raw = encode_ng_frame(Cmd.HF_ISO15693_SNIFF)
await self._t.send_frame(raw)
while True:
resp = await self._t.read_response(timeout=timeout)
if resp.cmd == Cmd.HF_ISO15693_SNIFF:
return {"status": resp.status}
@classmethod
def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SniffSession":
"""Open serial connection to PM3. Auto-detects port if default."""
ser = serial.Serial()
ser.port = port
ser.baudrate = baudrate
ser.bytesize = 8
ser.parity = "N"
ser.stopbits = 1
ser.timeout = 0.1
ser.xonxoff = False
ser.rtscts = False
ser.dsrdtr = False
ser.open()
async def _download_trace(self) -> list[dict]:
"""Download and parse the trace buffer from the device."""
raw, trace_len = await self._t.download_bigbuf(
offset=0, length=PM3_CMD_DATA_SIZE, timeout=4.0)
ser.reset_input_buffer()
ser.reset_output_buffer()
time.sleep(0.5)
ser.reset_input_buffer()
if trace_len == 0:
return []
return cls(ser)
if trace_len > PM3_CMD_DATA_SIZE:
raw, trace_len = await self._t.download_bigbuf(
offset=0, length=trace_len, timeout=10.0)
def start_15693(self, live: bool = True, idle_timeout_ms: int = 400,
on_frame=None, quiet: bool = False) -> None:
"""Start 15693 sniff session.
return parse_tracelog(raw[:trace_len])
async def iso15(self, timeout: float = 60.0) -> list[dict]:
"""Sniff ISO 15693 traffic, download trace, decode and print.
Blocks until button press or timeout. Then downloads the trace,
decodes each frame, and prints formatted output.
Returns the parsed trace entries.
live=True (default): streaming with button-toggle pause/resume.
live=False: legacy BigBuf batch mode, blocks until button exits.
on_frame: optional callback(frame: dict) invoked per decoded frame from the
reader thread — for scripting (see the `entries` property for the dict shape).
quiet: if True, don't print frames to the console (callback + `entries` still fill).
"""
import os
if self._active:
self.stop()
self._on_frame = on_frame
self._quiet = quiet
self._protocol = 0 # 15693
flags = 0
if live:
flags = SNIFF_FLAG_STREAMING | SNIFF_FLAG_BUTTON_TOGGLE
payload = struct.pack("<HB", idle_timeout_ms, flags)
frame = encode_ng_frame(Cmd.HF_ISO15693_SNIFF, payload)
self._ser.write(frame)
if live:
self._formatter = TraceFormatter(
mode="sniff", decoder=decode_15693, crc_len=2)
self._active = True
self._state = "starting"
self._reader_thread = threading.Thread(
target=self._stream_reader, daemon=True)
self._reader_thread.start()
else:
self._legacy_sniff()
def _stream_reader(self) -> None:
"""Background thread: read and print trace frames live."""
buf = bytearray()
while self._active:
try:
width = os.get_terminal_size().columns
except (OSError, ValueError):
width = 120
is_tty = sys.stdout.isatty()
chunk = self._ser.read(self._ser.in_waiting or 1)
if not chunk:
continue
buf.extend(chunk)
while self._try_decode_frame(buf):
pass
except serial.SerialException:
break
except Exception:
continue
def _try_decode_frame(self, buf: bytearray) -> bool:
"""Decode one response frame from buf. Returns True if consumed."""
if len(buf) < RESP_PREAMBLE_SIZE + RESP_POSTAMBLE_SIZE:
return False
idx = buf.find(struct.pack("<I", RESP_PREAMBLE_MAGIC))
if idx < 0:
buf[:] = buf[-3:]
return False
if idx > 0:
del buf[:idx]
if len(buf) < RESP_PREAMBLE_SIZE:
return False
length_ng = struct.unpack_from("<H", buf, 4)[0]
payload_len = length_ng & 0x7FFF
frame_len = RESP_PREAMBLE_SIZE + payload_len + RESP_POSTAMBLE_SIZE
if len(buf) < frame_len:
return False
try:
resp = decode_response_frame(bytes(buf[:frame_len]))
del buf[:frame_len]
except Exception:
del buf[:4]
return True
cmd = resp["cmd"]
data = resp.get("data", b"")
if cmd == Cmd.HF_SNIFF_STREAM.value:
self._handle_trace(data)
elif cmd == Cmd.HF_SNIFF_STATUS.value:
self._handle_status(data)
elif cmd == Cmd.HF_ISO15693_SNIFF.value:
self._active = False
return True
def _handle_trace(self, data: bytes) -> None:
"""Parse and print a trace entry from CMD_HF_SNIFF_STREAM."""
if len(data) < 8:
return
protocol, direction, duration, timestamp = struct.unpack_from("<BBHI", data)
payload = data[8:]
entry = {
"protocol": protocol,
"direction": direction,
"duration": duration,
"timestamp": timestamp,
"data": payload,
"data_hex": payload.hex(),
"decoded": self._formatter.decode(direction, payload) if self._formatter else None,
}
self._entries.append(entry)
if self._on_frame is not None:
self._on_frame(entry)
if self._formatter and not self._quiet:
self._formatter.print(direction, payload)
def _handle_status(self, data: bytes) -> None:
"""Handle a CMD_HF_SNIFF_STATUS state change."""
if len(data) < 3:
return
state, count = struct.unpack_from("<BH", data)
self._state = _STATE_NAMES.get(state, "unknown")
icon = _STATE_ICONS.get(state, "??")
label = _STATE_LABELS.get(state, "Unknown")
count_str = f" ({count} frames)" if count > 0 else ""
msg = f"\n[Snf] {icon} {label}{count_str}\n"
emit(msg)
if state == SNIFF_STATE_STOPPED:
self._active = False
def _legacy_sniff(self) -> None:
"""Legacy batch mode: block until button, download BigBuf, decode."""
print("[Snf] Sniffing ISO 15693... press PM3 button to stop.")
result = await self._sniff_15(timeout=timeout)
print(f"[Snf] Sniff ended (status={result['status']})")
print("[Snf] Downloading trace...")
entries = await self._download_trace()
# Read frames until we get the sniff completion response
buf = bytearray()
while True:
chunk = self._ser.read(self._ser.in_waiting or 1)
if chunk:
buf.extend(chunk)
if not entries:
print("[Snf] No trace data captured.")
return []
idx = buf.find(struct.pack("<I", RESP_PREAMBLE_MAGIC))
if idx < 0:
continue
if idx > 0:
buf = buf[idx:]
print(f"[Snf] {len(entries)} frames captured:\n")
for entry in entries:
line = format_sniff_line(entry, width=width, is_tty=is_tty)
print(line)
if len(buf) < RESP_PREAMBLE_SIZE:
continue
return entries
length_ng = struct.unpack_from("<H", buf, 4)[0]
payload_len = length_ng & 0x7FFF
frame_len = RESP_PREAMBLE_SIZE + payload_len + RESP_POSTAMBLE_SIZE
if len(buf) < frame_len:
continue
try:
resp = decode_response_frame(bytes(buf[:frame_len]))
buf = buf[frame_len:]
if resp["cmd"] == Cmd.HF_ISO15693_SNIFF.value:
break
except Exception:
buf = buf[4:]
print("[Snf] Sniff ended, downloading trace...")
# Legacy download not fully implemented in SniffSession.
# Use pm3.hf.iso15.sniff_decoded() for legacy batch mode.
print("[Snf] Legacy download not fully implemented in SniffSession.")
print("[Snf] Use pm3.hf.iso15.sniff_decoded() for legacy batch mode.")
def stop(self) -> None:
"""End sniff session from Python (sends BREAK_LOOP)."""
if self._active:
frame = encode_ng_frame(Cmd.BREAK_LOOP, b"")
self._ser.write(frame)
if self._reader_thread:
self._reader_thread.join(timeout=2.0)
self._active = False
self._state = "stopped"
def close(self) -> None:
"""Stop and close serial port."""
if self._active:
self.stop()
self._ser.close()
@property
def state(self) -> str:
"""Current session state: idle, sniffing, paused, stopped."""
return self._state
@property
def entries(self) -> list[dict]:
"""All captured trace entries across pause/resume cycles."""
return list(self._entries)
def clear(self) -> None:
"""Clear accumulated entries."""
self._entries.clear()

Some files were not shown because too many files have changed in this diff Show More