Compare commits

..

122 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
155 changed files with 23958 additions and 352 deletions

8
.gitignore vendored
View File

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

2
.gitmodules vendored
View File

@@ -1,4 +1,4 @@
[submodule "firmware"] [submodule "firmware"]
path = 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 branch = main

View File

@@ -30,6 +30,9 @@ pm3py/
hf_iso15.py # ISO 15693 — scan, rdbl, wrbl, thin sniff cmd hf_iso15.py # ISO 15693 — scan, rdbl, wrbl, thin sniff cmd
hf_mfc.py # MIFARE Classic — rdbl, wrbl, rdsc, chk, nested, cident hf_mfc.py # MIFARE Classic — rdbl, wrbl, rdsc, chk, nested, cident
lf.py # LF commands + T55xxCommands + LFSearchResult 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/ # firmware trace infrastructure (shared by sniff, sim, reader)
trace.py # parse_tracelog, TRACELOG_HDR_SIZE trace.py # parse_tracelog, TRACELOG_HDR_SIZE
decode_iso15.py # ISO 15693 command/response decoders decode_iso15.py # ISO 15693 command/response decoders
@@ -54,8 +57,24 @@ pm3py/
replay.py # Trace replay replay.py # Trace replay
access_control/ # Wiegand, OSDP access_control/ # Wiegand, OSDP
transponders/ # tag/transponder models (extracted from sim/) 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 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 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 lf/ # EM4100, HID, T5577
reader/ # higher-level reader modes by protocol/vendor (scaffold) reader/ # higher-level reader modes by protocol/vendor (scaffold)
``` ```
@@ -66,7 +85,7 @@ pm3py/
pm3.hw.ping() # hardware pm3.hw.ping() # hardware
pm3.hf.iso14a.scan() # ISO 14443-A pm3.hf.iso14a.scan() # ISO 14443-A
pm3.hf.iso15.rdbl(4) # ISO 15693 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.lf.t55.readbl(0) # T55xx
pm3.hf.tune() # HF antenna tune pm3.hf.tune() # HF antenna tune
pm3.hf.dropfield() # drop field pm3.hf.dropfield() # drop field
@@ -127,6 +146,25 @@ Design doc: `docs/PYTHON_SIM_DESIGN.md`. Firmware patch in `firmware/` submodule
Firmware maintenance: atomic single-file commits for easy rebase against upstream PM3. See design doc for CI workflow. 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 ## Adding new commands
1. Find the `CMD_*` constant in `include/pm3_cmd.h` and add to `Cmd` enum in `core/protocol.py` 1. Find the `CMD_*` constant in `include/pm3_cmd.h` and add to `Cmd` enum in `core/protocol.py`
@@ -135,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` 4. Parse the response using `struct.unpack_from` on `resp.data`
5. Return a dict with human-readable keys 5. Return a dict with human-readable keys
6. Write test with `AsyncMock` transport — no hardware needed 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 # 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 ## 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 ```bash
git clone --recurse-submodules <repo-url> pm3py
cd 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 ## 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 ```python
from pm3py import Proxmark3 from pm3py import Proxmark3
# Sync mode — auto-detects the device pm3 = Proxmark3.sync() # auto-detect; or Proxmark3.sync("/dev/ttyACM0")
pm3 = Proxmark3.sync() print(pm3.firmware) # version + compatibility, populated on connect
print(pm3.firmware.version_string) print(pm3.hw.ping()) # {'success': True, 'length': 32, 'data': '00010203...'}
print(pm3.hw.ping()) print(pm3.hw.version()["version_string"])
print(pm3.hw.version()) print(pm3.hf.iso14a.scan()) # scan a 14443-A card
pm3.close() 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 ```python
import asyncio import asyncio
from pm3py import Proxmark3 from pm3py import Proxmark3
async def main(): 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.hw.ping())
print(await pm3.hf.a14.scan()) print(await pm3.hf.iso14a.scan())
asyncio.run(main()) 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.*` ### Hardware — `pm3.hw.*`
```python ```python
pm3.hw.ping() # verify connectivity pm3.hw.ping() # {'success': True, 'length': 32, 'data': '...'}
pm3.hw.version() # firmware version, chip ID pm3.hw.ping(length=64) # ping with a custom echo payload length
pm3.hw.status() # device status pm3.hw.version() # {'chip_id': '0x...', 'section_size': ..., 'version_string': '...'}
pm3.hw.capabilities() # compiled features, platform (Easy vs RDV4) pm3.hw.status() # {'status': 0}
pm3.hw.tune() # full antenna tuning (LF + HF voltages) pm3.hw.capabilities() # {'version', 'baudrate', 'bigbuf_size', 'is_rdv4',
pm3.hw.led("green", on=True) # LED control with color names # 'compiled_with_lf', 'compiled_with_iso14443a',
pm3.hw.led("red", pulse=True, speed=300)# PWM effects (validates hardware support) # 'compiled_with_iso15693'}
pm3.hw.led("all", on=False) # turn off pm3.hw.tune() # full antenna tuning — nested {'lf': {...}, 'hf': {...}}
pm3.hw.dbg(level=2) # set debug level pm3.hw.dbg() # get debug level -> {'level': N}
pm3.hw.break_loop() # stop long-running operations pm3.hw.dbg(level=2) # set debug level 0-4
pm3.hw.reset() # reset device 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.*` ### Low Frequency — `pm3.lf.*`
```python ```python
pm3.lf.tune(divisor=95) # LF antenna voltage at frequency pm3.lf.tune() # LF antenna voltage @125kHz (divisor 95)
pm3.lf.read(samples=30000) # capture ADC samples pm3.lf.tune(divisor=88) # @134kHz — see return shape below
pm3.lf.config() # get sampling config pm3.lf.read(samples=12288) # capture ADC samples into device buffer
pm3.lf.config(divisor=88) # set config (134 kHz) pm3.lf.sniff() # alias for read()
pm3.lf.search() # capture + return LFSearchResult pm3.lf.config() # get current sampling config
pm3.lf.sniff() # alias for read pm3.lf.config(divisor=88) # set config (134 kHz); fire-and-forget then re-read
pm3.lf.sim(gap=0, data=b"...") # simulate from buffer pm3.lf.search() # capture + return an LFSearchResult
pm3.lf.sim(gap=0, data=b"...") # simulate an LF tag from the sample buffer
# T55xx tags
pm3.lf.t55.readbl(block=0)
pm3.lf.t55.writebl(block=1, data=0xDEADBEEF)
pm3.lf.t55.wakeup(password=0x12345678)
``` ```
`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: `lf.search()` returns an `LFSearchResult` with callable next steps:
```python ```python
result = pm3.lf.search() result = pm3.lf.search()
print(result) # shows available actions print(result) # prints available actions + sample count
raw = result.download() # download raw ADC samples raw = result.download() # raw ADC samples as bytes (1 byte/sample, 0-255)
result.tune() # check antenna voltage result.tune() # check antenna voltage (tag present?)
result.read_t55xx(block=0) # try reading as T55xx 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.*` ### High Frequency — `pm3.hf.*`
```python Core HF operations. `tune()` runs the firmware START/MEASURE/STOP state machine internally and reports the antenna voltage; `search()` probes 14443-A then 15693.
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.*`
```python ```python
pm3.hf.a14.scan() # → {uid, atqa, sak, ats} pm3.hf.tune() # → {'voltage_mV': 4521, 'voltage_V': 4.521}
pm3.hf.a14.raw(data=b"\x50\x00") # raw APDU pm3.hf.search() # try 14a then 15693 → {'iso14443a': {...}, 'found': True}
pm3.hf.a14.sniff() pm3.hf.sniff() # sniff raw HF, returns {'status', 'samples'}
pm3.hf.a14.sim(uid=b"\x01\x02\x03\x04") 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.*` ### ISO 15693 — `pm3.hf.iso15.*`
```python ```python
pm3.hf.iso15.scan() # → {uid, dsfid} pm3.hf.iso15.scan() # quick single-tag → {'found', 'uid', 'dsfid', 'response_flags'}
pm3.hf.iso15.rdbl(block=0) # read block pm3.hf.iso15.inventory() # 16-slot anti-collision → list of tag dicts
pm3.hf.iso15.wrbl(block=0, data="00112233") pm3.hf.iso15.rdbl(0) # read block 0
pm3.hf.iso15.sniff() 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.*` ### 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 ```python
pm3.hf.mf.rdbl(block=0) # read block (default key FF..FF) pm3 = Proxmark3.sync()
pm3.hf.mf.rdbl(block=4, key="A0A1A2A3A4A5", key_type=1) # key B
pm3.hf.mf.wrbl(block=4, key="FFFFFFFFFFFF", data="00" * 16) # Read one block (default key FFFFFFFFFFFF, key A)
pm3.hf.mf.rdsc(sector=0) # read entire sector pm3.hf.mf.rdbl(0)
pm3.hf.mf.chk(block=0, keys=["FFFFFFFFFFFF", "A0A1A2A3A4A5"]) # → {'success': True, 'block': 0, 'data': '0403...', 'raw': b'\x04\x03...'}
pm3.hf.mf.sniff()
pm3.hf.mf.sim(uid="01020304", size="1k") pm3.hf.mf.rdbl(4, key="A0A1A2A3A4A5", key_type=1) # key B
pm3.hf.mf.nested(block=0, key="FFFFFFFFFFFF")
pm3.hf.mf.cident() # identify magic card type # 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 ```python
from pm3py import Cmd from pm3py import Cmd
resp = pm3.send_ng(Cmd.STATUS) # NG frame # NG frame: cmd + raw payload bytes
resp = pm3.send_mix(Cmd.HF_ISO14443A_READER, arg0=0x0103) # MIX frame 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 ## 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 ```python
pm3 = Proxmark3.sync() pm3 = Proxmark3.sync()
print(pm3.firmware.compatible) # True if NG protocol works print(pm3.firmware.compatible) # True if the NG protocol handshake worked
print(pm3.firmware.version_string) # firmware version print(pm3.firmware.version_string) # firmware version string
print(pm3.firmware.chip_id) # e.g. "0x270B0A40" 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 ## Architecture
``` ```
pm3py/ pm3py/
├── protocol.py # Wire protocol constants, CRC-16/A, command IDs, error codes ├── core/ # wire protocol + device command modules
├── transport.py # Frame encode/decode, async serial I/O, PM3Transport │ ├── protocol.py # constants, CRC-16/A, Cmd enum, PM3Status
├── client.py # Proxmark3 class, sync proxy, firmware probe │ ├── transport.py # NG/MIX frame encode/decode, PM3Transport (async serial)
├── hw.py # hw.* commands │ ├── client.py # Proxmark3, _SyncProxy, FirmwareInfo
├── lf.py # lf.* commands + T55xx ├── hw.py # hw.* (ping, version, tune, LEDs, dbg, reset)
├── hf.py # hf.* commands (tune, search, sniff) ├── hf.py # hf.* core (tune, search, sniff, dropfield)
├── hf_14a.py # hf.14a.* (ISO 14443-A) ├── hf_iso14a.py # hf.iso14a.* (ISO 14443-A)
├── hf_15.py # hf.15.* (ISO 15693) ├── hf_iso15.py # hf.iso15.* (ISO 15693)
── hf_mf.py # hf.mf.* (MIFARE Classic) │ ├── 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 - **`core/`** is the wire stack: `transport.py` handles serial I/O and NG/MIX frame
- **Command modules** send structured commands and parse responses into dicts encode/decode; command modules send structured commands and parse responses into
- **USB connections** skip CRC (matching the C client behavior) dicts. USB connections skip CRC (matching the C client); FPC UART uses CRC-16/A with
- **FPC UART connections** use CRC-16/A with byte-swapped wire format 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 ## Tests
@@ -182,4 +718,8 @@ cd pm3py
python -m pytest tests/ -v 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).

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.""" """Main Proxmark3 client with nested API."""
import asyncio import asyncio
import functools
import glob import glob
import inspect import inspect
import logging import logging
@@ -13,6 +14,9 @@ from .hf import HFCommands
from .hf_iso14a import HF14ACommands from .hf_iso14a import HF14ACommands
from .hf_iso15 import HF15Commands from .hf_iso15 import HF15Commands
from .hf_mfc import HFMFCommands 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__) log = logging.getLogger(__name__)
@@ -25,6 +29,8 @@ class FirmwareInfo:
section_size: int = 0 section_size: int = 0
ng_protocol: bool = False ng_protocol: bool = False
compatible: bool = False compatible: bool = False
expected_firmware: str = "" # the fork build this pm3py is pinned to
matches_pin: bool = False # device firmware == the pinned build?
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
@@ -70,7 +76,9 @@ class Proxmark3:
self.hf = HFCommands(self._transport) self.hf = HFCommands(self._transport)
self.hf.iso14a = HF14ACommands(self._transport) self.hf.iso14a = HF14ACommands(self._transport)
self.hf.iso15 = HF15Commands(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() self.firmware = FirmwareInfo()
async def connect(self) -> None: async def connect(self) -> None:
@@ -111,6 +119,10 @@ class Proxmark3:
except PM3Error: except PM3Error:
fw.warnings.append("Could not read firmware version — device may be in bootloader mode") fw.warnings.append("Could not read firmware version — device may be in bootloader mode")
# Step 2.5: Compare against the pinned fork firmware (for pm3flash / live-sim)
fw.expected_firmware = FIRMWARE_PIN.tag or FIRMWARE_PIN.sha
fw.matches_pin = _fw_matches(fw.version_string)
# Step 3: Assess compatibility # Step 3: Assess compatibility
fw.compatible = fw.ng_protocol and len(fw.warnings) == 0 fw.compatible = fw.ng_protocol and len(fw.warnings) == 0
@@ -155,19 +167,27 @@ class _SyncProxy:
def __getattr__(self, name): def __getattr__(self, name):
attr = getattr(self._target, name) attr = getattr(self._target, name)
if isinstance(attr, (HWCommands, LFCommands, HFCommands, if isinstance(attr, (HWCommands, LFCommands, HFCommands,
HF14ACommands, HF15Commands, HFMFCommands)): HF14ACommands, HF15Commands, HFMFCommands,
HFMFUCommands)):
return _SyncProxy(attr, self._loop) return _SyncProxy(attr, self._loop)
# Sub-modules attached dynamically (e.g. hf.iso14a, hf.mfc) # Sub-modules attached dynamically (e.g. hf.iso14a, hf.mfc)
if hasattr(attr, '_t'): if hasattr(attr, '_t'):
return _SyncProxy(attr, self._loop) return _SyncProxy(attr, self._loop)
if callable(attr) and inspect.iscoroutinefunction(attr): 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): def sync_wrapper(*args, **kwargs):
return self._loop.run_until_complete(attr(*args, **kwargs)) return self._loop.run_until_complete(attr(*args, **kwargs))
sync_wrapper.__name__ = name
sync_wrapper.__doc__ = attr.__doc__
return sync_wrapper return sync_wrapper
return attr 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): def close(self):
"""Disconnect and close the event loop.""" """Disconnect and close the event loop."""
self._loop.run_until_complete(self._target.disconnect()) 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 # Sub-modules attached by client.py
self.iso14a = None # type: ignore self.iso14a = None # type: ignore
self.iso15 = 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: async def tune(self, on_progress: ProgressCallback = None) -> dict:
"""Measure HF antenna voltage.""" """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 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 = { result = {
"voltage_mV": voltage, "voltage_mV": voltage,
"voltage_V": round(voltage / 1000.0, 3), "voltage_V": round(voltage / 1000.0, 3),
@@ -62,14 +71,30 @@ class HFCommands:
results["found"] = len(results) > 0 results["found"] = len(results) > 0
return results 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: on_progress: ProgressCallback = None) -> dict:
"""Sniff HF traffic.""" """Sniff HF traffic.
payload = struct.pack("<II", samples, skip) if samples else b""
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) 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: async def dropfield(self) -> dict:
"""Turn off HF field.""" """Turn off HF field.
resp = await self._t.send_ng(Cmd.HF_DROPFIELD)
return {"status": resp.status} 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, payload=data,
timeout=5.0, 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 { return {
"status": resp.status, "status": resp.status,
"data": resp.data.hex() if resp.data else None, "received": received,
"raw": resp.data, "data": payload.hex() if payload else None,
"raw": payload,
} }
async def sniff(self, param: int = 0) -> dict: async def sniff(self, param: int = 0) -> dict:
"""Sniff ISO14443A traffic.""" """Sniff ISO14443A traffic."""
resp = await self._t.send_mix( resp = await self._t.send_ng(
Cmd.HF_ISO14443A_SNIFF, arg0=param, timeout=30.0, Cmd.HF_ISO14443A_SNIFF, payload=bytes([param]), timeout=30.0,
) )
return {"status": resp.status} return {"status": resp.status}
async def sim(self, uid: bytes, sak: int = 0x08, atqa: bytes = b"\x04\x00", async def sim(self, tagtype: int = 1, uid: bytes = b"", exit_after: int = 0,
flags: int = 0) -> dict: flags: int = 0, rats: bytes = b"",
"""Simulate ISO14443A card.""" ulauth_1a1: bytes = b"", ulauth_1a2: bytes = b"",
if len(uid) == 4: ulauth_1a2_mirror: bool = False) -> dict:
uid_flag = 0x0010 """Simulate an ISO14443A tag.
elif len(uid) == 7:
uid_flag = 0x0020 tagtype selects the emulated tag: 1=MIFARE Classic 1k, 2=Ultralight,
elif len(uid) == 10: 3=DESFire, 4=ISO14443-4, 5=Tnp3xxx, 6=MF Mini, 7=MFU EV1/NTAG215,
uid_flag = 0x0030 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 # FLAG_4B_UID_IN_DATA
elif len(uid) == 7:
uid_flag = 0x0020 # FLAG_7B_UID_IN_DATA
elif len(uid) == 10:
uid_flag = 0x0030 # FLAG_10B_UID_IN_DATA
else:
raise ValueError("UID must be 4, 7, or 10 bytes")
else: else:
raise ValueError("UID must be 4, 7, or 10 bytes") uid_flag = 0x0000 # FLAG_UID_IN_EMUL
flag_val = flags | uid_flag | 0x0001 # FLAG_INTERACTIVE flag_val = (flags & ~0x0030) | uid_flag # FLAG_MASK_UID = 0x0030
payload = atqa + bytes([sak]) + uid
resp = await self._t.send_mix( # struct { u8 tagtype; u16 flags; u8 uid[10]; u8 exitAfter;
Cmd.HF_ISO14443A_SIMULATE, arg0=flag_val, timeout=60.0, payload=payload, # 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} return {"status": resp.status}

View File

@@ -1,6 +1,6 @@
"""ISO 15693 commands: hf.iso15.*""" """ISO 15693 commands: hf.iso15.*"""
import struct import struct
from .protocol import Cmd from .protocol import Cmd, crc15, crc15_bytes
from .transport import PM3Transport, PM3Error from .transport import PM3Transport, PM3Error
# Re-export trace symbols for backward compat with existing test imports # Re-export trace symbols for backward compat with existing test imports
@@ -11,12 +11,57 @@ from ..trace import (
format_sniff_line, format_sniff_line,
) )
# ISO15 PM3 flags # ISO15 PM3 transport flags — values MUST match firmware include/iso15.h
ISO15_CONNECT = 0x01 ISO15_CONNECT = 0x01
ISO15_NO_DISCONNECT = 0x02 ISO15_NO_DISCONNECT = 0x02
ISO15_RAW = 0x04 ISO15_RAW = 0x04
ISO15_APPEND_CRC = 0x08 ISO15_APPEND_CRC = 0x08 # legacy: ARM firmware ignores it — CRC is added host-side
ISO15_READ_RESPONSE = 0x10 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: class HF15Commands:
@@ -27,10 +72,13 @@ class HF15Commands:
async def scan(self) -> dict: async def scan(self) -> dict:
"""Scan for ISO15693 tags using inventory command.""" """Scan for ISO15693 tags using inventory command."""
# ISO15693 inventory: flags=0x06 (high data rate + inventory), cmd=0x01 # ISO15693 inventory: flags=0x26 (high data rate + inventory + 1-slot), cmd=0x01.
iso_cmd = bytes([0x06, 0x01, 0x00]) # flags, inventory cmd, mask_len=0 # SLOT1 (0x20) is required: without it the firmware runs a 16-slot anti-collision
flags = ISO15_CONNECT | ISO15_READ_RESPONSE | ISO15_APPEND_CRC # round and replies with an iso15_slot_result_t struct, not [flags][dsfid][uid],
payload = struct.pack("<BH", flags, len(iso_cmd)) + iso_cmd # 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) resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=5.0)
if resp.status != 0 or len(resp.data) < 10: if resp.status != 0 or len(resp.data) < 10:
@@ -47,19 +95,87 @@ class HF15Commands:
"response_flags": resp_flags, "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: async def rdbl(self, block: int, uid: str | None = None) -> dict:
"""Read a single block.""" """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: if uid:
uid_bytes = bytes.fromhex(uid)[::-1] # reverse for wire 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: 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 = _iso15_payload(iso_cmd)
payload = struct.pack("<BH", flags, len(iso_cmd)) + iso_cmd
resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=5.0) 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} return {"success": False, "error": resp.status}
resp_flags = resp.data[0] resp_flags = resp.data[0]
@@ -67,7 +183,7 @@ class HF15Commands:
return {"success": False, "error_flag": resp_flags} return {"success": False, "error_flag": resp_flags}
lock_status = resp.data[1] 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 { return {
"success": True, "success": True,
@@ -88,13 +204,30 @@ class HF15Commands:
else: else:
iso_cmd = bytes([0x02, 0x21, block]) + data iso_cmd = bytes([0x02, 0x21, block]) + data
flags = ISO15_CONNECT | ISO15_READ_RESPONSE | ISO15_APPEND_CRC payload = _iso15_payload(iso_cmd, flags=_WRITE_FLAGS) # LONG_WAIT for the ~22ms write ack
payload = struct.pack("<BH", flags, len(iso_cmd)) + iso_cmd
resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=5.0) 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 success = resp.status == 0 and len(resp.data) > 0 and resp.data[0] == 0
return {"success": success, "block": block} 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: async def sniff(self, timeout: float = 60.0) -> dict:
"""Start ISO15693 sniff. Blocks until button press or timeout. """Start ISO15693 sniff. Blocks until button press or timeout.

View File

@@ -7,6 +7,16 @@ MF_KEY_A = 0
MF_KEY_B = 1 MF_KEY_B = 1
MFBLOCK_SIZE = 16 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: class HFMFCommands:
"""MIFARE Classic commands.""" """MIFARE Classic commands."""
@@ -49,8 +59,9 @@ class HFMFCommands:
raise ValueError(f"Block data must be {MFBLOCK_SIZE} bytes") raise ValueError(f"Block data must be {MFBLOCK_SIZE} bytes")
# MIX format: arg0=blockno, arg1=keytype # MIX format: arg0=blockno, arg1=keytype
# payload = key(6) + reserved(10) + data(16) = 32 bytes # firmware reads key at asBytes[0], block_data at asBytes[10]
payload = key_bytes + b"\x00" * 10 + data # payload = key(6) + reserved(4) + data(16) = 26 bytes
payload = key_bytes + b"\x00" * 4 + data
resp = await self._t.send_mix( resp = await self._t.send_mix(
Cmd.HF_MIFARE_WRITEBL, arg0=block, arg1=key_type, Cmd.HF_MIFARE_WRITEBL, arg0=block, arg1=key_type,
payload=payload, timeout=5.0, payload=payload, timeout=5.0,
@@ -59,6 +70,45 @@ class HFMFCommands:
success = resp.oldarg[0] > 0 if resp.oldarg else resp.status == 0 success = resp.oldarg[0] > 0 if resp.oldarg else resp.status == 0
return {"success": success, "block": block} 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", async def rdsc(self, sector: int, key: str | bytes = "FFFFFFFFFFFF",
key_type: int = MF_KEY_A) -> dict: key_type: int = MF_KEY_A) -> dict:
"""Read an entire MIFARE Classic sector.""" """Read an entire MIFARE Classic sector."""
@@ -96,14 +146,24 @@ class HFMFCommands:
continue continue
return {"found": False} return {"found": False}
async def sniff(self) -> dict: async def sniff(self, param: int = 0) -> dict:
"""Sniff MIFARE traffic.""" """Sniff MIFARE (ISO14443-A) traffic.
resp = await self._t.send_ng(Cmd.HF_MIFARE_SNIFF, timeout=30.0)
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} 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. """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): if isinstance(uid, str):
uid = bytes.fromhex(uid) uid = bytes.fromhex(uid)
@@ -116,23 +176,70 @@ class HFMFCommands:
if uid_flag is None: if uid_flag is None:
raise ValueError("UID must be 4, 7, or 10 bytes") raise ValueError("UID must be 4, 7, or 10 bytes")
flags = 0x0001 | uid_flag | size_flags[size] # FLAG_INTERACTIVE flags = uid_flag | size_flags[size]
resp = await self._t.send_mix( if interactive:
Cmd.HF_MIFARE_SIMULATE, arg0=flags, payload=uid, timeout=60.0, 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} return {"status": resp.status}
async def nested(self, block: int, key: str | bytes = "FFFFFFFFFFFF", async def nested(self, block: int, key: str | bytes = "FFFFFFFFFFFF",
key_type: int = MF_KEY_A, 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.""" """Run nested attack to recover keys."""
key_bytes = self._parse_key(key) key_bytes = self._parse_key(key)
payload = struct.pack("<BB", block, key_type) + key_bytes # struct { block, keytype, target_block, target_keytype, bool calibrate, key[6] }
payload += struct.pack("<BB", target_block, target_key_type) 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) 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: # response struct { int16 isOK; u8 block; u8 keytype; u8 cuid[4];
"""Identify magic (gen1/gen2) card type.""" # u8 nt_a[4]; u8 ks_a[4]; u8 nt_b[4]; u8 ks_b[4] }
resp = await self._t.send_ng(Cmd.HF_MIFARE_CIDENT, timeout=5.0) if len(resp.data) < 24:
return {"status": resp.status, "data": resp.data.hex() if resp.data else None} 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.""" """Send break to stop long-running operations."""
await self._t.send_ng_no_response(Cmd.BREAK_LOOP) await self._t.send_ng_no_response(Cmd.BREAK_LOOP)
async def standalone(self) -> dict: async def standalone(self, arg: int = 1, mode: str | bytes = b"") -> dict:
"""Start standalone mode.""" """Start standalone mode.
resp = await self._t.send_ng(Cmd.STANDALONE, timeout=5.0)
return {"status": resp.status} 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: async def reset(self) -> None:
"""Reset the device.""" """Reset the device."""

View File

@@ -25,35 +25,73 @@ class T55xxCommands:
} }
async def writebl(self, block: int, data: int, page: int = 0, async def writebl(self, block: int, data: int, page: int = 0,
password: int | None = None, downlink_mode: int = 0) -> dict: password: int | None = None, downlink_mode: int = 0,
"""Write a T55xx block.""" 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 = password if password is not None else 0
pwd_mode = 1 if password is not None else 0 # Firmware t55xx_write_block_t: data(4)+pwd(4)+blockno(1)+flags(1).
payload = struct.pack("<IIBBBB", data, pwd, block, page, pwd_mode, downlink_mode) # 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) resp = await self._t.send_ng(Cmd.LF_T55XX_WRITEBL, payload, timeout=5.0)
return {"status": resp.status} 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.""" """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) resp = await self._t.send_ng(Cmd.LF_T55XX_WAKEUP, payload)
return {"status": resp.status} 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: async def config(self) -> dict:
"""Get T55xx timing configuration.""" """Return T55xx downlink 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 The firmware exposes no GET for the T55xx timing table, and
modes = [] CMD_LF_T55XX_SET_CONFIG is write-only (never replies), so the default
for i in range(4): table is maintained client-side.
offset = i * 14 """
if offset + 14 <= len(resp.data): return {"modes": [dict(m) for m in self._DEFAULT_MODES]}
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}
class LFCommands: class LFCommands:
@@ -69,12 +107,12 @@ class LFCommands:
divisor: LF frequency divisor (19-255). 95=125kHz, 88=134kHz. divisor: LF frequency divisor (19-255). 95=125kHz, 88=134kHz.
Freq = 12000/(divisor+1) kHz. Freq = 12000/(divisor+1) kHz.
""" """
# Send init # Send init — firmware requires length == 2 ({mode, divisor})
payload = struct.pack("<BBB", 1, divisor, 0) payload = struct.pack("<BB", 1, divisor)
await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_LF, payload, timeout=5.0) await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_LF, payload, timeout=5.0)
# Poll for result # Measure — reply carries the antenna voltage as a uint32 (mV)
payload = struct.pack("<BBB", 2, divisor, 0) payload = struct.pack("<BB", 2, divisor)
resp = await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_LF, payload, timeout=5.0) 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 voltage = struct.unpack_from("<I", resp.data, 0)[0] if len(resp.data) >= 4 else 0
@@ -94,23 +132,32 @@ class LFCommands:
return result return result
async def read(self, samples: int = 12288, verbose: bool = False, async def _acquire(self, cmd: int, samples: int, verbose: bool = False) -> dict:
on_progress: ProgressCallback = None) -> dict: """Capture LF ADC samples into BigBuf. Shared by read (reader field ON) and sniff
"""Read LF ADC samples into device buffer.""" (field OFF). Payload is lf_sample_payload_t — a PACKED bitfield: samples:30, realtime:1,
val = (samples & 0x3FFFFFFF) verbose:1, cotag:1 (5 bytes)."""
word = (samples & 0x3FFFFFFF)
if verbose: if verbose:
val |= (1 << 31) word |= (1 << 31) # bit 31 = verbose; bit 30 = realtime (0)
payload = struct.pack("<I", val) payload = struct.pack("<IB", word, 0) # trailing byte carries cotag (0)
resp = await self._t.send_ng(Cmd.LF_SNIFF_RAW_ADC, payload, timeout=10.0) resp = await self._t.send_ng(cmd, payload, timeout=10.0)
return { return {
"status": resp.status, "status": resp.status,
"samples": struct.unpack_from("<I", resp.data, 0)[0] if len(resp.data) >= 4 else 0, "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, async def sniff(self, samples: int = 12288,
on_progress: ProgressCallback = None) -> dict: on_progress: ProgressCallback = None) -> dict:
"""Sniff LF signal.""" """Sniff LF: reader field OFF, capture an external reader<->tag exchange
return await self.read(samples=samples, on_progress=on_progress) (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"", async def sim(self, gap: int = 0, data: bytes = b"",
on_progress: ProgressCallback = None) -> dict: 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), 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), samples_to_skip if samples_to_skip is not None else current.get("samples_to_skip", 0),
0) 0)
resp = await self._t.send_ng(Cmd.LF_SAMPLING_SET_CONFIG, payload) # Firmware never replies to LF_SAMPLING_SET_CONFIG — fire-and-forget
return {"status": resp.status, **await self.config()} 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, async def download_samples(self, start: int = 0, count: int = 30000,
on_progress: ProgressCallback = None) -> bytes: on_progress: ProgressCallback = None) -> bytes:
"""Download raw ADC samples from the device's BigBuf. """Download raw ADC samples from the device's BigBuf.
Must call read() or search() first to capture samples. Must call read() or search() first to capture samples. Returns raw bytes (1 byte per
Returns raw bytes (1 byte per sample, unsigned 0-255). 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() data = await self._t.download_bulk(
remaining = count Cmd.DOWNLOAD_BIGBUF, start, count, Cmd.DOWNLOADED_BIGBUF, Cmd.ACK, timeout=5.0,
offset = start )
if on_progress:
while remaining > 0: ret = on_progress({"type": "download", "downloaded": len(data), "total": count})
chunk = min(remaining, PM3_CMD_DATA_SIZE) if asyncio.iscoroutine(ret):
resp = await self._t.send_mix( await ret
Cmd.DOWNLOAD_BIGBUF, offset, chunk, 0, timeout=5.0, return data
)
# 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,
})
if asyncio.iscoroutine(ret):
await ret
offset += chunk
remaining -= chunk
return bytes(buf[:count])
async def search(self, on_progress: ProgressCallback = None) -> "LFSearchResult": async def search(self, on_progress: ProgressCallback = None) -> "LFSearchResult":
"""Search for LF tags. Captures samples and returns a result with callable next steps. """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_FMT = "<QQQ"
MIX_ARGS_SIZE = 24 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-16/A (ISO 14443-3) ---
_CRC_TABLE: list[int] = [] _CRC_TABLE: list[int] = []
@@ -68,6 +104,23 @@ def crc16_a(data: bytes) -> int:
return crc 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 --- # --- Error codes ---
class PM3Status(IntEnum): class PM3Status(IntEnum):
SUCCESS = 0 SUCCESS = 0
@@ -140,7 +193,8 @@ class Cmd(IntEnum):
TIA = 0x0117 TIA = 0x0117
BREAK_LOOP = 0x0118 BREAK_LOOP = 0x0118
SET_TEAROFF = 0x0119 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 GET_DBGMODE = 0x0120
# FPGA # FPGA
@@ -204,6 +258,7 @@ class Cmd(IntEnum):
HF_SNIFF_STREAM = 0x0337 HF_SNIFF_STREAM = 0x0337
HF_SNIFF_STATUS = 0x0338 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 commands (firmware response table)
SIM_TABLE_UPLOAD = 0x0900 SIM_TABLE_UPLOAD = 0x0900

View File

@@ -11,8 +11,31 @@ from .protocol import (
CMD_PREAMBLE_SIZE, CMD_POSTAMBLE_SIZE, CMD_PREAMBLE_SIZE, CMD_POSTAMBLE_SIZE,
RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE, RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE,
PM3_CMD_DATA_SIZE, MIX_ARGS_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: def _crc_to_wire(crc: int) -> int:
"""Byte-swap CRC to match PM3 wire format: (low << 8) | high.""" """Byte-swap CRC to match PM3 wire format: (low << 8) | high."""
@@ -93,6 +116,30 @@ def decode_response_frame(data: bytes) -> dict:
return result 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): class PM3Error(Exception):
"""Proxmark3 communication error.""" """Proxmark3 communication error."""
def __init__(self, message: str, status: int = 0): def __init__(self, message: str, status: int = 0):
@@ -130,6 +177,10 @@ class PM3Transport:
self._reader: asyncio.StreamReader | None = None self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None self._writer: asyncio.StreamWriter | None = None
self._lock = asyncio.Lock() 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: async def connect(self) -> None:
import serial_asyncio import serial_asyncio
@@ -159,23 +210,40 @@ class PM3Transport:
async def send_frame(self, frame: bytes) -> None: async def send_frame(self, frame: bytes) -> None:
if not self._writer: if not self._writer:
raise PM3Error("Not connected") 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) self._writer.write(frame)
await self._writer.drain() await self._writer.drain()
async def read_response(self, timeout: float = 2.0) -> PM3Response: 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: if not self._reader:
raise PM3Error("Not connected") raise PM3Error("Not connected")
extra = 0.0 # accumulated WTX wait-extension (seconds)
try: for _ in range(_MAX_ASYNC_SKIP):
# Read until we find response magic try:
raw = await asyncio.wait_for( raw = await asyncio.wait_for(self._read_response_frame(), timeout=timeout + extra)
self._read_response_frame(), timeout=timeout, except asyncio.TimeoutError:
) self._resync_needed = True # late response would desync the next read — drain it
d = decode_response_frame(raw) raise PM3Error("Response timeout", status=-4)
return PM3Response.from_dict(d) try:
except asyncio.TimeoutError: resp = PM3Response.from_dict(decode_response_frame(raw))
raise PM3Error("Response timeout", status=-4) 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: async def _read_response_frame(self) -> bytes:
"""Read a complete response frame, scanning for magic bytes.""" """Read a complete response frame, scanning for magic bytes."""
@@ -234,6 +302,157 @@ class PM3Transport:
frame = encode_ng_frame(cmd, payload) frame = encode_ng_frame(cmd, payload)
await self.send_frame(frame) 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, async def download_bigbuf(self, offset: int = 0, length: int = PM3_CMD_DATA_SIZE,
timeout: float = 4.0) -> tuple[bytes, int]: timeout: float = 4.0) -> tuple[bytes, int]:
"""Download data from device BigBuf. """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 from .dual_session import DualInterfaceSession
# --- Transponder models (canonical home: pm3py.transponders) --- # --- Transponder models (canonical home: pm3py.transponders) ---
from pm3py.transponders.hf.iso14443a.base import ( # Loaded lazily (PEP 562) from ._models. Those models import back into pm3py.sim for the
Tag14443A, Tag14443A_3, Tag14443A_4, Reader14443A, State14443A, # 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
from pm3py.transponders.hf.iso14443a.nxp.crypto1 import Crypto1 # first import). On first access they load from ._models and cache into this module's globals.
from pm3py.transponders.hf.iso14443a.nxp.mifare_classic import MifareClassicTag, MifareClassicReader def __getattr__(name):
from pm3py.transponders.hf.iso14443a.nxp.desfire import DesfireTag, DesfireReader if name.startswith("__") and name.endswith("__"):
from pm3py.transponders.hf.iso14443a.ndef import NfcType2Tag, NfcType4Tag raise AttributeError(name) # don't trigger a model import for dunder probes
from pm3py.transponders.hf.iso15693.base import Tag15693, Reader15693, State15693 import importlib
from pm3py.transponders.hf.iso15693.type5 import NfcType5Tag, ndef_text, ndef_uri, ndef_mime
from pm3py.transponders.hf.iso15693.nxp.nxp_icode import NxpIcodeTag models = importlib.import_module(f"{__name__}._models")
from pm3py.transponders.hf.iso15693.nxp.icode_slix import IcodeSlixTag try:
from pm3py.transponders.hf.iso15693.nxp.icode_slix2 import IcodeSlix2Tag value = getattr(models, name)
from pm3py.transponders.hf.iso15693.nxp.icode3 import Icode3Tag except AttributeError:
from pm3py.transponders.hf.iso15693.nxp.icode_dna import IcodeDnaTag raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
from pm3py.transponders.hf.iso15693.nxp.ntag5_platform import Ntag5PlatformTag globals()[name] = value # cache: subsequent access is a direct lookup
from pm3py.transponders.hf.iso15693.nxp.ntag5_switch import Ntag5SwitchTag return value
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 def __dir__():
from pm3py.transponders.lf.em.em4100 import EM4100Tag, EM4100Reader return sorted(set(globals()) | set(__all__))
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
__all__ = [ __all__ = [
"RFFrame", "RFFrame",
@@ -48,17 +46,39 @@ __all__ = [
"Reader", "ScriptedReader", "InteractiveReader", "ReaderStep", "StepResult", "Reader", "ScriptedReader", "InteractiveReader", "ReaderStep", "StepResult",
"Tag14443A", "Tag14443A_3", "Tag14443A_4", "Reader14443A", "State14443A", "Tag14443A", "Tag14443A_3", "Tag14443A_4", "Reader14443A", "State14443A",
"Crypto1", "Crypto1",
"MifareClassicTag", "MifareClassicReader", "MifareClassicTag", "MifareClassicReader", "MifareAccessConditions",
"Tag15693", "Reader15693", "State15693", "Tag15693", "Reader15693", "State15693",
"TagLF", "ReaderLF", "Modulation", "TagLF", "ReaderLF", "Modulation",
"EM4100Tag", "EM4100Reader", "EM4100Tag", "EM4100Reader", "EM4100Code",
"HIDProxTag", "HIDReader", "HIDProxTag", "HIDReader",
"T5577Tag", "T5577Reader", "T5577Tag", "T5577Reader", "T5577Config",
"Hitag2Tag", "Hitag2Reader",
"BitField", "Register",
"NfcType2Tag", "NfcType4Tag", "NfcType5Tag", "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", "ndef_text", "ndef_uri", "ndef_mime",
"NxpIcodeTag", "IcodeSlixTag", "IcodeSlix2Tag", "NxpIcodeTag", "IcodeSlixTag", "IcodeSlix2Tag",
"Icode3Tag", "IcodeDnaTag", "Icode3Tag", "IcodeDnaTag",
"Ntag5PlatformTag", "Ntag5SwitchTag", "Ntag5LinkTag", "Ntag5BoostTag", "Ntag5PlatformTag", "Ntag5SwitchTag", "Ntag5LinkTag", "Ntag5BoostTag",
"Ntag5StatusReg", "Ntag5ConfigReg",
"NxpKeyPrivileges", "IcodeFeatureFlags",
"DesfireTag", "DesfireReader", "DesfireTag", "DesfireReader",
"xEM", "xNT", "xM1", "FlexDF", "NExT", "MagicMifareClassicTag", "xEM", "xNT", "xM1", "FlexDF", "NExT", "MagicMifareClassicTag",
"MutationFuzzer", "GrammarFuzzer", "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

@@ -6,7 +6,6 @@ from typing import Iterator
from bitarray import bitarray from bitarray import bitarray
from .frame import RFFrame from .frame import RFFrame
from pm3py.transponders.hf.iso14443a.base import CL1, NVB_SELECT, _compute_bcc
class MutationFuzzer: class MutationFuzzer:
@@ -76,6 +75,9 @@ class GrammarFuzzer:
return None return None
def _gen_14443a(self, command: str, **overrides) -> RFFrame | 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: match command:
case "REQA": case "REQA":
return RFFrame.from_bytes(b"\x26") return RFFrame.from_bytes(b"\x26")

View File

@@ -11,6 +11,8 @@ import serial
from .frame import RFFrame from .frame import RFFrame
from .table_compiler import ResponseTable, TableCompiler from .table_compiler import ResponseTable, TableCompiler
from pm3py.trace.format 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 .transponder import Transponder
from ..core.protocol import Cmd from ..core.protocol import Cmd
from ..core.transport import encode_ng_frame, decode_response_frame, RESP_PREAMBLE_MAGIC, RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE 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_CLEAR = 0x0951
CMD_SIM_TABLE_UPDATE = 0x0952 CMD_SIM_TABLE_UPDATE = 0x0952
CMD_HF_ISO15693_SIM_TRACE = 0x0336 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 NG payload per frame
MAX_PAYLOAD = 512 MAX_PAYLOAD = 512
@@ -50,6 +66,10 @@ class SimSession:
self._tag_model: Transponder | None = None self._tag_model: Transponder | None = None
self._formatter: TraceFormatter | None = None self._formatter: TraceFormatter | None = None
self.on_field_strength: callable | None = None # callback(adc_mv: int) 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 @classmethod
def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SimSession": def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SimSession":
@@ -94,35 +114,118 @@ class SimSession:
"""Clear the firmware response table.""" """Clear the firmware response table."""
await self._t.send_ng(CMD_SIM_TABLE_CLEAR, b"") await self._t.send_ng(CMD_SIM_TABLE_CLEAR, b"")
async def start_14a(self, tag, compile: bool = True) -> None: def start_14a(self, tag, tagtype: int = 7, trace: bool = False,
"""Compile table, upload, start 14443-A sim with WTX relay loop.""" on_frame=None, quiet: bool = False, exit_after: int = 0) -> None:
self._tag_model = tag """Start an ISO 14443-A (Layer 3) sim, optionally streaming a live trace.
if compile:
if hasattr(tag, '_keys_a'):
table = TableCompiler.compile_mifare(tag)
else:
table = TableCompiler.compile_14a(tag)
await self.upload_table(table)
# 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 uid = tag._uid
flag_val = 0x0001 # FLAG_INTERACTIVE
if len(uid) == 4: if len(uid) == 4:
flag_val |= 0x0010 uid_flag = 0x0010 # FLAG_4B_UID_IN_DATA
elif len(uid) == 7: elif len(uid) == 7:
flag_val |= 0x0020 uid_flag = 0x0020 # FLAG_7B_UID_IN_DATA
elif len(uid) == 10: elif len(uid) == 10:
flag_val |= 0x0030 uid_flag = 0x0030 # FLAG_10B_UID_IN_DATA
atqa = tag.atqa if hasattr(tag, 'atqa') else b"\x04\x00" else:
sak = tag.sak if hasattr(tag, 'sak') else 0x08 raise ValueError("UID must be 4, 7, or 10 bytes")
payload = atqa + bytes([sak]) + uid flag_val = uid_flag
await self._t.send_ng_no_response(Cmd.HF_ISO14443A_SIMULATE) if stream:
# TODO: use send_mix for 14a sim (MIX frame format) 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._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). """Start 15693 sim (synchronous).
Firmware handles standard commands (inventory, read, write) autonomously. Firmware handles standard commands (inventory, read, write) autonomously.
@@ -131,8 +234,26 @@ class SimSession:
Args: Args:
trace: If True, print live reader↔tag communication to console. 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._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 # Resolve the raw serial port
if self._port: if self._port:
@@ -147,7 +268,7 @@ class SimSession:
# Start sim with UID and block_size — same format as PM3 client # Start sim with UID and block_size — same format as PM3 client
# Client sends UID as-is (firmware reverses internally) # 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 block_size = tag._block_size if hasattr(tag, '_block_size') else 4
payload = tag._uid + bytes([block_size, flags]) payload = tag._uid + bytes([block_size, flags])
frame = encode_ng_frame(Cmd.HF_ISO15693_SIMULATE, payload) frame = encode_ng_frame(Cmd.HF_ISO15693_SIMULATE, payload)
@@ -168,7 +289,7 @@ class SimSession:
if compile: if compile:
self._compile_and_upload_table(tag, ser) self._compile_and_upload_table(tag, ser)
if trace: if stream:
self._formatter = TraceFormatter(mode="sim", decoder=tag.decode_trace, crc_len=2) self._formatter = TraceFormatter(mode="sim", decoder=tag.decode_trace, crc_len=2)
self._trace_thread = threading.Thread( self._trace_thread = threading.Thread(
target=self._trace_reader, args=(ser,), daemon=True target=self._trace_reader, args=(ser,), daemon=True
@@ -176,7 +297,7 @@ class SimSession:
self._trace_thread.start() self._trace_thread.start()
print(f"[SimSession] 15693 sim started, UID={tag._uid.hex()}" 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()") print(f"[SimSession] Modify tag in REPL, then call tag.sync()")
def _compile_and_upload_table(self, tag, ser) -> None: def _compile_and_upload_table(self, tag, ser) -> None:
@@ -268,19 +389,45 @@ class SimSession:
resp = decode_response_frame(bytes(buf[:frame_len])) resp = decode_response_frame(bytes(buf[:frame_len]))
buf = 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"] data = resp["data"]
if len(data) >= 2: if len(data) >= 2:
direction = data[0] & 0x0F direction = data[0] & 0x0F
if direction == 0x03 and len(data) >= 3: if protocol == 0 and direction == 0x03 and len(data) >= 3:
# ADC field strength report # ADC field strength report (ISO 15693 only)
adc_mv = (data[1] << 8) | data[2] adc_mv = (data[1] << 8) | data[2]
if self.on_field_strength: if self.on_field_strength:
self.on_field_strength(adc_mv) self.on_field_strength(adc_mv)
else: else:
crc_fail = bool(data[0] & 0x80) crc_fail = bool(data[0] & 0x80)
payload = data[1:] 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: except Exception:
buf = buf[4:] # skip bad magic, try again buf = buf[4:] # skip bad magic, try again
@@ -305,8 +452,9 @@ class SimSession:
if resp is None: if resp is None:
continue continue
elif resp.cmd == Cmd.HF_ISO15693_SIMULATE: elif resp.cmd in (Cmd.HF_ISO15693_SIMULATE, Cmd.HF_MIFARE_SIMULATE):
# Sim ended (firmware sent final reply) # 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}") print(f"[SimSession] Sim ended, status={resp.status}")
self._active = False self._active = False
break break
@@ -361,6 +509,20 @@ class SimSession:
payload = struct.pack("<IH", offset, len(data)) + data payload = struct.pack("<IH", offset, len(data)) + data
await self._t.send_ng_no_response(Cmd.HF_ISO15693_EML_SETMEM, payload) 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: def stop(self) -> None:
"""Stop simulation.""" """Stop simulation."""
self._active = False self._active = False

View File

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

View File

@@ -1,6 +1,5 @@
"""Sniff sessions — live streaming with button-toggle, or legacy batch.""" """Sniff sessions — live streaming with button-toggle, or legacy batch."""
import struct import struct
import sys
import threading import threading
import time import time
@@ -19,6 +18,7 @@ from ..core.transport import (
from ..trace import parse_tracelog from ..trace import parse_tracelog
from ..trace.decode_iso15 import decode_15693 from ..trace.decode_iso15 import decode_15693
from ..trace.format import TraceFormatter, format_sniff_line from ..trace.format import TraceFormatter, format_sniff_line
from ..trace.output import emit
# Default serial port (same as SimSession) # Default serial port (same as SimSession)
DEFAULT_PORT = "/dev/ttyACM0" DEFAULT_PORT = "/dev/ttyACM0"
@@ -65,6 +65,8 @@ class SniffSession:
self._reader_thread: threading.Thread | None = None self._reader_thread: threading.Thread | None = None
self._formatter: TraceFormatter | None = None self._formatter: TraceFormatter | None = None
self._state = "idle" self._state = "idle"
self._on_frame = None
self._quiet = False
@classmethod @classmethod
def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SniffSession": def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SniffSession":
@@ -88,14 +90,20 @@ class SniffSession:
return cls(ser) return cls(ser)
def start_15693(self, live: bool = True, idle_timeout_ms: int = 400) -> None: def start_15693(self, live: bool = True, idle_timeout_ms: int = 400,
on_frame=None, quiet: bool = False) -> None:
"""Start 15693 sniff session. """Start 15693 sniff session.
live=True (default): streaming with button-toggle pause/resume. live=True (default): streaming with button-toggle pause/resume.
live=False: legacy BigBuf batch mode, blocks until button exits. 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).
""" """
if self._active: if self._active:
self.stop() self.stop()
self._on_frame = on_frame
self._quiet = quiet
self._protocol = 0 # 15693 self._protocol = 0 # 15693
flags = 0 flags = 0
@@ -190,10 +198,13 @@ class SniffSession:
"duration": duration, "duration": duration,
"timestamp": timestamp, "timestamp": timestamp,
"data": payload, "data": payload,
"data_hex": payload.hex(),
"decoded": self._formatter.decode(direction, payload) if self._formatter else None,
} }
self._entries.append(entry) self._entries.append(entry)
if self._on_frame is not None:
if self._formatter: self._on_frame(entry)
if self._formatter and not self._quiet:
self._formatter.print(direction, payload) self._formatter.print(direction, payload)
def _handle_status(self, data: bytes) -> None: def _handle_status(self, data: bytes) -> None:
@@ -209,8 +220,7 @@ class SniffSession:
count_str = f" ({count} frames)" if count > 0 else "" count_str = f" ({count} frames)" if count > 0 else ""
msg = f"\n[Snf] {icon} {label}{count_str}\n" msg = f"\n[Snf] {icon} {label}{count_str}\n"
sys.stdout.write(msg) emit(msg)
sys.stdout.flush()
if state == SNIFF_STATE_STOPPED: if state == SNIFF_STATE_STOPPED:
self._active = False self._active = False

View File

@@ -13,6 +13,84 @@ def decode_14443a(direction: int, payload: bytes) -> str | None:
return _decode_14443a_response(payload) return _decode_14443a_response(payload)
# ---- NFC Forum Type 2 / MIFARE Ultralight / NTAG command set ----
#
# These decoders cover the byte-oriented command set shared by MIFARE
# Ultralight (MF0ICU2), Ultralight EV1 (MF0UL11/21), NTAG21x
# (NTAG210/212/213/215/216) and NTAG I2C plus (NT3H2111/2211). They are
# layered on top of the generic 14443-A decoder by the tag models'
# ``decode_trace`` (see NfcType2Tag), mirroring how the NXP ICODE tags
# layer ``decode_15693_nxp`` over the base 15693 decoder.
_TYPE2_CMDS = {
0x30: "READ",
0x3A: "FAST_READ",
0xA2: "WRITE",
0xA0: "COMPAT_WRITE",
0x60: "GET_VERSION",
0x1B: "PWD_AUTH",
0x39: "READ_CNT",
0xA5: "INCR_CNT",
0x3E: "CHECK_TEARING",
0x3C: "READ_SIG",
0xC2: "SECTOR_SELECT",
0x50: "HALT",
}
def _decode_type2_request(payload: bytes) -> str | None:
b0 = payload[0]
name = _TYPE2_CMDS.get(b0)
if name is None:
return None
if b0 == 0x30 and len(payload) >= 2: # READ <page>
return f"READ p{payload[1]}"
if b0 == 0x3A and len(payload) >= 3: # FAST_READ <start> <end>
return f"FAST_READ p{payload[1]}-{payload[2]}"
if b0 in (0xA2, 0xA0) and len(payload) >= 2: # WRITE / COMPAT_WRITE <page>
return f"{name} p{payload[1]}"
if b0 == 0x1B and len(payload) >= 5: # PWD_AUTH <4-byte pwd>
return f"PWD_AUTH {payload[1:5].hex().upper()}"
if b0 == 0x39 and len(payload) >= 2: # READ_CNT <counter>
return f"READ_CNT #{payload[1]}"
if b0 == 0xA5 and len(payload) >= 2: # INCR_CNT <counter>
return f"INCR_CNT #{payload[1]}"
if b0 == 0x3E and len(payload) >= 2: # CHECK_TEARING <counter>
return f"CHECK_TEARING #{payload[1]}"
if b0 == 0xC2 and len(payload) >= 2 and payload[1] == 0xFF:
return "SECTOR_SELECT p1" # packet 1 (C2 FF)
return name
def _decode_type2_response(payload: bytes) -> str | None:
# 4-bit ACK/NAK acknowledge responses
if len(payload) == 1:
b0 = payload[0]
if b0 == 0x0A:
return "ACK"
if b0 in (0x00, 0x01, 0x04, 0x05):
return f"NAK 0x{b0:X}"
if len(payload) == 8:
return f"VERSION {payload.hex().upper()}"
return None
def decode_14443a_type2(direction: int, payload: bytes) -> str | None:
"""Decode a Type 2 (Ultralight/NTAG) frame, falling back to generic 14443-A.
Used by the ``decode_trace`` of NfcType2Tag and its subclasses so that
byte-oriented commands (READ, FAST_READ, GET_VERSION, PWD_AUTH, counters,
SECTOR_SELECT) get named while anticollision/REQA/SAK still decode via the
base decoder.
"""
if not payload:
return None
if direction == 0:
return _decode_type2_request(payload) or _decode_14443a_request(payload)
return _decode_type2_response(payload) or _decode_14443a_response(payload)
def _decode_14443a_request(payload: bytes) -> str | None: def _decode_14443a_request(payload: bytes) -> str | None:
b0 = payload[0] b0 = payload[0]

View File

@@ -0,0 +1,98 @@
"""ISO 14443 Type B (SRIX / ST25TB lineage) request/response decoders + CRC_B."""
from __future__ import annotations
# ST25TB / SRIX command opcodes
_14B_CMDS = {
0x08: "READ_BLOCK",
0x09: "WRITE_BLOCK",
0x0B: "GET_UID",
0x0C: "RESET_TO_INVENTORY",
0x0E: "SELECT",
0x0F: "COMPLETION",
}
def decode_14443b(direction: int, payload: bytes) -> str | None:
"""Decode an ISO 14443 Type B (ST25TB/SRIX) frame."""
if not payload:
return None
if direction == 0:
return _decode_request(payload)
return _decode_response(payload)
def _decode_request(payload: bytes) -> str | None:
b0 = payload[0]
# SRIX / ST25TB anticollision (opcode 0x06)
if b0 == 0x06 and len(payload) >= 2:
if payload[1] == 0x00:
return "INITIATE"
if payload[1] == 0x04:
return "PCALL16"
return f"ANTICOL 0x{payload[1]:02X}"
# SRIX slot marker: low nibble = 6, high nibble = slot 1..15
if (b0 & 0x0F) == 0x06 and (b0 >> 4) != 0:
return f"SLOT_MARKER {b0 >> 4}"
# Standard ISO 14443-3 Type B (REQB/WUPB/ATTRIB/HLTB)
if b0 == 0x05 and len(payload) >= 3:
return "WUPB" if (payload[2] & 0x08) else "REQB"
if (b0 & 0x0F) == 0x05 and (b0 >> 4) != 0:
return f"SLOT-MARKER {b0 >> 4}"
if b0 == 0x1D:
return "ATTRIB"
if b0 == 0x50 and len(payload) >= 5:
return "HLTB"
# SRIX / ST25TB memory + select opcodes (must precede the I-block fallback,
# since they share the PCB=00 bit pattern).
name = _14B_CMDS.get(b0)
if name is not None:
if b0 == 0x08 and len(payload) >= 2:
return f"READ_BLOCK #{payload[1]}"
if b0 == 0x09 and len(payload) >= 2:
return f"WRITE_BLOCK #{payload[1]}"
if b0 == 0x0E and len(payload) >= 2:
return f"SELECT chip=0x{payload[1]:02X}"
return name
# ISO-DEP I-block (PCB) — fallback for standard Type B Layer 4.
if b0 & 0xC0 == 0x00 and len(payload) > 1:
return f"I-BLOCK({b0 & 0x01})"
return None
def _decode_response(payload: bytes) -> str | None:
# ATQB: 50 | PUPI(4) | AppData(4) | ProtInfo(3) = 12 bytes
if len(payload) == 12 and payload[0] == 0x50:
return f"ATQB PUPI={payload[1:5].hex().upper()}"
if len(payload) == 1:
return f"CHIP_ID 0x{payload[0]:02X}"
if len(payload) == 4:
return f"BLOCK {payload.hex().upper()}"
if len(payload) == 8:
return f"UID {bytes(reversed(payload)).hex().upper()}"
# ISO-DEP I-block response
if payload[0] & 0xC0 == 0x00 and len(payload) > 1:
return f"I-BLOCK({payload[0] & 0x01})"
return None
def crc14b(data: bytes) -> bytes:
"""ISO/IEC 14443-3 Type B CRC (CRC_B), transmitted LSByte first.
Polynomial x^16 + x^12 + x^5 + 1, init 0xFFFF, result one's-complemented,
reflected. Matches the reference routine in the ST25TB datasheet appendix.
"""
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
if crc & 0x0001:
crc = (crc >> 1) ^ 0x8408
else:
crc >>= 1
crc = (~crc) & 0xFFFF
return bytes([crc & 0xFF, (crc >> 8) & 0xFF])

View File

@@ -42,6 +42,26 @@ _15693_NXP_CMDS = {
0xBD: "NXP READ SIGNATURE", 0xBD: "NXP READ SIGNATURE",
} }
_15693_ST_CMDS = {
0xA0: "ST READ CONFIG",
0xA1: "ST WRITE CONFIG",
0xA6: "ST KILL",
0xB1: "ST WRITE PASSWORD",
0xB3: "ST PRESENT PASSWORD",
0xB4: "ST GET RANDOM",
0xBA: "ST TOGGLE DISCREET/SILENT",
0xB5: "ST FAST READ DYN CONFIG",
0xB7: "ST FAST WRITE DYN CONFIG",
0xAA: "ST READ DYN CONFIG",
0xAB: "ST WRITE DYN CONFIG",
0xAC: "ST READ MAILBOX MSG LEN",
0xAD: "ST READ MAILBOX MSG",
0xAE: "ST WRITE MAILBOX MSG",
0xC0: "ST MANAGE GPO",
0xD1: "ST INVENTORY INITIATED",
0xD2: "ST INITIATE",
}
_15693_ERRORS = { _15693_ERRORS = {
0x01: "not supported", 0x01: "not supported",
0x02: "not recognized", 0x02: "not recognized",
@@ -212,3 +232,22 @@ def decode_15693_nxp(direction: int, payload: bytes) -> str | None:
if name is not None: if name is not None:
return _annotate_nxp_request(name, cmd, payload) return _annotate_nxp_request(name, cmd, payload)
return None return None
def decode_15693_st(direction: int, payload: bytes) -> str | None:
"""Decode an ST (STMicroelectronics) custom ISO 15693 command."""
if direction != 0:
return None
if len(payload) < 2:
return None
cmd = payload[1]
name = _15693_ST_CMDS.get(cmd)
if name is None:
return None
# ST custom commands carry the mfg code (0x02) then, often, FID/PID or a
# password id. Annotate the common ones.
if cmd in (0xA0, 0xA1) and len(payload) >= 5: # READ/WRITE CONFIG: FID/PID
return f"{name} FID={payload[3]:02X} PID={payload[4]:02X}"
if cmd in (0xB3, 0xB1, 0xBA) and len(payload) >= 4: # password id
return f"{name} id={payload[3]}"
return name

View File

@@ -6,6 +6,7 @@ import signal
import sys import sys
from .decode_iso15 import decode_15693 from .decode_iso15 import decode_15693
from .output import emit
# ---- ANSI colors ---- # ---- ANSI colors ----
_C_CYAN = "\033[36m" _C_CYAN = "\033[36m"
@@ -65,6 +66,21 @@ class TraceFormatter:
return text return text
return f"{code}{text}{_C_RESET}" return f"{code}{text}{_C_RESET}"
def decode(self, direction: int, payload: bytes, crc_fail: bool = False) -> str | None:
"""Return the plain-text decoded annotation for a frame — no ANSI, no hex.
The same annotation ``format()`` renders (e.g. "INVENTORY", "READ BLOCK 4"),
exposed as structured data for scripting/callbacks.
"""
if self._crc_len > 0 and len(payload) > self._crc_len:
data = payload[:-self._crc_len]
else:
data = payload
annotation = self._decoder(direction, data) if self._decoder is not None else None
if crc_fail:
annotation = f"{annotation} BAD CRC" if annotation else "BAD CRC"
return annotation
def format(self, direction: int, payload: bytes, crc_fail: bool = False) -> str: def format(self, direction: int, payload: bytes, crc_fail: bool = False) -> str:
"""Format a trace line with colors, decoding, and wrapping.""" """Format a trace line with colors, decoding, and wrapping."""
self._line_count += 1 self._line_count += 1
@@ -135,7 +151,7 @@ class TraceFormatter:
line = f"{prefix_colored}{hex_colored}{' ' * gap}{ann_colored}" line = f"{prefix_colored}{hex_colored}{' ' * gap}{ann_colored}"
else: else:
line = f"{prefix_colored}{hex_colored}" line = f"{prefix_colored}{hex_colored}"
return f"\n{line}" return f"{line}\n"
# Multi-line # Multi-line
pad = " " * prefix_len pad = " " * prefix_len
@@ -153,7 +169,7 @@ class TraceFormatter:
ann_pad = max(avail - len(al), 0) ann_pad = max(avail - len(al), 0)
parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, al)}") parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, al)}")
return "\n" + "\n".join(parts) return "\n".join(parts) + "\n"
def _color_hex_line(self, line: str, data_hex: str, crc_hex: str) -> str: def _color_hex_line(self, line: str, data_hex: str, crc_hex: str) -> str:
if not crc_hex: if not crc_hex:
@@ -214,9 +230,8 @@ class TraceFormatter:
return lines or [""] return lines or [""]
def print(self, direction: int, payload: bytes, crc_fail: bool = False) -> None: def print(self, direction: int, payload: bytes, crc_fail: bool = False) -> None:
"""Format and print a trace line to stdout.""" """Format and print a trace line, live even under bpython."""
sys.stdout.write(self.format(direction, payload, crc_fail=crc_fail)) emit(self.format(direction, payload, crc_fail=crc_fail))
sys.stdout.flush()
def format_sniff_line(entry: dict, width: int = 120, is_tty: bool = True) -> str: def format_sniff_line(entry: dict, width: int = 120, is_tty: bool = True) -> str:
@@ -228,5 +243,5 @@ def format_sniff_line(entry: dict, width: int = 120, is_tty: bool = True) -> str
width=width, is_tty=is_tty, crc_len=2) width=width, is_tty=is_tty, crc_len=2)
direction = 1 if entry["is_response"] else 0 direction = 1 if entry["is_response"] else 0
result = fmt.format(direction, entry["data"]) result = fmt.format(direction, entry["data"])
# Strip leading newline that TraceFormatter adds # Strip the trailing newline that TraceFormatter adds
return result.lstrip("\n") return result.rstrip("\n")

75
pm3py/trace/output.py Normal file
View File

@@ -0,0 +1,75 @@
"""Terminal output that stays live under interactive REPLs.
Live sniff and sim stream trace frames from a background thread. Writing those
straight to ``sys.stdout`` is fine in a plain REPL, but bpython replaces stdout
with a capture object whose ``write`` hands control back to the main REPL thread
via a greenlet switch — which raises from any other thread. The text is buffered
first, so it survives, but the display only repaints on the next keypress, so
streamed frames appear to "pile up until you touch a key".
bpython exposes exactly the escape hatch we need: ``_interrupting_refresh_callback``
is a curtsies ``threadsafe_event_trigger`` built to wake the input loop from
another thread. So under bpython we buffer the text directly (via the repl's
``on_write``, skipping the greenlet-switching ``write``) and then fire that
trigger to force an immediate repaint. Everywhere else — plain python, IPython —
it is just a direct write. All of the bpython plumbing is internal, so any
mismatch falls back to a plain write and the sniff keeps working (just
keypress-flushed) rather than breaking.
The detection is intentionally bpython-only: the "wake the render loop" step is
per-REPL and there is no universal hook, so we special-case the one REPL we use
and let every other environment take the direct-write default.
"""
from __future__ import annotations
import sys
def _bpython_sink():
"""Return ``(on_write, wake)`` for the running bpython repl, else ``None``.
``sys.stdout`` under bpython is a ``FakeOutput`` whose ``on_write`` is a bound
method of the running repl; that repl carries the thread-safe repaint trigger.
Every access here is bpython-internal, so a miss just yields ``None``.
"""
out = sys.stdout
if type(out).__module__.split(".", 1)[0] != "bpython":
return None
on_write = getattr(out, "on_write", None)
repl = getattr(on_write, "__self__", None)
wake = getattr(repl, "_interrupting_refresh_callback", None)
if callable(on_write) and callable(wake):
return on_write, wake
return None
def emit(text: str) -> None:
"""Write trace ``text`` to the terminal, live even under bpython.
Safe to call from a background thread. Under bpython the text is buffered and
an immediate repaint is triggered; otherwise it is a plain stdout write.
"""
sink = None
try:
sink = _bpython_sink()
except Exception:
sink = None
if sink is not None:
on_write, wake = sink
try:
on_write(text)
except Exception:
pass # fall through to a plain write below
else:
try:
wake()
except Exception:
pass
return
try:
sys.stdout.write(text)
sys.stdout.flush()
except Exception:
pass

View File

@@ -0,0 +1,166 @@
"""Self-describing bit-field registers.
A ``Register`` is a fixed-width integer — a config word, a status byte — whose bits carry
named, documented meaning. Declare the layout once with ``BitField`` descriptors and you get,
for free:
* read / edit by field name ``cfg.modulation``, ``cfg.max_block = 3``
* ``<tab>`` completion of the field names in the shell, each field's one-line ``doc`` as the
autocomplete tooltip (bpython/ipython read the descriptor docstring)
* a decoded table when you print it — the "always-on hover"
The point is that a bare ``0x00148040`` tells you nothing, while the register spells it out.
Nobody hand-decodes these words from a datasheet if the object does it for them.
"""
from __future__ import annotations
import sys
from typing import Any
# House-style ANSI (mirrors pm3py/trace/format.py); TTY-gated so tests/pipes stay plain text.
_DIM = "\033[2m"
_CYAN = "\033[36m"
_RESET = "\033[0m"
def _color(code: str, text: str) -> str:
if not code or not sys.stdout.isatty():
return text
return f"{code}{text}{_RESET}"
class BitField:
"""A named slice of a register's bits.
``BitField(hi, lo)`` covers bits ``[lo, hi]`` inclusive (``lo`` defaults to ``hi`` for a
single bit). ``doc`` is the one-line meaning shown in completion tooltips and the decode
table. ``values`` is an optional ``{raw_code: label}`` map — when given, reads return the
label and writes accept either the label or the raw code (single-bit fields with no map
read/write as ``bool``).
"""
def __init__(self, hi: int, lo: int | None = None, *, doc: str = "",
values: dict[int, Any] | None = None):
if lo is None:
lo = hi
self.hi, self.lo = max(hi, lo), min(hi, lo)
self.width = self.hi - self.lo + 1
self.doc = doc
self.values = values
self._reverse = {v: k for k, v in values.items()} if values else {}
self.is_bool = self.width == 1 and values is None
self.name = "?"
self.__doc__ = doc # so `cfg.field?` / bpython introspection shows the meaning
def __set_name__(self, owner: type, name: str) -> None:
self.name = name
@property
def _mask(self) -> int:
return ((1 << self.width) - 1) << self.lo
def raw(self, reg: "Register") -> int:
"""The raw code stored in these bits, before any ``values`` mapping."""
return (reg.value >> self.lo) & ((1 << self.width) - 1)
def __get__(self, reg: "Register | None", owner: type | None = None):
if reg is None:
return self
code = self.raw(reg)
if self.values is not None:
return self.values.get(code, code)
return bool(code) if self.is_bool else code
def __set__(self, reg: "Register", value: Any) -> None:
code = self._encode(value)
if code < 0 or code >> self.width:
raise ValueError(f"{self.name}: {value!r} does not fit in {self.width} bit(s)")
reg.value = (reg.value & ~self._mask) | (code << self.lo)
def _encode(self, value: Any) -> int:
if self._reverse and value in self._reverse:
return self._reverse[value]
if isinstance(value, bool):
return int(value)
if isinstance(value, int):
return value
raise ValueError(f"{self.name}: cannot set to {value!r}")
def display(self, reg: "Register") -> str:
if self.is_bool:
return "on" if self.raw(reg) else "off"
return str(self.__get__(reg))
@property
def bits(self) -> str:
return f"[{self.hi}]" if self.width == 1 else f"[{self.hi}:{self.lo}]"
class Register:
"""Base for a fixed-width register. Subclass, set ``_width_bits`` / ``_label``, and declare
``BitField`` class attributes. Construct from a raw value (``Reg(0x00148040)``) or empty."""
_width_bits = 32
_label = "register"
def __init__(self, value: "int | Register" = 0):
self.value = int(value) & ((1 << self._width_bits) - 1)
def __int__(self) -> int:
return self.value
def __index__(self) -> int:
return self.value
def __bytes__(self) -> bytes:
return self.value.to_bytes(self._width_bits // 8, "big")
def __eq__(self, other: object) -> bool:
if isinstance(other, Register):
return type(self) is type(other) and self.value == other.value
if isinstance(other, int):
return self.value == other
return NotImplemented
def __hash__(self) -> int:
return hash((type(self), self.value))
@classmethod
def fields(cls) -> list[tuple[str, BitField]]:
"""All declared ``BitField``s, highest bit first (base classes included)."""
seen: dict[str, BitField] = {}
for klass in cls.__mro__:
for name, obj in vars(klass).items():
if isinstance(obj, BitField) and name not in seen:
seen[name] = obj
return sorted(seen.items(), key=lambda kv: kv[1].lo, reverse=True)
def _rows(self) -> list[dict]:
"""Decoded rows (dicts: name/value/bits/doc/lo), high bit first. Subclasses may add
computed rows via ``_extra_rows`` (e.g. a mode-dependent field)."""
rows = [
{"name": name, "value": bf.display(self), "bits": bf.bits, "doc": bf.doc, "lo": bf.lo}
for name, bf in self.fields()
]
rows.extend(self._extra_rows())
rows.sort(key=lambda r: r["lo"], reverse=True)
return rows
def _extra_rows(self) -> list[dict]:
return []
def decode(self) -> dict[str, Any]:
"""Plain ``{field: value}`` mapping — no ANSI, handy for tests and programmatic use."""
return {r["name"]: r["value"] for r in self._rows()}
def __repr__(self) -> str:
rows = self._rows()
hexw = self._width_bits // 4
head = f"{self._label} = 0x{self.value:0{hexw}X}"
name_w = max((len(r["name"]) for r in rows), default=0)
val_w = max((len(r["value"]) for r in rows), default=0)
lines = [head]
for r in rows:
line = f" {r['name']:<{name_w}} {r['value']:<{val_w}} {_color(_DIM, r['bits'])}"
lines.append(line.rstrip())
return _color(_CYAN, head) + "\n" + "\n".join(lines[1:])

View File

@@ -0,0 +1 @@
"""Infineon ISO 14443-A transponders (OPTIGA Authenticate NBT)."""

View File

@@ -0,0 +1,475 @@
"""OPTIGA(TM) Authenticate NBT (NBT2000A8K0T4) — Infineon NFC bridge tag.
Sourced from the Infineon Extended Datasheet Rev 2.1. NFC Forum Type 4 Tag over
ISO/IEC 14443-A (7-byte UID, Infineon mfg code 0x05), with an 8 KB file system
(4 KB NDEF + 4x1 KB proprietary files), a File Access Policy (EF.FAP) gating
per-file read/write, 32-bit password management, and ECDSA (NIST P-256) one-way
tag authentication.
Modelled here (the NFC side): activation + ISO-DEP, the Type 4 file tree
(EF.CC / NDEF / 4 proprietary / EF.FAP), SELECT (by AID and by file id with
inline password verification), READ BINARY / UPDATE BINARY with FAP enforcement,
CREATE / DELETE / CHANGE / UNBLOCK PASSWORD, AUTHENTICATE TAG (ECDSA-P256),
GET DATA (applet version) and the PERSONALIZATION -> OPERATIONAL life cycle.
The I2C bridge / pass-through side is not simulated.
"""
from __future__ import annotations
import os
import struct
from pm3py.sim.frame import RFFrame
from ..base import Tag14443A_4
# File IDs
FID_CC = b"\xE1\x03"
FID_NDEF = b"\xE1\x04"
FID_PP = [b"\xE1\xA1", b"\xE1\xA2", b"\xE1\xA3", b"\xE1\xA4"]
FID_FAP = b"\xE1\xAF"
NDEF_AID = b"\xD2\x76\x00\x00\x85\x01\x01"
# Command INS (CLA 0x00)
INS_SELECT = 0xA4
INS_PERSONALIZE = 0xE2
INS_READ_BINARY = 0xB0
INS_AUTH_TAG = 0x88
INS_UPDATE_BINARY = 0xD6
INS_CHANGE_UNBLOCK_PWD = 0x24
INS_CREATE_PWD = 0xE1
INS_DELETE_PWD = 0xE4
INS_GET_DATA = 0x30
# FAP config byte values
FAP_NEVER = 0x00
FAP_ALWAYS = 0x40
FAP_PWD = 0x80 # | password-id in low 5 bits
# Status words
SW_OK = b"\x90\x00"
SW_WRONG_LENGTH = b"\x67\x00"
SW_SEC_NOT_SATISFIED = b"\x69\x82"
SW_COND_NOT_SATISFIED = b"\x69\x85"
SW_WRONG_DATA = b"\x6A\x80"
SW_FILE_NOT_FOUND = b"\x6A\x82"
SW_WRONG_P1P2 = b"\x6A\x86"
SW_REF_NOT_FOUND = b"\x6A\x88"
SW_CMD_NOT_ALLOWED = b"\x69\x86"
# Default ATS: length 0C, T0=78, TA1=77, TB1=70, TC1=02, hist "NBT2000"
DEFAULT_ATS = b"\x0C\x78\x77\x70\x02" + b"NBT2000"
class _Password:
def __init__(self, value: bytes, response: bytes = b"\x00\x00",
limit: int = 0x7F):
self.value = value
self.response = response
self.limit = limit
self.tries = limit
self.blocked = False
class OptigaAuthenticateNBT(Tag14443A_4):
"""Infineon OPTIGA Authenticate NBT NFC Type 4 tag."""
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
ecc_key=None, atqa: bytes = b"\x44\x00", sak: int = 0x20):
if uid is None:
uid = b"\x05" + os.urandom(6) # Infineon mfg code 0x05
# ATQA/SAK are NOT published in the NBT2000 extended datasheet or the
# hardware integration guide (verified 2026-07: both omit them). They are
# fixed by the tag being a 7-byte-UID ISO 14443-4 (Type 4A) device:
# ATQA 0x0044 (wire 44 00) = bit-frame anticollision + double-size UID,
# SAK 0x20 = ISO-DEP compliant, UID complete.
# These match every 7-byte-UID Type-4A tag (NTAG21x, DESFire, ST25TA).
super().__init__(uid=uid, atqa=atqa, sak=sak, ats=DEFAULT_ATS)
self._lifecycle = "PERSONALIZATION"
self._ecc_key = ecc_key # ECC P-256 private key (lazy-generated)
self._passwords: dict[int, _Password] = {}
self._files: dict[bytes, bytearray] = {
FID_CC: bytearray(64),
FID_NDEF: bytearray(4096),
FID_PP[0]: bytearray(1024),
FID_PP[1]: bytearray(1024),
FID_PP[2]: bytearray(1024),
FID_PP[3]: bytearray(1024),
FID_FAP: bytearray(42),
}
# File Access Policy: fileid -> [i2c_r, i2c_w, nfc_r, nfc_w]
self._fap: dict[bytes, list[int]] = {
FID_CC: [FAP_ALWAYS, FAP_NEVER, FAP_ALWAYS, FAP_NEVER],
FID_NDEF: [FAP_ALWAYS, FAP_ALWAYS, FAP_ALWAYS, FAP_ALWAYS],
FID_PP[0]: [FAP_ALWAYS] * 4,
FID_PP[1]: [FAP_ALWAYS] * 4,
FID_PP[2]: [FAP_ALWAYS] * 4,
FID_PP[3]: [FAP_ALWAYS] * 4,
FID_FAP: [FAP_ALWAYS] * 4,
}
self._app_selected = False
self._selected_file: bytes | None = None
self._read_ok: set[bytes] = set()
self._write_ok: set[bytes] = set()
self._build_cc(ndef_message)
def _build_cc(self, ndef_message: bytes) -> None:
cc = bytearray(64)
struct.pack_into(">H", cc, 0, 0x002F) # CCLEN
cc[2] = 0x20 # mapping version 2.0
struct.pack_into(">H", cc, 3, 0x0100) # MLe
struct.pack_into(">H", cc, 5, 0x00FF) # MLc
cc[7:15] = b"\x04\x06" + FID_NDEF + struct.pack(">H", 0x1000) + b"\x00\x00"
off = 15
for fid in FID_PP:
cc[off:off + 8] = b"\x05\x06" + fid + struct.pack(">H", 0x0400) + b"\x00\x00"
off += 8
self._files[FID_CC] = cc
self._write_fap_file()
if ndef_message:
self.set_ndef(ndef_message)
def _write_fap_file(self) -> None:
fap = bytearray()
for fid in (FID_CC, FID_NDEF, *FID_PP, FID_FAP):
fap += fid + bytes(self._fap[fid])
self._files[FID_FAP] = fap
# ----- NDEF convenience -----
def set_ndef(self, message: bytes) -> None:
if len(message) > 4096 - 2:
raise ValueError("NDEF message too large (max 4094 bytes)")
f = self._files[FID_NDEF]
struct.pack_into(">H", f, 0, len(message))
f[2:2 + len(message)] = message
for i in range(2 + len(message), len(f)):
f[i] = 0
def finalize(self) -> None:
"""Move to OPERATIONAL life cycle (config locked)."""
self._lifecycle = "OPERATIONAL"
def add_password(self, pwid: int, value: bytes, response: bytes = b"\x00\x00",
limit: int = 0x7F) -> None:
self._passwords[pwid] = _Password(value, response, limit)
def set_fap(self, fileid: bytes, nfc_read: int, nfc_write: int) -> None:
self._fap[fileid][2] = nfc_read
self._fap[fileid][3] = nfc_write
self._write_fap_file()
@property
def lifecycle(self) -> str:
return self._lifecycle
# ----- ISO-DEP reset -----
async def power_on(self) -> None:
await super().power_on()
self._app_selected = False
self._selected_file = None
self._read_ok.clear()
self._write_ok.clear()
def _handle_rats(self, frame: RFFrame) -> RFFrame | None:
self._app_selected = False
self._selected_file = None
self._read_ok.clear()
self._write_ok.clear()
return super()._handle_rats(frame)
# ----- APDU dispatch -----
def _handle_apdu(self, apdu: bytes) -> bytes:
if len(apdu) < 4: # need at least CLA INS P1 P2
return SW_WRONG_LENGTH
cla, ins = apdu[0], apdu[1]
if cla != 0x00:
return b"\x6E\x00" # only the NFC CLA 0x00 set is modelled
if ins == INS_SELECT:
return self._handle_select(apdu)
if ins == INS_READ_BINARY:
return self._handle_read_binary(apdu)
if ins == INS_UPDATE_BINARY:
return self._handle_update_binary(apdu)
if ins == INS_AUTH_TAG:
return self._handle_authenticate(apdu)
if ins == INS_CREATE_PWD:
return self._handle_create_password(apdu)
if ins == INS_DELETE_PWD:
return self._handle_delete_password(apdu)
if ins == INS_CHANGE_UNBLOCK_PWD:
return self._handle_change_unblock(apdu)
if ins == INS_GET_DATA:
return self._handle_get_data(apdu)
if ins == INS_PERSONALIZE:
return self._handle_personalize(apdu)
return b"\x6D\x00"
# ----- SELECT -----
def _handle_select(self, apdu: bytes) -> bytes:
p1, p2 = apdu[2], apdu[3]
lc = apdu[4] if len(apdu) > 4 else 0
data = apdu[5:5 + lc]
if p1 == 0x04: # select by AID
if data == NDEF_AID:
self._app_selected = True
self._selected_file = None
return SW_OK
return SW_FILE_NOT_FOUND
if p1 == 0x00 and p2 == 0x0C: # select by file id
if not self._app_selected or len(data) < 2:
return SW_FILE_NOT_FOUND
fid = bytes(data[0:2])
if fid not in self._files:
return SW_FILE_NOT_FOUND
self._selected_file = fid
fci = self._verify_select_passwords(fid, data[2:])
return fci + SW_OK
return SW_WRONG_P1P2
def _verify_select_passwords(self, fid: bytes, tlvs: bytes) -> bytes:
"""Parse 52h (read) / 54h (write) password TLVs, mark verified files."""
fci = b""
i = 0
while i + 6 <= len(tlvs):
tag, ln = tlvs[i], tlvs[i + 1]
if ln != 0x04:
break
pwd = bytes(tlvs[i + 2:i + 6])
if tag == 0x52:
if self._check_password(self._fap[fid][2], pwd):
self._read_ok.add(fid)
fci += b"\x02\x52"
elif tag == 0x54:
if self._check_password(self._fap[fid][3], pwd):
self._write_ok.add(fid)
fci += b"\x02\x54"
i += 6
if fci:
return b"\x6F" + bytes([len(fci)]) + fci
return b""
def _check_password(self, fap_byte: int, supplied: bytes) -> bool:
if not (fap_byte & FAP_PWD):
return False
pwid = fap_byte & 0x1F
pw = self._passwords.get(pwid)
if pw is None or pw.blocked:
return False
if supplied == pw.value:
pw.tries = pw.limit
return True
if pw.limit != 0xFFFF:
pw.tries -= 1
if pw.tries <= 0:
pw.blocked = True
return False
# ----- access-control helpers -----
def _read_allowed(self, fid: bytes) -> bool:
cfg = self._fap[fid][2]
if cfg == FAP_ALWAYS:
return True
if cfg == FAP_NEVER:
return False
return fid in self._read_ok
def _write_allowed(self, fid: bytes) -> bool:
cfg = self._fap[fid][3]
if cfg == FAP_ALWAYS:
return True
if cfg == FAP_NEVER:
return False
return fid in self._write_ok
# ----- READ / UPDATE BINARY -----
def _handle_read_binary(self, apdu: bytes) -> bytes:
if self._selected_file is None:
return SW_CMD_NOT_ALLOWED
fid = self._selected_file
content = self._files[fid]
offset = (apdu[2] << 8) | apdu[3]
if len(apdu) < 5:
return SW_WRONG_LENGTH
le = apdu[4]
if offset > len(content):
return SW_WRONG_LENGTH
if not self._read_allowed(fid):
return SW_SEC_NOT_SATISFIED
if offset + le > len(content):
avail = len(content) - offset
return bytes([0x6C, avail & 0xFF])
return bytes(content[offset:offset + le]) + SW_OK
def _handle_update_binary(self, apdu: bytes) -> bytes:
if self._selected_file is None:
return SW_CMD_NOT_ALLOWED
fid = self._selected_file
offset = (apdu[2] << 8) | apdu[3]
lc = apdu[4] if len(apdu) > 4 else 0
data = apdu[5:5 + lc]
if fid == FID_CC and 0x02 <= offset <= 0x0E:
return SW_COND_NOT_SATISFIED # CC 02..0E never updatable
if not self._write_allowed(fid):
return SW_SEC_NOT_SATISFIED
if fid == FID_FAP:
return self._update_fap(data)
content = self._files[fid]
if offset + len(data) > len(content):
return SW_WRONG_P1P2
content[offset:offset + len(data)] = data
return SW_OK
def _update_fap(self, data: bytes) -> bytes:
if len(data) != 6:
return SW_WRONG_LENGTH
fid = bytes(data[0:2])
if fid not in self._fap:
return SW_WRONG_DATA
self._fap[fid] = list(data[2:6])
self._write_fap_file()
return SW_OK
# ----- AUTHENTICATE TAG (ECDSA P-256) -----
def _handle_authenticate(self, apdu: bytes) -> bytes:
if apdu[2] != 0x00 or apdu[3] != 0x00:
return SW_WRONG_P1P2
lc = apdu[4] if len(apdu) > 4 else 0
challenge = apdu[5:5 + lc]
if lc == 0:
return SW_WRONG_LENGTH
try:
from Crypto.PublicKey import ECC
from Crypto.Signature import DSS
from Crypto.Hash import SHA256
except ImportError:
return SW_COND_NOT_SATISFIED
if self._ecc_key is None:
self._ecc_key = ECC.generate(curve="P-256")
h = SHA256.new(bytes(challenge))
sig = DSS.new(self._ecc_key, "fips-186-3", encoding="der").sign(h)
return sig + SW_OK
# ----- password management -----
def _handle_create_password(self, apdu: bytes) -> bytes:
if apdu[2] != 0x00 or apdu[3] != 0x00:
return SW_WRONG_P1P2
lc = apdu[4] if len(apdu) > 4 else 0
data = apdu[5:5 + lc]
if lc == 0x09:
body = data
elif lc == 0x0D:
body = data[4:] # skip master password (verify skipped)
else:
return SW_WRONG_LENGTH
if len(body) != 9:
return SW_WRONG_LENGTH
pwid = body[0]
value = bytes(body[1:5])
response = bytes(body[5:7])
limit = (body[7] << 8) | body[8]
if pwid == 0x00 or pwid > 0x1F:
return SW_WRONG_DATA
if response in (b"\x00\x00", b"\xFF\xFF"):
return SW_WRONG_DATA
if limit == 0x0000 or (0x0080 <= limit <= 0xFFFE):
return SW_WRONG_DATA
if pwid in self._passwords:
return SW_COND_NOT_SATISFIED
if len(self._passwords) >= 0x1C:
return SW_COND_NOT_SATISFIED
self._passwords[pwid] = _Password(value, response, limit)
return SW_OK
def _handle_delete_password(self, apdu: bytes) -> bytes:
if apdu[2] != 0x00:
return SW_WRONG_P1P2
pwid = apdu[3] & 0x1F
if pwid not in self._passwords:
return SW_REF_NOT_FOUND
del self._passwords[pwid]
return SW_OK
def _handle_change_unblock(self, apdu: bytes) -> bytes:
if apdu[2] != 0x00:
return SW_WRONG_P1P2
p2 = apdu[3]
op = (p2 >> 6) & 0x03 # b8,b7: 00=UNBLOCK, 01=CHANGE
pwid = p2 & 0x1F
lc = apdu[4] if len(apdu) > 4 else 0
data = apdu[5:5 + lc]
pw = self._passwords.get(pwid)
if pw is None:
return SW_REF_NOT_FOUND
if op == 0x00: # UNBLOCK
if lc not in (0x00, 0x04):
return SW_WRONG_LENGTH
pw.blocked = False
pw.tries = pw.limit
return SW_OK
if op == 0x01: # CHANGE
if lc not in (0x04, 0x08):
return SW_WRONG_LENGTH
if pw.blocked:
return SW_COND_NOT_SATISFIED
new_pwd = data[-4:]
pw.value = bytes(new_pwd)
return SW_OK
return SW_WRONG_P1P2
# ----- GET DATA (applet version) -----
def _handle_get_data(self, apdu: bytes) -> bytes:
if apdu[2] == 0xDF and apdu[3] == 0x3A:
return b"\x6F\x05\x04\x03\x01\x01\x02" + SW_OK # version 1.1.2
return SW_WRONG_P1P2
# ----- PERSONALIZE DATA (PERSONALIZATION only) -----
def _handle_personalize(self, apdu: bytes) -> bytes:
if self._lifecycle != "PERSONALIZATION":
return SW_COND_NOT_SATISFIED
if apdu[2] != 0x00 or apdu[3] != 0x00:
return SW_WRONG_P1P2
lc = apdu[4] if len(apdu) > 4 else 0
data = apdu[5:5 + lc]
if len(data) < 3:
return SW_WRONG_LENGTH
dgi = bytes(data[0:2])
dlen = data[2]
body = data[3:3 + dlen]
if dgi == b"\xBF\x63": # FINALIZE PERSONALIZATION
self._lifecycle = "OPERATIONAL"
return SW_OK
if dgi == b"\xA0\x03": # password data set
if len(body) != 9:
return SW_WRONG_DATA
self._passwords[body[0]] = _Password(
bytes(body[1:5]), bytes(body[5:7]), (body[7] << 8) | body[8])
return SW_OK
if dgi == b"\xE1\xAF": # FAP config
if len(body) != 42:
return SW_WRONG_DATA
for i in range(0, 42, 6):
fid = bytes(body[i:i + 2])
if fid in self._fap:
self._fap[fid] = list(body[i + 2:i + 6])
self._write_fap_file()
return SW_OK
if dgi in self._files: # file content: <offset(2)><content>
if len(body) < 2:
return SW_WRONG_DATA
off = (body[0] << 8) | body[1]
content = self._files[dgi]
payload = body[2:]
if off + len(payload) > len(content):
return SW_WRONG_DATA
content[off:off + len(payload)] = payload
return SW_OK
return b"\x6A\x88" # unsupported data group

View File

@@ -13,6 +13,12 @@ NDEF_TLV_TYPE = 0x03
TERMINATOR_TLV = 0xFE TERMINATOR_TLV = 0xFE
NDEF_MAGIC = 0xE1 NDEF_MAGIC = 0xE1
# Firmware emulator-memory layout (must match firmware include/mifare.h).
# The MFU/NTAG sim (tagtype 2/7) reads pages/version/signature from a 56-byte
# mfu_dump_t prefix and serves page reads from the data area that follows it.
MFU_DUMP_PREFIX_LENGTH = 56
CMD_HF_MIFARE_EML_MEMSET = 0x0602 # pm3_cmd.h; {u16 blockno,u8 cnt,u8 width,data}
class NfcType2Tag(Tag14443A_3): class NfcType2Tag(Tag14443A_3):
"""NFC Forum Type 2 Tag (NTAG-like). """NFC Forum Type 2 Tag (NTAG-like).
@@ -67,9 +73,10 @@ class NfcType2Tag(Tag14443A_3):
f"NDEF message too large: {len(message)} bytes, " f"NDEF message too large: {len(message)} bytes, "
f"capacity is {self._ndef_capacity} bytes") f"capacity is {self._ndef_capacity} bytes")
# Clear data area # Clear only the NDEF data area — never the lock / config / PWD / PACK
self._memory[self._ndef_data_offset:] = bytes( # pages that follow it on NTAG-class parts (clearing to end wipes them).
len(self._memory) - self._ndef_data_offset) end = self._ndef_area_end()
self._memory[self._ndef_data_offset:end] = bytes(end - self._ndef_data_offset)
offset = self._ndef_data_offset offset = self._ndef_data_offset
if len(message) < 0xFF: if len(message) < 0xFF:
@@ -82,9 +89,72 @@ class NfcType2Tag(Tag14443A_3):
self._memory[offset:offset + len(tlv)] = tlv self._memory[offset:offset + len(tlv)] = tlv
def clear_ndef(self) -> None: def clear_ndef(self) -> None:
"""Remove NDEF message.""" """Remove NDEF message (leaves lock/config/PWD/PACK pages intact)."""
self._memory[self._ndef_data_offset:] = bytes( end = self._ndef_area_end()
len(self._memory) - self._ndef_data_offset) self._memory[self._ndef_data_offset:end] = bytes(end - self._ndef_data_offset)
def _ndef_area_end(self) -> int:
"""Byte offset just past the NDEF data area.
Bounded by the CC-declared size (page 3 byte 2 × 8) so the NDEF area never
overlaps the lock/config/PWD/PACK pages at the top of an NTAG's memory.
Falls back to end-of-memory when no CC size is present.
"""
cc_size = self._memory[3 * PAGE_SIZE + 2] * 8
if cc_size:
return min(self._ndef_data_offset + cc_size, len(self._memory))
return len(self._memory)
# ----- emulator-memory sync (14a sim) -----
def _build_mfu_dump(self) -> bytes:
"""Assemble the firmware ``mfu_dump_t`` image: 56-byte prefix + page data.
The 14a MFU/NTAG sim (tagtype 2/7) is data-driven — firmware reads
``pages``, ``version`` and ``signature`` from this prefix and serves page
reads from the data area at offset 56. Loaded into emulator RAM so the
reader sees the model's real memory (UID pages, CC, NDEF, PWD, PACK).
"""
prefix = bytearray(MFU_DUMP_PREFIX_LENGTH)
version = getattr(self, "_version_bytes", None) or b"\x00" * 8
prefix[0:8] = bytes(version)[:8].ljust(8, b"\x00") # version[8]
# tbo[2] @8, tbo1 @10 stay 0
prefix[11] = (self._total_pages - 1) & 0xFF # pages = last page idx
signature = getattr(self, "_signature", None) or b"\x00" * 32
prefix[12:44] = bytes(signature)[:32].ljust(32, b"\x00") # signature[32]
# counter_tearing[3][4] @44: NTAG21x NFC counter lives at index 2 (24-bit LE)
counter = int(getattr(self, "_nfc_counter", 0) or 0) & 0xFFFFFF
prefix[52:55] = counter.to_bytes(3, "little")
return bytes(prefix) + bytes(self._memory)
def sync(self) -> None:
"""Push the full tag memory into the running sim's emulator RAM.
Call after mutating any memory — pages 0/1, CC, NDEF, PWD, PACK — so the
firmware serves the updated bytes. READ/FAST_READ and PWD_AUTH read the
emulator buffer live, so changes take effect on the next reader command.
Requires binding to a live sim (``SimSession.start_14a`` sets ``_serial``);
for a change to land while a sim is *already running*, the firmware must
accept EML updates mid-sim (the live-sync firmware build).
"""
if self._serial is None:
raise RuntimeError(
"Not bound to a live session — call SimSession.start_14a(tag) first")
import time
from pm3py.core.transport import encode_ng_frame
image = self._build_mfu_dump()
if len(image) % PAGE_SIZE:
image += b"\x00" * (PAGE_SIZE - len(image) % PAGE_SIZE)
total_blocks = len(image) // PAGE_SIZE
MAX_BLOCKS = 120 # 120*4 = 480B payload (+4B header) < 512B NG cap
for start in range(0, total_blocks, MAX_BLOCKS):
n = min(MAX_BLOCKS, total_blocks - start)
chunk = image[start * PAGE_SIZE:(start + n) * PAGE_SIZE]
# EML_MEMSET: {u16 blockno, u8 blockcnt, u8 blockwidth, data[]}
payload = struct.pack("<HBB", start, n, PAGE_SIZE) + chunk
self._serial.write(encode_ng_frame(CMD_HF_MIFARE_EML_MEMSET, payload))
time.sleep(0.02)
def _handle_application(self, frame: RFFrame) -> RFFrame | None: def _handle_application(self, frame: RFFrame) -> RFFrame | None:
if not frame.data: if not frame.data:

View File

@@ -1 +1,9 @@
"""NXP ISO 14443-A transponders (MIFARE Classic, DESFire).""" """NXP ISO 14443-A transponders.
- mifare_classic, desfire — MIFARE Classic / DESFire.
- type2 — NxpType2Tag: shared Ultralight/NTAG command core.
- ntag21x — NTAG210/212/213/215/216.
- ultralight — MIFARE Ultralight, Ultralight C (3DES), Ultralight EV1
(MF0UL11/MF0UL21).
- ntag_i2c — NTAG I2C plus (NT3H2111 1k / NT3H2211 2k).
"""

View File

@@ -5,6 +5,7 @@ import struct
from pm3py.sim.frame import RFFrame from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import Medium from pm3py.sim.medium import Medium
from pm3py.transponders.bitfield import BitField, Register
from ..base import Tag14443A_3, Reader14443A, _compute_bcc from ..base import Tag14443A_3, Reader14443A, _compute_bcc
from .crypto1 import Crypto1 from .crypto1 import Crypto1
@@ -18,6 +19,118 @@ NACK = 0x00
BLOCK_SIZE = 16 BLOCK_SIZE = 16
# Per-block access-condition (C1 C2 C3) meanings. Data-block table matches Proxmark3
# mifare4.c; the trailer table is written to the NXP MF1S50 datasheet (r=read, w=write,
# A/B = which key, AB = either key, - = never).
_MFAC_DATA = {
0: "r=AB w=AB incr=AB dec/tr/rst=AB (transport)",
1: "r=AB dec/tr/rst=AB (value block)",
2: "r=AB (read-only)",
3: "r=B w=B",
4: "r=AB w=B",
5: "r=B (read-only, key B)",
6: "r=AB w=B incr=B dec/tr/rst=AB (value block)",
7: "no access",
}
_MFAC_TRAILER = {
0: "KeyA w=A; AC r=A; KeyB r=A w=A (KeyB is readable data)",
1: "KeyA w=A; AC r=A w=A; KeyB r=A w=A (transport/default)",
2: "KeyA w=-; AC r=A; KeyB r=A w=-",
3: "KeyA w=B; AC r=AB w=B; KeyB w=B",
4: "KeyA w=B; AC r=AB; KeyB w=B",
5: "AC r=AB w=B (keys locked)",
6: "KeyA w=-; AC r=AB (keys locked)",
7: "AC r=AB (all keys locked)",
}
class MifareAccessConditions(Register):
"""MIFARE Classic sector-trailer access bits — trailer bytes 6-9 (the three access bytes
plus the general-purpose byte), decoded as a 32-bit word.
Each of the sector's four blocks (three data blocks + the trailer) has a 3-bit access
condition ``C1 C2 C3``. The three access bytes store every condition *twice* — once true,
once inverted — so one flipped bit permanently bricks the sector. This spells out each
block in plain English, flags an inconsistent inverted copy (``.valid``), and, when you
:meth:`build` one, computes the inverted copy for you so it is always self-consistent::
MifareAccessConditions(0xFF078069) # decode the factory-default trailer
ac = MifareAccessConditions.build(trailer=3) # KeyB-controlled trailer
bytes(ac) # -> the 4 trailer bytes 6..9 to write
"""
_width_bits = 32
_label = "MIFARE access bits (trailer 6-9)"
gpb = BitField(7, 0, doc="general-purpose byte — free user data (e.g. the MAD GPB)")
_BLOCKS = ("block0", "block1", "block2", "trailer")
def _cond(self, blockn: int) -> int:
"""The 3-bit access condition (C1<<2 | C2<<1 | C3) for one block of the sector."""
v = self.value
c1 = (v >> (20 + blockn)) & 1 # C1 = byte7 high nibble
c2 = (v >> (8 + blockn)) & 1 # C2 = byte8 low nibble
c3 = (v >> (12 + blockn)) & 1 # C3 = byte8 high nibble
return (c1 << 2) | (c2 << 1) | c3
@property
def block0(self) -> int:
return self._cond(0)
@property
def block1(self) -> int:
return self._cond(1)
@property
def block2(self) -> int:
return self._cond(2)
@property
def trailer(self) -> int:
return self._cond(3)
def describe(self, blockn: int) -> str:
table = _MFAC_TRAILER if blockn == 3 else _MFAC_DATA
return table.get(self._cond(blockn), "?")
@property
def valid(self) -> bool:
"""True iff the redundant inverted copy is the nibble-complement of the true copy."""
v = self.value
return (((v >> 24) & 0x0F) == (((v >> 20) & 0x0F) ^ 0x0F) and # ~C1 vs C1
((v >> 28) & 0x0F) == (((v >> 8) & 0x0F) ^ 0x0F) and # ~C2 vs C2
((v >> 16) & 0x0F) == (((v >> 12) & 0x0F) ^ 0x0F)) # ~C3 vs C3
@classmethod
def build(cls, block0: int = 0, block1: int = 0, block2: int = 0,
trailer: int = 1, gpb: int = 0x00) -> "MifareAccessConditions":
"""Compose access bytes from the four per-block conditions (0..7), filling in the
inverted copy so the result always passes ``.valid``. ``trailer`` defaults to 1
(the factory/transport trailer). ``gpb`` is the general-purpose byte."""
c1 = c2 = c3 = 0
for n, cond in enumerate((block0, block1, block2, trailer)):
if not 0 <= cond <= 7:
raise ValueError(f"block {n} access condition must be 0..7, got {cond}")
c1 |= ((cond >> 2) & 1) << n
c2 |= ((cond >> 1) & 1) << n
c3 |= (cond & 1) << n
byte6 = ((c2 ^ 0x0F) << 4) | (c1 ^ 0x0F)
byte7 = (c1 << 4) | (c3 ^ 0x0F)
byte8 = (c3 << 4) | c2
return cls((byte6 << 24) | (byte7 << 16) | (byte8 << 8) | (gpb & 0xFF))
def _rows(self) -> list[dict]:
rows = [
{"name": name, "value": f"{self._cond(n)} {self.describe(n)}", "bits": "", "doc": ""}
for n, name in enumerate(self._BLOCKS)
]
rows.append({"name": "gpb", "value": f"0x{self.gpb:02X}", "bits": "[7:0]", "doc": ""})
if not self.valid:
rows.append({"name": "(!)", "value": "inverted copy inconsistent — INVALID",
"bits": "", "doc": ""})
return rows
class MifareClassicTag(Tag14443A_3): class MifareClassicTag(Tag14443A_3):
"""MIFARE Classic 1K/4K transponder with Crypto-1 authentication.""" """MIFARE Classic 1K/4K transponder with Crypto-1 authentication."""
@@ -63,6 +176,12 @@ class MifareClassicTag(Tag14443A_3):
else: else:
return 128 + (sector - 32) * 16 + 15 return 128 + (sector - 32) * 16 + 15
def access(self, sector: int) -> MifareAccessConditions:
"""Decode the sector's trailer access bits (trailer bytes 6-9) as a self-describing
:class:`MifareAccessConditions` register."""
trailer = self.read_block_raw(self._sector_trailer_block(sector))
return MifareAccessConditions(int.from_bytes(trailer[6:10], "big"))
def read_block_raw(self, block: int) -> bytes: def read_block_raw(self, block: int) -> bytes:
"""Direct read of block data (bypassing auth).""" """Direct read of block data (bypassing auth)."""
offset = block * BLOCK_SIZE offset = block * BLOCK_SIZE

View File

@@ -0,0 +1,188 @@
"""NTAG21x — NXP NFC Forum Type 2 tags (NTAG210/212/213/215/216).
Sourced from the NXP datasheets:
- NTAG210/212 (Rev. 3.0, doc 265331)
- NTAG213/215/216 (Rev. 3.2, doc 265332)
Feature summary:
- GET_VERSION, FAST_READ, PWD_AUTH (+PACK), READ_SIG on all five.
- Single 24-bit NFC one-way counter (READ_CNT 0x39, addr 0x02) on 213/215/216
only — enabled by ACCESS.NFC_CNT_EN, optionally password protected by
ACCESS.NFC_CNT_PWD_PROT. NTAG210/212 have no counter.
- Password protection from AUTH0 onwards (write, or read+write if ACCESS.PROT).
"""
from __future__ import annotations
from pm3py.sim.frame import RFFrame
from pm3py.transponders.bitfield import BitField
from .type2 import NxpType2Tag, Type2Config, READ_CNT, FAST_READ, READ_CMD, _nak, NAK_ARG
# ACCESS byte bits (CFG1 byte 0)
ACCESS_PROT = 0x80
ACCESS_CFGLCK = 0x40
ACCESS_NFC_CNT_EN = 0x10
ACCESS_NFC_CNT_PWD_PROT = 0x08
class Ntag21xConfig(Type2Config):
"""NTAG21x CFG0/CFG1 config word (see :class:`Type2Config`), adding the NTAG-only ASCII
mirror and NFC-counter fields. Decode ``tag.config`` or build one and assign it back."""
_label = "NTAG21x config (CFG0/CFG1)"
mirror_conf = BitField(63, 62, doc="ASCII mirror mode",
values={0: "off", 1: "UID", 2: "NFC counter", 3: "UID+counter"})
mirror_byte = BitField(61, 60, doc="byte offset within mirror_page where the mirror starts")
mirror_page = BitField(47, 40, doc="page where ASCII mirroring begins (0 = disabled)")
nfc_cnt_en = BitField(28, doc="enable the 24-bit NFC read counter (213/215/216)")
nfc_cnt_pwd_prot = BitField(27, doc="NFC counter readable only after PWD_AUTH")
class Ntag21x(NxpType2Tag):
"""Base for the NTAG21x family. Concrete ICs set the class attributes."""
_has_nfc_counter: bool = False # True for 213/215/216
_cc_default: bytes = b"\xE1\x10\x00\x00"
_config_cls = Ntag21xConfig
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
password: bytes = b"\xFF\xFF\xFF\xFF",
pack: bytes = b"\x00\x00", auth0: int = 0xFF,
prot: bool = False, signature: bytes | None = None,
nfc_cnt_en: bool = False):
if uid is None:
import os
uid = b"\x04" + os.urandom(6)
self._nfc_counter = 0
self._counter_triggered = False
super().__init__(uid=uid, password=password, pack=pack, auth0=auth0,
prot=prot, signature=signature, cc=self._cc_default,
ndef_message=ndef_message,
total_pages=self._total_pages_cls,
atqa=b"\x44\x00", sak=0x00)
if nfc_cnt_en and self._cfg1_page is not None:
off = self._cfg1_page * 4
self._memory[off] |= ACCESS_NFC_CNT_EN
# ----- config bits -----
@property
def _access(self) -> int:
if self._cfg1_page is None:
return 0
return self._memory[self._cfg1_page * 4]
@property
def nfc_counter_enabled(self) -> bool:
return self._has_nfc_counter and bool(self._access & ACCESS_NFC_CNT_EN)
@property
def nfc_counter(self) -> int:
return self._nfc_counter
# ----- power cycle resets the "first read" trigger -----
async def power_on(self) -> None:
await super().power_on()
self._counter_triggered = False
# ----- NFC counter auto-increment on first READ/FAST_READ -----
def _maybe_bump_counter(self) -> None:
if self.nfc_counter_enabled and not self._counter_triggered:
self._counter_triggered = True
if self._nfc_counter < 0xFFFFFF:
self._nfc_counter += 1
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
resp = super()._handle_read(frame)
# A data frame is >1 byte; a single-byte reply is an ACK/NAK.
if resp is not None and len(resp.data) > 1:
self._maybe_bump_counter()
return resp
def _handle_fast_read(self, frame: RFFrame) -> RFFrame | None:
resp = super()._handle_fast_read(frame)
if resp is not None and len(resp.data) > 1:
self._maybe_bump_counter()
return resp
# ----- READ_CNT (0x39) -----
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
if (frame.data and frame.data[0] == READ_CNT and self._has_nfc_counter):
return self._handle_read_cnt(frame)
return super()._handle_application(frame)
def _handle_read_cnt(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 2 or frame.data[1] != 0x02:
return _nak(NAK_ARG)
if not self.nfc_counter_enabled:
return _nak(NAK_ARG)
if (self._access & ACCESS_NFC_CNT_PWD_PROT) and not self._authenticated_pwd:
return _nak(NAK_ARG)
return RFFrame.from_bytes(self._nfc_counter.to_bytes(3, "little"))
# --------------------------------------------------------------------------
# Concrete ICs
# --------------------------------------------------------------------------
class NTAG213(Ntag21x):
"""NTAG213 — 180 bytes total, 144 bytes user (pages 04h27h)."""
_total_pages_cls = 45
_version_bytes = b"\x00\x04\x04\x02\x01\x00\x0F\x03"
_cc_default = b"\xE1\x10\x12\x00"
_has_nfc_counter = True
_cfg0_page = 0x29
_cfg1_page = 0x2A
_pwd_page = 0x2B
_pack_page = 0x2C
class NTAG215(Ntag21x):
"""NTAG215 — 540 bytes total, 504 bytes user (pages 04h81h)."""
_total_pages_cls = 135
_version_bytes = b"\x00\x04\x04\x02\x01\x00\x11\x03"
_cc_default = b"\xE1\x10\x3E\x00"
_has_nfc_counter = True
_cfg0_page = 0x83
_cfg1_page = 0x84
_pwd_page = 0x85
_pack_page = 0x86
class NTAG216(Ntag21x):
"""NTAG216 — 924 bytes total, 888 bytes user (pages 04hE1h)."""
_total_pages_cls = 231
_version_bytes = b"\x00\x04\x04\x02\x01\x00\x13\x03"
_cc_default = b"\xE1\x10\x6D\x00"
_has_nfc_counter = True
_cfg0_page = 0xE3
_cfg1_page = 0xE4
_pwd_page = 0xE5
_pack_page = 0xE6
class NTAG210(Ntag21x):
"""NTAG210 — 48 bytes user (pages 04h0Fh). No NFC counter, no dyn lock."""
_total_pages_cls = 20
_version_bytes = b"\x00\x04\x04\x01\x01\x00\x0B\x03"
_cc_default = b"\xE1\x10\x06\x00"
_has_nfc_counter = False
_cfg0_page = 0x10
_cfg1_page = 0x11
_pwd_page = 0x12
_pack_page = 0x13
class NTAG212(Ntag21x):
"""NTAG212 — 128 bytes user (pages 04h23h). No NFC counter."""
_total_pages_cls = 41
_version_bytes = b"\x00\x04\x04\x01\x01\x00\x0E\x03"
_cc_default = b"\xE1\x10\x10\x00"
_has_nfc_counter = False
_cfg0_page = 0x25
_cfg1_page = 0x26
_pwd_page = 0x27
_pack_page = 0x28

View File

@@ -0,0 +1,359 @@
"""NTAG I2C plus — NXP NT3H2111 (1k) / NT3H2211 (2k).
Sourced from the NXP datasheet (Rev. 3.5, doc 359935), RF/NFC interface only.
Distinctive features vs NTAG21x:
- Memory addressed in sectors reached via SECTOR_SELECT (0xC2). Sector 0 holds
UID/CC/user/config; Sector 1 (2k only) is 1 KB extra user memory; Sector 3
mirrors the session registers.
- READ does not roll over — a window running into an invalid region reads 00h;
an out-of-range start page returns NAK.
- FAST_WRITE (0xA6) writes the 64-byte SRAM in one shot (pass-through mode).
- Has GET_VERSION, FAST_READ, PWD_AUTH (+PACK), READ_SIG. No NFC counter.
"""
from __future__ import annotations
import os
from pm3py.sim.frame import RFFrame
from pm3py.transponders.bitfield import BitField, Register
from .type2 import (
NxpType2Tag, _ack, _nak, NAK_ARG, PWD_AUTH,
SECTOR_SELECT, READ_CMD, WRITE_CMD, GET_VERSION, FAST_READ, READ_SIG,
)
FAST_WRITE = 0xA6
# ACCESS byte (page E4h byte 0) bits
NFC_PROT = 0x80
NFC_DIS_SEC1 = 0x20
# NC_REG field-detect event maps (datasheet Table 13)
_FD_OFF = {0: "field off", 1: "field off / HALT",
2: "field off / last NDEF read", 3: "field off / last data (pass-through)"}
_FD_ON = {0: "field on", 1: "first SoC", 2: "tag selected", 3: "data ready (pass-through)"}
_I2C_PROT = {0: "full I2C access", 1: "protected area read-only",
2: "protected area no access", 3: "protected area no access"}
class _NtagI2CRegBlock(Register):
"""Shared decode of an NTAG I2C plus register block (config pages E8h/E9h or the session
copy at ECh/EDh) as one 64-bit word. Byte 6 differs (REG_LOCK vs NS_REG), so the concrete
:class:`NtagI2CConfig` / :class:`NtagI2CSession` add it."""
_width_bits = 64
# --- NC_REG (byte 0) ---
nfcs_i2c_rst = BitField(63, doc="enable NFC-silence + I2C soft-reset via repeated start")
pthru_on = BitField(62, doc="pass-through mode: SRAM mapped to end of Sector 0")
fd_off = BitField(61, 60, values=_FD_OFF, doc="event that releases the field-detect pin")
fd_on = BitField(59, 58, values=_FD_ON, doc="event that pulls the field-detect pin low")
sram_mirror_on = BitField(57, doc="mirror the 64-byte SRAM into user memory")
transfer_dir = BitField(56, values={0: "I2C->NFC", 1: "NFC->I2C"},
doc="pass-through data-flow direction (must be 1 when not in pass-through)")
# --- byte 1..5 ---
last_ndef_block = BitField(55, 48, doc="I2C block of the last NDEF byte (drives NDEF_DATA_READ)")
sram_mirror_block = BitField(47, 40, doc="I2C block where the SRAM mirror starts (default F8h)")
wdt_ls = BitField(39, 32, doc="watchdog timer, least-significant byte")
wdt_ms = BitField(31, 24, doc="watchdog timer, most-significant byte")
i2c_clock_str = BitField(16, doc="enable I2C clock stretching")
@property
def watchdog(self) -> int:
"""16-bit watchdog time (WDT_MS:WDT_LS)."""
return (self.wdt_ms << 8) | self.wdt_ls
class NtagI2CConfig(_NtagI2CRegBlock):
"""NTAG I2C plus configuration registers (pages E8h/E9h) — the power-on default behaviour.
Changes take effect after the next POR; use :class:`NtagI2CSession` to change the live
session. ``REG_LOCK`` here permanently freezes these registers per interface."""
_label = "NTAG I2C config registers (E8/E9)"
reg_lock_i2c = BitField(9, doc="permanently lock these registers from I2C writes (irreversible)")
reg_lock_nfc = BitField(8, doc="permanently lock these registers from NFC writes (irreversible)")
class NtagI2CSession(_NtagI2CRegBlock):
"""NTAG I2C plus session registers (pages ECh/EDh) — the live copy for the current RF
session (NFC-read-only; POR reloads them from the config registers). Byte 6 is the NS_REG
status; ``I2C_CLOCK_STR`` byte also carries the ``neg_auth_reached`` status bit."""
_label = "NTAG I2C session registers (EC/ED)"
neg_auth_reached = BitField(17, doc="status: negative-PWD_AUTH limit reached (auth locked)")
# NS_REG status (byte 6)
ndef_data_read = BitField(15, doc="status: all bytes up to LAST_NDEF_BLOCK have been read")
i2c_locked = BitField(14, doc="status: memory arbitration locked to the I2C interface")
rf_locked = BitField(13, doc="status: memory arbitration locked to the NFC interface")
sram_i2c_ready = BitField(12, doc="status: SRAM data ready for the I2C interface to read")
sram_rf_ready = BitField(11, doc="status: SRAM data ready for the NFC interface to read")
eeprom_wr_err = BitField(10, doc="status: EEPROM write/erase HV error (write 0 via I2C to clear)")
eeprom_wr_busy = BitField(9, doc="status: EEPROM write in progress")
rf_field_present = BitField(8, doc="status: an RF field is present")
class NtagI2CPtI2C(Register):
"""NTAG I2C plus I2C-protection register (PT_I2C, page E7h byte 0) — restricts access to
the password-protected area from the I2C side."""
_width_bits = 8
_label = "NTAG I2C PT_I2C (E7)"
prot_2k = BitField(3, doc="require password auth to reach Sector 1 from I2C (2k)")
sram_prot = BitField(2, doc="require password auth for pass-through / SRAM mirror")
i2c_prot = BitField(1, 0, values=_I2C_PROT, doc="I2C access level to the protected area")
class NtagI2C(NxpType2Tag):
"""NTAG I2C plus base. Concrete ICs: NT3H2111 (1k), NT3H2211 (2k)."""
_is_2k: bool = False
_has_cc = True
# Config / session pages within Sector 0.
_cfg0_page = 0xE3 # RFU/RFU/RFU/AUTH0
_cfg1_page = 0xE4 # ACCESS/RFU/RFU/RFU
_pwd_page = 0xE5
_pack_page = 0xE6
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
password: bytes = b"\xFF\xFF\xFF\xFF", pack: bytes = b"\x00\x00",
auth0: int = 0xFF, prot: bool = False,
signature: bytes | None = None):
if uid is None:
uid = b"\x04" + os.urandom(6)
self._current_sector = 0
self._sector1 = bytearray(256 * 4) if self._is_2k else None
self._sram = bytearray(64)
self._passthru = False
super().__init__(uid=uid, password=password, pack=pack, auth0=auth0,
prot=prot, signature=signature, cc=self._cc_default,
ndef_message=ndef_message, total_pages=256,
atqa=b"\x44\x00", sak=0x00)
self._init_registers()
def _init_registers(self) -> None:
"""Lay down configuration + session register defaults (Sector 0)."""
# Configuration registers (pages E8h/E9h) and session (ECh/EDh).
self._set_page(0xE8, bytes([0x01, 0x00, 0xF8, 0x48])) # NC_REG/LAST_NDEF/SRAM_MIRROR/WDT_LS
self._set_page(0xE9, bytes([0x08, 0x01, 0x00, 0x00])) # WDT_MS/I2C_CLK_STR/REG_LOCK/RFU
self._set_page(0xEC, bytes([0x01, 0x00, 0xF8, 0x48])) # session copy
self._set_page(0xED, bytes([0x08, 0x01, 0x00, 0x00])) # WDT_MS/I2C_CLK_STR/NS_REG/RFU
self._set_page(0xE7, bytes([0x00, 0x00, 0x00, 0x00])) # PT_I2C
# ----- config / session / protection registers -----
@property
def config(self) -> NtagI2CConfig:
"""The configuration registers (pages E8h/E9h) — power-on defaults. Assign back to
program them (``tag.config = cfg``); effect applies after the next POR."""
raw = self._get_page(0xE8) + self._get_page(0xE9)
return NtagI2CConfig(int.from_bytes(raw, "big"))
@config.setter
def config(self, value: "NtagI2CConfig | int") -> None:
raw = int(value).to_bytes(8, "big")
self._set_page(0xE8, raw[0:4])
self._set_page(0xE9, raw[4:8])
@property
def session(self) -> NtagI2CSession:
"""The session registers (pages ECh/EDh) — the live copy for this RF session."""
raw = self._get_page(0xEC) + self._get_page(0xED)
return NtagI2CSession(int.from_bytes(raw, "big"))
@session.setter
def session(self, value: "NtagI2CSession | int") -> None:
raw = int(value).to_bytes(8, "big")
self._set_page(0xEC, raw[0:4])
self._set_page(0xED, raw[4:8])
@property
def pt_i2c(self) -> NtagI2CPtI2C:
"""The I2C-protection register (PT_I2C, page E7h byte 0)."""
return NtagI2CPtI2C(self._get_page(0xE7)[0])
@pt_i2c.setter
def pt_i2c(self, value: "NtagI2CPtI2C | int") -> None:
self._set_page(0xE7, bytes([int(value) & 0xFF, 0, 0, 0]))
# ----- sector / page validity -----
@property
def _access(self) -> int:
return self._memory[self._cfg1_page * 4]
def _page_readable(self, sector: int, page: int) -> bool:
if page > 0xFF:
return False
if sector == 0:
if page <= 0xE9 or page in (0xEC, 0xED):
return True
if self._passthru and 0xF0 <= page <= 0xFF:
return True
return False
if sector == 1:
if not self._is_2k or (self._access & NFC_DIS_SEC1):
return False
return True
if sector == 3:
return page in (0xF8, 0xF9)
return False
def _page_writable(self, sector: int, page: int) -> bool:
if sector == 0:
return 2 <= page <= 0xE9
if sector == 1:
return self._is_2k and not (self._access & NFC_DIS_SEC1)
return False
def _page_bytes(self, sector: int, page: int) -> bytes:
if sector == 0:
return bytes(self._memory[page * 4:page * 4 + 4])
if sector == 1 and self._sector1 is not None:
return bytes(self._sector1[page * 4:page * 4 + 4])
if sector == 3 and page in (0xF8, 0xF9):
base = 0xEC + (page - 0xF8)
return bytes(self._memory[base * 4:base * 4 + 4])
if self._passthru and 0xF0 <= page <= 0xFF and sector == 0:
off = (page - 0xF0) * 4
return bytes(self._sram[off:off + 4])
return b"\x00\x00\x00\x00"
def _masked_page(self, sector: int, page: int) -> bytes:
b = self._page_bytes(sector, page)
if sector == 0 and page in (self._pwd_page, self._pack_page):
return b"\x00\x00\x00\x00"
return b
# ----- command dispatch -----
async def power_on(self) -> None:
await super().power_on()
self._current_sector = 0
self._passthru = False
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
if not frame.data:
return None
if self._pending == "sector_select":
return self._handle_sector_select_p2(frame)
cmd = frame.data[0]
if cmd == SECTOR_SELECT:
return self._handle_sector_select_p1(frame)
if cmd == FAST_WRITE:
return self._handle_fast_write(frame)
return super()._handle_application(frame)
# ----- READ / FAST_READ / WRITE with sector routing, no rollover -----
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 2:
return _nak(NAK_ARG)
page = frame.data[1]
sec = self._current_sector
if not self._page_readable(sec, page):
return _nak(NAK_ARG)
if self._read_protected(page) and sec == 0:
return _nak(NAK_ARG)
data = bytearray()
for i in range(4):
p = page + i
if self._page_readable(sec, p):
data += self._masked_page(sec, p)
else:
data += b"\x00\x00\x00\x00"
return RFFrame.from_bytes(bytes(data))
def _handle_fast_read(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 3:
return _nak(NAK_ARG)
start, end = frame.data[1], frame.data[2]
sec = self._current_sector
if end < start or not self._page_readable(sec, start):
return _nak(NAK_ARG)
if self._read_protected(start) and sec == 0:
return _nak(NAK_ARG)
data = bytearray()
for p in range(start, end + 1):
if self._page_readable(sec, p):
data += self._masked_page(sec, p)
else:
data += b"\x00\x00\x00\x00"
return RFFrame.from_bytes(bytes(data))
def _handle_write(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 6:
return _nak(NAK_ARG)
page = frame.data[1]
sec = self._current_sector
if not self._page_writable(sec, page):
return _nak(NAK_ARG)
if sec == 0 and self._write_protected(page):
return _nak(NAK_ARG)
payload = bytes(frame.data[2:6])
if sec == 0:
self._apply_page_write(page, payload)
elif sec == 1 and self._sector1 is not None:
self._sector1[page * 4:page * 4 + 4] = payload
return _ack()
# ----- SECTOR_SELECT (0xC2), two packets -----
def _handle_sector_select_p1(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 2 or frame.data[1] != 0xFF:
return _nak(NAK_ARG)
self._pending = "sector_select"
return _ack()
def _handle_sector_select_p2(self, frame: RFFrame) -> RFFrame | None:
self._pending = None
sec = frame.data[0]
valid = sec == 0 or sec == 3 or (sec == 1 and self._is_2k)
if not valid:
return _nak(NAK_ARG)
self._current_sector = sec
# Success is signalled by passive ACK (>1 ms of silence) — no reply.
return None
# ----- FAST_WRITE (0xA6): 64-byte SRAM (pass-through) -----
def _handle_fast_write(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 3 or frame.data[1] != 0xF0 or frame.data[2] != 0xFF:
return _nak(NAK_ARG)
data = bytes(frame.data[3:3 + 64])
self._sram[:len(data)] = data
return _ack()
def enumerate_responses(self):
"""Table entries for the readable Sector 0 pages + GET_VERSION/READ_SIG."""
FLAG_CRC = 0x01
if self._version_bytes is not None:
yield (bytes([GET_VERSION]), self._version_bytes, FLAG_CRC)
for page in range(0xEA): # Sector 0 readable user/config span
if self._read_protected(page):
continue
data = bytearray()
for i in range(4):
data += self._masked_page(0, page + i)
yield (bytes([READ_CMD, page]), bytes(data), FLAG_CRC)
if self._has_read_sig:
yield (bytes([READ_SIG, 0x00]), self._signature, FLAG_CRC)
class NT3H2111(NtagI2C):
"""NTAG I2C plus 1k (NT3H2111) — 888 bytes user (Sector 0, pages 04h-E1h)."""
_is_2k = False
_version_bytes = b"\x00\x04\x04\x05\x02\x02\x13\x03"
_cc_default = b"\xE1\x10\x6D\x00" # NFC-Forum recommended CC (datasheet Fig. 11)
class NT3H2211(NtagI2C):
"""NTAG I2C plus 2k (NT3H2211) — 888 + 1024 bytes user (Sector 0 + Sector 1)."""
_is_2k = True
_version_bytes = b"\x00\x04\x04\x05\x02\x02\x15\x03"
# The datasheet does not tabulate a 2k CC; E1 10 EA 00 is the value NXP
# ships on NT3H2211 (T2T area sized to 1872 bytes). Adjust with set CC if needed.
_cc_default = b"\xE1\x10\xEA\x00"

View File

@@ -0,0 +1,435 @@
"""NXP byte-oriented Type 2 tag platform (MIFARE Ultralight / NTAG).
Shared command handling for the NXP Type 2 family: GET_VERSION, FAST_READ,
COMPATIBILITY_WRITE, PWD_AUTH, READ_SIG plus AUTH0-based password protection
and the PWD/PACK config-page read-back masking. Concrete ICs (NTAG21x,
Ultralight EV1, NTAG I2C) subclass this and add their product-specific bits
(NFC counter, incrementable counters, sector select).
This mirrors the ISO 15693 side, where ``NxpIcodeTag`` centralises the NXP
custom command set and per-IC classes extend it.
"""
from __future__ import annotations
from pm3py.sim.frame import RFFrame
from pm3py.trace.decode_iso14a import decode_14443a_type2
from pm3py.transponders.bitfield import BitField, Register
from ..ndef import NfcType2Tag, PAGE_SIZE, READ_CMD, WRITE_CMD
# ---- Command codes (NFC Forum Type 2 / Ultralight / NTAG) ----
GET_VERSION = 0x60
FAST_READ = 0x3A
COMPAT_WRITE = 0xA0
PWD_AUTH = 0x1B
READ_CNT = 0x39
INCR_CNT = 0xA5
CHECK_TEARING = 0x3E
READ_SIG = 0x3C
SECTOR_SELECT = 0xC2
# ---- 4-bit ACK / NAK codes (Table 23, NTAG21x datasheet) ----
ACK = 0x0A
NAK_ARG = 0x00 # invalid argument (invalid page address)
NAK_CRC = 0x01 # parity or CRC error
NAK_AUTH = 0x04 # invalid authentication counter overflow
NAK_WRITE = 0x05 # EEPROM write error
def _ack() -> RFFrame:
return RFFrame.from_bytes(bytes([ACK]))
def _nak(code: int = NAK_ARG) -> RFFrame:
return RFFrame.from_bytes(bytes([code]))
class Type2Config(Register):
"""The two NXP Type 2 configuration pages (CFG0 + CFG1) decoded as one 64-bit word:
the 4 bytes of CFG0 (MSB) followed by the 4 bytes of CFG1.
Common to Ultralight EV1 and NTAG21x. Only the fields every part shares live here —
NTAG adds its mirror / NFC-counter fields in :class:`Ntag21xConfig`. Bits are numbered on
the concatenated word, so CFG0 byte 0 is bits 63:56 and CFG1 byte 0 (ACCESS) is bits 31:24.
"""
_width_bits = 64
_label = "Type 2 config (CFG0/CFG1)"
strg_mod_en = BitField(58, doc="strong load modulation enable (CFG0 MIRROR bit 2)")
auth0 = BitField(39, 32, doc="first page needing PWD_AUTH; >= last page ⇒ protection off")
prot = BitField(31, doc="0 = write protect from AUTH0; 1 = read+write protect")
cfglck = BitField(30, doc="lock CFG0/CFG1 permanently — irreversible")
authlim = BitField(26, 24, doc="failed-PWD_AUTH attempts before lockout (0 = unlimited)")
class NxpType2Tag(NfcType2Tag):
"""NXP Type 2 platform: Ultralight EV1 / NTAG21x / NTAG I2C command core.
Subclasses set the capability/layout class attributes below. Everything
that is common — version, fast read, compatibility write, password auth,
originality signature, AUTH0 access control — lives here.
"""
# Capabilities (subclasses override)
_version_bytes: bytes | None = None # 8-byte GET_VERSION response, None = unsupported
_has_fast_read: bool = True
_has_read_sig: bool = True
_has_pwd_auth: bool = True
_has_cc: bool = True # False for Ultralight-style parts (page 3 = OTP)
_ndef_cc: bytes | None = None # CC stamped into OTP when NDEF-formatting a UL part
# Config-page indices (subclasses set to real page numbers, None = none)
_cfg0_page: int | None = None # MIRROR / RFUI / MIRROR_PAGE / AUTH0
_cfg1_page: int | None = None # ACCESS / RFUI / RFUI / RFUI
_pwd_page: int | None = None # 4-byte password (reads back as 00)
_pack_page: int | None = None # 2-byte PACK + RFUI (reads back as 00)
#: register class decoding the CFG0/CFG1 pages (subclasses with config pages set this)
_config_cls: type[Register] | None = None
@property
def config(self) -> Register:
"""CFG0/CFG1 decoded as a self-describing register (see the part's ``_config_cls``).
Assign one back (``tag.config = cfg``) to program both config pages."""
if self._config_cls is None or self._cfg0_page is None or self._cfg1_page is None:
raise AttributeError(f"{type(self).__name__} has no CFG0/CFG1 config register")
raw = self._get_page(self._cfg0_page) + self._get_page(self._cfg1_page)
return self._config_cls(int.from_bytes(raw, "big"))
@config.setter
def config(self, value: "Register | int") -> None:
if self._cfg0_page is None or self._cfg1_page is None:
raise AttributeError(f"{type(self).__name__} has no CFG0/CFG1 config register")
raw = int(value).to_bytes(8, "big")
self._set_page(self._cfg0_page, raw[0:4])
self._set_page(self._cfg1_page, raw[4:8])
def __init__(self, uid: bytes, *,
password: bytes = b"\xFF\xFF\xFF\xFF",
pack: bytes = b"\x00\x00",
auth0: int = 0xFF,
prot: bool = False,
signature: bytes | None = None,
cc: bytes | None = None,
ndef_message: bytes = b"",
total_pages: int = 45,
atqa: bytes = b"\x44\x00", sak: int = 0x00):
# Stash product config so the overridden _init_memory() can use it.
self._cc = cc
self._init_password = password
self._init_pack = pack
self._init_auth0 = auth0
self._init_prot = prot
self._signature = (signature or bytes(32))
self._authenticated_pwd = False
self._pending: str | None = None # two-phase command state
self._pending_page: int = 0
super().__init__(uid=uid, ndef_message=ndef_message,
total_pages=total_pages, atqa=atqa, sak=sak)
# ----- memory / config initialisation -----
def _init_memory(self, ndef_message: bytes) -> None:
"""Lay out UID/BCC, CC, config pages, then optional NDEF."""
self._ndef_data_offset = 4 * PAGE_SIZE
self._ndef_capacity = (self._total_pages - 4) * PAGE_SIZE - 3
# Mirror the 7-byte UID + BCC into pages 0-2 so READ returns it
# (page0 = SN0..2+BCC0, page1 = SN3..6, page2 byte0 = BCC1).
uid = self._uid
if len(uid) == 7:
bcc0 = 0x88 ^ uid[0] ^ uid[1] ^ uid[2]
bcc1 = uid[3] ^ uid[4] ^ uid[5] ^ uid[6]
self._memory[0:4] = bytes(uid[0:3]) + bytes([bcc0])
self._memory[4:8] = bytes(uid[3:7])
self._memory[8] = bcc1
# Page 3 — Capability Container (NTAG) or OTP (Ultralight). Only lay
# down a CC for parts that carry one; Ultralight-family parts leave
# page 3 as the (zeroed) OTP area.
if self._has_cc:
if self._cc is not None:
self._memory[3 * PAGE_SIZE:4 * PAGE_SIZE] = self._cc
else:
data_size = (self._total_pages - 4) * PAGE_SIZE
self._memory[3 * PAGE_SIZE:4 * PAGE_SIZE] = bytes(
[0xE1, 0x10, data_size // 8, 0x00])
elif ndef_message and self._ndef_cc is not None:
# Ultralight parts carry OTP at page 3; NDEF-formatting writes the
# product CC there so a phone recognises the tag as Type 2 NDEF.
self._memory[3 * PAGE_SIZE:4 * PAGE_SIZE] = self._ndef_cc
# Config pages
if self._pwd_page is not None:
self._set_page(self._pwd_page, self._init_password)
if self._pack_page is not None:
self._set_page(self._pack_page, self._init_pack + b"\x00\x00")
if self._cfg0_page is not None:
cfg0 = bytearray(self._get_page(self._cfg0_page))
cfg0[3] = self._init_auth0 & 0xFF # AUTH0
self._set_page(self._cfg0_page, bytes(cfg0))
if self._cfg1_page is not None:
cfg1 = bytearray(self._get_page(self._cfg1_page))
if self._init_prot:
cfg1[0] |= 0x80 # ACCESS.PROT
self._set_page(self._cfg1_page, bytes(cfg1))
if ndef_message:
self.set_ndef(ndef_message)
# ----- page helpers -----
def _get_page(self, page: int) -> bytes:
off = page * PAGE_SIZE
return bytes(self._memory[off:off + PAGE_SIZE])
def get_page(self, page: int) -> bytes:
return self._get_page(page)
def _set_page(self, page: int, data: bytes) -> None:
off = page * PAGE_SIZE
self._memory[off:off + PAGE_SIZE] = data[:PAGE_SIZE].ljust(PAGE_SIZE, b"\x00")
def set_page(self, page: int, data: bytes) -> None:
if len(data) != PAGE_SIZE:
raise ValueError(f"Expected {PAGE_SIZE} bytes")
self._set_page(page, data)
def _apply_page_write(self, page: int, data: bytes) -> None:
"""Write a page honouring the OR-once bytes.
Page 2 bytes 0-1 are read-only (UID / internal); bytes 2-3 are the
static lock bytes and are bit-wise OR'd. Page 3 (OTP on Ultralight,
CC on NTAG) is OR'd whole. Everything else overwrites.
"""
data = data[:PAGE_SIZE].ljust(PAGE_SIZE, b"\x00")
off = page * PAGE_SIZE
cur = bytearray(self._memory[off:off + PAGE_SIZE])
if page == 2:
cur[2] |= data[2]
cur[3] |= data[3]
elif page == 3:
for i in range(PAGE_SIZE):
cur[i] |= data[i]
else:
cur[:] = data
self._memory[off:off + PAGE_SIZE] = bytes(cur)
# ----- config accessors (derived from memory, faithful to the IC) -----
@property
def auth0(self) -> int:
if self._cfg0_page is None:
return 0xFF
return self._memory[self._cfg0_page * PAGE_SIZE + 3]
@property
def memory(self) -> bytes:
return bytes(self._memory)
@property
def prot(self) -> bool:
if self._cfg1_page is None:
return False
return bool(self._memory[self._cfg1_page * PAGE_SIZE] & 0x80)
@property
def password(self) -> bytes:
if self._pwd_page is None:
return b""
return self._get_page(self._pwd_page)
@password.setter
def password(self, value: bytes) -> None:
if self._pwd_page is not None and len(value) == 4:
self._set_page(self._pwd_page, value)
@property
def pack(self) -> bytes:
if self._pack_page is None:
return b""
return self._get_page(self._pack_page)[:2]
@pack.setter
def pack(self, value: bytes) -> None:
if self._pack_page is not None and len(value) == 2:
self._set_page(self._pack_page, value + b"\x00\x00")
def _is_config_page(self, page: int) -> bool:
return page in (self._pwd_page, self._pack_page)
# ----- trace decode -----
def decode_trace(self, direction: int, payload: bytes) -> str | None:
return decode_14443a_type2(direction, payload)
# ----- power / reset -----
async def power_on(self) -> None:
await super().power_on()
self._authenticated_pwd = False
self._pending = None
async def power_off(self) -> None:
await super().power_off()
self._authenticated_pwd = False
self._pending = None
# ----- command dispatch -----
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
if not frame.data:
return None
# Second phase of COMPATIBILITY_WRITE: 16 data bytes (not a command).
if self._pending == "compat_write":
return self._handle_compat_write_data(frame)
cmd = frame.data[0]
if cmd == GET_VERSION and self._version_bytes is not None:
return RFFrame.from_bytes(self._version_bytes)
if cmd == FAST_READ and self._has_fast_read:
return self._handle_fast_read(frame)
if cmd == COMPAT_WRITE:
return self._handle_compat_write_cmd(frame)
if cmd == PWD_AUTH and self._has_pwd_auth:
return self._handle_pwd_auth(frame)
if cmd == READ_SIG and self._has_read_sig:
return self._handle_read_sig(frame)
# READ / WRITE handled by NfcType2Tag, but with our access rules.
if cmd == READ_CMD:
return self._handle_read(frame)
if cmd == WRITE_CMD:
return self._handle_write(frame)
return None
# ----- access control -----
def _read_protected(self, page: int) -> bool:
"""Read protection applies only if PROT=1 (read+write protected)."""
return (self.prot and not self._authenticated_pwd
and page >= self.auth0 and self.auth0 <= self._total_pages)
def _write_protected(self, page: int) -> bool:
return (not self._authenticated_pwd
and page >= self.auth0 and self.auth0 <= self._total_pages)
def _mask_config(self, start_page: int, data: bytearray) -> None:
"""Blank out PWD/PACK bytes in a READ window (they never read back)."""
for i in range(len(data)):
pg = (start_page + i // PAGE_SIZE)
# honour rollover
pg = pg % self._total_pages
if self._is_config_page(pg):
data[i] = 0x00
# ----- READ (override to add protection + config masking) -----
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 2:
return None
page = frame.data[1]
if page >= self._total_pages:
return _nak(NAK_ARG)
if self._read_protected(page):
return _nak(NAK_ARG)
data = bytearray(16)
for i in range(16):
offset = (page * PAGE_SIZE + i) % len(self._memory)
data[i] = self._memory[offset]
self._mask_config(page, data)
return RFFrame.from_bytes(bytes(data))
def _handle_fast_read(self, frame: RFFrame) -> RFFrame | None:
"""FAST_READ (0x3A): return pages start..end inclusive."""
if len(frame.data) < 3:
return _nak(NAK_ARG)
start = frame.data[1]
end = frame.data[2]
if start >= self._total_pages or end >= self._total_pages or end < start:
return _nak(NAK_ARG)
if self._read_protected(start) or self._read_protected(end):
return _nak(NAK_ARG)
data = bytearray(self._memory[start * PAGE_SIZE:(end + 1) * PAGE_SIZE])
self._mask_config(start, data)
return RFFrame.from_bytes(bytes(data))
# ----- WRITE (override to add protection) -----
def _handle_write(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 6:
return _nak(NAK_ARG)
page = frame.data[1]
if page < 2 or page >= self._total_pages:
return _nak(NAK_ARG)
if self._write_protected(page):
return _nak(NAK_ARG)
self._apply_page_write(page, bytes(frame.data[2:6]))
return _ack()
# ----- COMPATIBILITY_WRITE (0xA0), two-phase -----
def _handle_compat_write_cmd(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 2:
return _nak(NAK_ARG)
page = frame.data[1]
if page < 2 or page >= self._total_pages:
return _nak(NAK_ARG)
if self._write_protected(page):
return _nak(NAK_ARG)
self._pending = "compat_write"
self._pending_page = page
return _ack()
def _handle_compat_write_data(self, frame: RFFrame) -> RFFrame | None:
self._pending = None
if len(frame.data) < 4:
return _nak(NAK_ARG)
# Only the least-significant 4 bytes are written (rest must be 00).
self._apply_page_write(self._pending_page, bytes(frame.data[:4]))
return _ack()
# ----- PWD_AUTH (0x1B) -----
def _handle_pwd_auth(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 5:
return _nak(NAK_ARG)
supplied = bytes(frame.data[1:5])
if self._pwd_page is None or supplied != self.password:
return _nak(NAK_AUTH)
self._authenticated_pwd = True
return RFFrame.from_bytes(self.pack)
# ----- READ_SIG (0x3C) -----
def _handle_read_sig(self, frame: RFFrame) -> RFFrame | None:
return RFFrame.from_bytes(self._signature)
# ----- table compilation hook -----
def enumerate_responses(self):
"""Yield (cmd_bytes, response_bytes, flags) for the firmware table.
Covers the static, stateless commands a phone issues before any
password/auth interaction: GET_VERSION, per-page READ, FAST_READ of
the whole memory, READ_SIG. Stateful commands (PWD_AUTH, counters,
writes) are served by the tag model over the WTX/retry relay.
"""
FLAG_CRC = 0x01
if self._version_bytes is not None:
yield (bytes([GET_VERSION]), self._version_bytes, FLAG_CRC)
# READ for each unprotected page (16 bytes, rolling over).
for page in range(self._total_pages):
if self._read_protected(page):
continue
data = bytearray(16)
for i in range(16):
data[i] = self._memory[(page * PAGE_SIZE + i) % len(self._memory)]
self._mask_config(page, data)
yield (bytes([READ_CMD, page]), bytes(data), FLAG_CRC)
if self._has_read_sig:
yield (bytes([READ_SIG, 0x00]), self._signature, FLAG_CRC)

View File

@@ -0,0 +1,301 @@
"""MIFARE Ultralight family — original, Ultralight C, Ultralight EV1.
Sourced from the NXP datasheets:
- MF0ICU2 = MIFARE Ultralight C (Rev. 3.5) — 3DES AUTHENTICATE, 16-bit counter.
- MF0ULX1 = MIFARE Ultralight EV1 (Rev. 3.3) — MF0UL11 / MF0UL21.
The original MIFARE Ultralight (MF0ICU1) datasheet was not supplied; MifareUltralight
below models its well-established behaviour (16 pages, READ/WRITE/COMPAT_WRITE, OTP).
All three share the Type 2 command core in ``type2.NxpType2Tag`` (READ, WRITE,
COMPATIBILITY_WRITE, OTP/lock OR-on-write). Product-specific pieces are added here.
"""
from __future__ import annotations
import os
from pm3py.sim.frame import RFFrame
from pm3py.transponders.bitfield import BitField, Register
from .type2 import (
NxpType2Tag, Type2Config, _ack, _nak, NAK_ARG, NAK_AUTH,
READ_CNT, INCR_CNT, CHECK_TEARING, READ_CMD, WRITE_CMD,
)
AUTHENTICATE = 0x1A # Ultralight C 3DES authenticate
AUTH_CONT = 0xAF # continuation byte (part 2)
class UltralightEV1Config(Type2Config):
"""Ultralight EV1 CFG0/CFG1 — the shared :class:`Type2Config` fields (auth0, prot, cfglck,
authlim, strg_mod_en). EV1 has no ASCII mirror or NFC counter, so nothing is added."""
_label = "Ultralight EV1 config (CFG0/CFG1)"
class UltralightCConfig(Register):
"""Ultralight C protection config: AUTH0 (page 2Ah byte 0) + the AUTH1 protection bit
(page 2Bh byte 0), packed as ``auth0 << 8 | auth1``. Protection from AUTH0 onward is
enforced by 3DES AUTHENTICATE rather than a password."""
_width_bits = 16
_label = "Ultralight C auth config"
auth0 = BitField(15, 8, doc="first page (>= 3) protected; 0x30+ ⇒ protection disabled")
auth1_prot = BitField(0, values={0: "read+write", 1: "write-only"},
doc="protection scope from AUTH0 onward")
# NXP factory default Ultralight C 3DES key (K1||K2, 16 bytes)
UL_C_DEFAULT_KEY = bytes.fromhex("49454D4B41455242214E4143554F5946")
def _rotl1(b: bytes) -> bytes:
"""Rotate an 8-byte block left by one byte."""
return b[1:] + b[:1]
# --------------------------------------------------------------------------
# Original MIFARE Ultralight (MF0ICU1)
# --------------------------------------------------------------------------
class MifareUltralight(NxpType2Tag):
"""Original MIFARE Ultralight (MF0ICU1) — 64 bytes, 16 pages.
Pages 0-1 UID, page 2 internal + static lock, page 3 OTP, pages 4-15 user.
Commands: READ (0x30), WRITE (0xA2), COMPATIBILITY_WRITE (0xA0). No
GET_VERSION / FAST_READ / PWD_AUTH — those are EV1 features.
"""
_version_bytes = None
_has_fast_read = False
_has_read_sig = False
_has_pwd_auth = False
_has_cc = False
_ndef_cc = b"\xE1\x10\x06\x00" # 48 bytes user
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b""):
if uid is None:
uid = b"\x04" + os.urandom(6)
super().__init__(uid=uid, ndef_message=ndef_message, total_pages=16,
atqa=b"\x44\x00", sak=0x00)
# --------------------------------------------------------------------------
# MIFARE Ultralight C (MF0ICU2)
# --------------------------------------------------------------------------
class MifareUltralightC(NxpType2Tag):
"""MIFARE Ultralight C (MF0ICU2) — 192 bytes, 48 pages, 3DES auth.
Layout: page 3 OTP, pages 04h-27h user, page 28h lock bytes 2/3,
page 29h 16-bit counter, page 2Ah AUTH0, page 2Bh AUTH1, pages 2Ch-2Fh
16-byte 3DES key. Protection from AUTH0 onward is gated by a successful
3DES AUTHENTICATE (0x1A) rather than PWD_AUTH.
"""
_version_bytes = None
_has_fast_read = False
_has_read_sig = False
_has_pwd_auth = False
_has_cc = False
_ndef_cc = b"\xE1\x10\x12\x00" # 144 bytes user
AUTH0_PAGE = 0x2A
AUTH1_PAGE = 0x2B
KEY_PAGE = 0x2C # 4 pages: 2C..2F
COUNTER_PAGE = 0x29
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
key: bytes = UL_C_DEFAULT_KEY, auth0: int = 0x30,
auth1_read_protect: bool = True):
if uid is None:
uid = b"\x04" + os.urandom(6)
self._auth_state = "NONE" # NONE, WAIT_RESP, AUTHENTICATED
self._rnd_b = b""
self._resp_iv = b""
super().__init__(uid=uid, ndef_message=ndef_message, total_pages=48,
atqa=b"\x44\x00", sak=0x00)
# Install key + AUTH0/AUTH1 into their pages.
for i in range(4):
self._set_page(self.KEY_PAGE + i, key[i * 4:i * 4 + 4])
self._set_page(self.AUTH0_PAGE, bytes([auth0, 0, 0, 0]))
self._set_page(self.AUTH1_PAGE, bytes([0x00 if auth1_read_protect else 0x01, 0, 0, 0]))
self._counter = 0
@property
def key(self) -> bytes:
return b"".join(self._get_page(self.KEY_PAGE + i) for i in range(4))
@property
def auth0(self) -> int:
return self._get_page(self.AUTH0_PAGE)[0]
@property
def _auth1_read_protected(self) -> bool:
return (self._get_page(self.AUTH1_PAGE)[0] & 0x01) == 0x00
@property
def config(self) -> UltralightCConfig:
"""AUTH0 + AUTH1 protection decoded as a self-describing register. Assign one back
(``tag.config = cfg``) to program the AUTH0 / AUTH1 pages."""
return UltralightCConfig(
(self._get_page(self.AUTH0_PAGE)[0] << 8) | self._get_page(self.AUTH1_PAGE)[0]
)
@config.setter
def config(self, value: "UltralightCConfig | int") -> None:
v = int(value)
self._set_page(self.AUTH0_PAGE, bytes([(v >> 8) & 0xFF, 0, 0, 0]))
self._set_page(self.AUTH1_PAGE, bytes([v & 0x01, 0, 0, 0]))
async def power_on(self) -> None:
await super().power_on()
self._auth_state = "NONE"
# UL-C protection is gated by 3DES auth, not PWD_AUTH.
def _read_protected(self, page: int) -> bool:
return (self._auth1_read_protected and self._auth_state != "AUTHENTICATED"
and page >= self.auth0 and self.auth0 <= self._total_pages)
def _write_protected(self, page: int) -> bool:
return (self._auth_state != "AUTHENTICATED"
and page >= self.auth0 and self.auth0 <= self._total_pages)
def _is_config_page(self, page: int) -> bool:
# The 3DES key never reads back.
return self.KEY_PAGE <= page < self.KEY_PAGE + 4
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
if not frame.data:
return None
# Second phase of AUTHENTICATE (reader sends 0xAF + 16 bytes).
if self._auth_state == "WAIT_RESP" and frame.data[0] == AUTH_CONT:
return self._handle_auth_part2(frame)
if frame.data[0] == AUTHENTICATE:
return self._handle_auth_part1(frame)
if frame.data[0] == WRITE_CMD and len(frame.data) >= 2 and frame.data[1] == self.COUNTER_PAGE:
return self._handle_counter_write(frame)
return super()._handle_application(frame)
# ----- 16-bit one-way counter (page 0x29) -----
def _handle_counter_write(self, frame: RFFrame) -> RFFrame | None:
"""WRITE to the counter page increments the 16-bit one-way counter."""
if len(frame.data) < 6:
return _nak(NAK_ARG)
incr = frame.data[2] | (frame.data[3] << 8)
self._counter = min(0xFFFF, self._counter + incr)
self._set_page(self.COUNTER_PAGE,
bytes([self._counter & 0xFF, (self._counter >> 8) & 0xFF, 0, 0]))
return _ack()
# ----- 3DES AUTHENTICATE (0x1A / 0xAF) -----
def _handle_auth_part1(self, frame: RFFrame) -> RFFrame | None:
"""AUTHENTICATE part 1: tag returns ek(RndB)."""
try:
from Crypto.Cipher import DES3
except ImportError:
return _nak(NAK_AUTH)
self._rnd_b = os.urandom(8)
cipher = DES3.new(self.key, DES3.MODE_CBC, iv=bytes(8))
ek_rnd_b = cipher.encrypt(self._rnd_b)
self._resp_iv = ek_rnd_b # IV that the reader will chain from
self._auth_state = "WAIT_RESP"
return RFFrame.from_bytes(bytes([AUTH_CONT]) + ek_rnd_b)
def _handle_auth_part2(self, frame: RFFrame) -> RFFrame | None:
"""AUTHENTICATE part 2: verify reader token, return ek(RndA')."""
from Crypto.Cipher import DES3
token = bytes(frame.data[1:17])
if len(token) != 16:
self._auth_state = "NONE"
return _nak(NAK_AUTH)
cipher = DES3.new(self.key, DES3.MODE_CBC, iv=self._resp_iv)
plain = cipher.decrypt(token)
rnd_a = plain[0:8]
rnd_b_prime = plain[8:16]
if rnd_b_prime != _rotl1(self._rnd_b):
self._auth_state = "NONE"
return _nak(NAK_AUTH)
# Success: return ek(RndA'), IV chains from last received cipher block.
enc = DES3.new(self.key, DES3.MODE_CBC, iv=token[8:16])
ek_rnd_a_prime = enc.encrypt(_rotl1(rnd_a))
self._auth_state = "AUTHENTICATED"
return RFFrame.from_bytes(bytes([0x00]) + ek_rnd_a_prime)
# --------------------------------------------------------------------------
# MIFARE Ultralight EV1 (MF0UL11 / MF0UL21)
# --------------------------------------------------------------------------
class MifareUltralightEV1(NxpType2Tag):
"""MIFARE Ultralight EV1 base — GET_VERSION, FAST_READ, PWD_AUTH, READ_SIG,
plus three independent 24-bit one-way counters (READ_CNT / INCR_CNT /
CHECK_TEARING_EVENT). Concrete ICs: MF0UL11, MF0UL21."""
_has_cc = False # page 3 is OTP; format for NDEF to add a CC
_config_cls = UltralightEV1Config
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
password: bytes = b"\xFF\xFF\xFF\xFF", pack: bytes = b"\x00\x00",
auth0: int = 0xFF, prot: bool = False,
signature: bytes | None = None):
if uid is None:
uid = b"\x04" + os.urandom(6)
self._counters = [0, 0, 0]
self._tearing = [0xBD, 0xBD, 0xBD] # BD = no tearing event
super().__init__(uid=uid, password=password, pack=pack, auth0=auth0,
prot=prot, signature=signature, cc=None,
ndef_message=ndef_message,
total_pages=self._total_pages_cls,
atqa=b"\x44\x00", sak=0x00)
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
if frame.data:
cmd = frame.data[0]
if cmd == READ_CNT:
return self._handle_read_cnt(frame)
if cmd == INCR_CNT:
return self._handle_incr_cnt(frame)
if cmd == CHECK_TEARING:
return self._handle_check_tearing(frame)
return super()._handle_application(frame)
def _handle_read_cnt(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 2 or frame.data[1] > 2:
return _nak(NAK_ARG)
return RFFrame.from_bytes(self._counters[frame.data[1]].to_bytes(3, "little"))
def _handle_incr_cnt(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 6 or frame.data[1] > 2:
return _nak(NAK_ARG)
n = frame.data[1]
incr = frame.data[2] | (frame.data[3] << 8) | (frame.data[4] << 16)
if self._counters[n] + incr > 0xFFFFFF:
return _nak(NAK_AUTH) # counter overflow (NAK 0x4)
self._counters[n] += incr
return _ack()
def _handle_check_tearing(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 2 or frame.data[1] > 2:
return _nak(NAK_ARG)
return RFFrame.from_bytes(bytes([self._tearing[frame.data[1]]]))
class MF0UL11(MifareUltralightEV1):
"""MIFARE Ultralight EV1 MF0UL11 — 48 bytes user (pages 04h-0Fh)."""
_total_pages_cls = 20
_version_bytes = b"\x00\x04\x03\x01\x01\x00\x0B\x03"
_ndef_cc = b"\xE1\x10\x06\x00" # 48 bytes user
_cfg0_page = 0x10
_cfg1_page = 0x11
_pwd_page = 0x12
_pack_page = 0x13
class MF0UL21(MifareUltralightEV1):
"""MIFARE Ultralight EV1 MF0UL21 — 128 bytes user (pages 04h-23h)."""
_total_pages_cls = 41
_version_bytes = b"\x00\x04\x03\x01\x01\x00\x0E\x03"
_ndef_cc = b"\xE1\x10\x10\x00" # 128 bytes user
_cfg0_page = 0x25
_cfg1_page = 0x26
_pwd_page = 0x27
_pack_page = 0x28

View File

@@ -0,0 +1 @@
"""STMicroelectronics ISO 14443-A transponders (ST25TA Type 4, ST25TN Type 2)."""

View File

@@ -0,0 +1,353 @@
"""ST25TA512B / ST25TA02KB / ST25TA16K / ST25TA64K — ST NFC Forum Type 4 tags.
Sourced from ST datasheets DS12365 Rev 2 (ST25TA512B/02KB) and DocID027786 Rev 4
(ST25TA16K). ISO/IEC 14443 Type A, ISO-DEP, NDEF via ISO 7816 APDUs.
Three files: System (0xE101), CC (0xE103), NDEF (0x0001). Adds over the generic
NfcType4Tag: the ST System file, the exact ST CC contents, 128-bit Read/Write
password protection (VERIFY / Enable / Disable / Change Reference Data), the
20-bit anti-tearing event counter (02KB family) and ExtendedReadBinary (CLA A2h).
ST25TA64K is not covered by a supplied datasheet; its constants below are
extrapolated from the family (FLEN=0x2000) — flagged in the class docstring.
"""
from __future__ import annotations
import os
import struct
from pm3py.sim.frame import RFFrame
from ..ndef import NfcType4Tag
from ..base import State14443A
# File IDs
SYSTEM_FILE_ID = b"\xE1\x01"
CC_FILE_ID = b"\xE1\x03"
NDEF_FILE_ID = b"\x00\x01"
NDEF_AID = b"\xD2\x76\x00\x00\x85\x01\x01"
# ISO 7816 instructions
INS_SELECT = 0xA4
INS_READ_BINARY = 0xB0
INS_UPDATE_BINARY = 0xD6
INS_VERIFY = 0x20
INS_CHANGE_REF_DATA = 0x24
INS_DISABLE_VERIF = 0x26
INS_ENABLE_VERIF = 0x28
CLA_STD = 0x00
CLA_ST = 0xA2
# Status words
SW_OK = b"\x90\x00"
SW_PWD_REQUIRED = b"\x63\x00"
SW_FILE_NOT_FOUND = b"\x6A\x82"
SW_WRONG_P1P2 = b"\x6A\x86"
SW_WRONG_LENGTH = b"\x67\x00"
SW_SEC_NOT_SATISFIED = b"\x69\x82"
SW_REF_NOT_USABLE = b"\x69\x84"
SW_COND_NOT_SATISFIED = b"\x69\x85"
SW_WRONG_LE_LC = b"\x6A\x80"
class ST25TA(NfcType4Tag):
"""Base for the ST25TA family. Concrete ICs set the class attributes."""
_cc_bytes: bytes = b"" # 15-byte CC file
_max_ndef: int = 256 # NDEF file size (FLEN)
_ic_ref: int = 0x00
_product_code: int = 0x00 # UID byte1
_ats_bytes: bytes = b"\x05\x75\x80\x60\x02"
_has_counter: bool = False # 20-bit event counter (02KB family)
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
read_password: bytes | None = None,
write_password: bytes | None = None):
if uid is None:
uid = bytes([0x02, self._product_code]) + os.urandom(5)
# Event counter state (read by _build_files inside super().__init__).
self._counter = 0
self._counter_cfg = 0x00 # b1 enable, b0 source(0=read,1=write)
self._counter_bumped = False
super().__init__(uid=uid, ndef_message=ndef_message,
max_ndef_size=self._max_ndef,
atqa=b"\x44\x00", sak=0x20, ats=self._ats_bytes)
# 16-byte passwords; None → protection disabled for that operation.
self._read_password = read_password
self._write_password = write_password
self._read_verified = read_password is None
self._write_verified = write_password is None
self._read_retries = 3
self._write_retries = 3
# ----- file contents -----
def _build_files(self) -> None:
self._cc_file = self._cc_bytes
self._update_ndef_file()
self._system_file = self._build_system_file()
def _build_system_file(self) -> bytes:
sf = bytearray(0x12)
struct.pack_into(">H", sf, 0x00, 0x12) # length
sf[0x02] = 0x80 # GPO / ST reserved
if self._has_counter:
sf[0x03] = self._counter_cfg
sf[0x08:0x0F] = self._uid # 7-byte UID
struct.pack_into(">H", sf, 0x0F, self._max_ndef - 1) # memory size - 1
sf[0x11] = self._ic_ref
return bytes(sf)
def _current_system_file(self) -> bytes:
sf = bytearray(self._build_system_file())
if self._has_counter:
sf[0x04:0x07] = self._counter.to_bytes(3, "big")
return bytes(sf)
# ----- ISO-DEP reset -----
async def power_on(self) -> None:
await super().power_on()
self._app_selected = False
self._selected_file = None
self._read_verified = self._read_password is None
self._write_verified = self._write_password is None
self._read_retries = 3
self._write_retries = 3
self._counter_bumped = False
def _handle_rats(self, frame: RFFrame) -> RFFrame | None:
# App/file selection state resets on a new activation.
self._app_selected = False
self._selected_file = None
self._counter_bumped = False
return super()._handle_rats(frame)
# ----- APDU dispatch -----
def _handle_apdu(self, apdu: bytes) -> bytes:
if len(apdu) < 4: # need at least CLA INS P1 P2
return SW_WRONG_LENGTH
cla, ins = apdu[0], apdu[1]
if cla == CLA_ST and ins == INS_READ_BINARY:
return self._handle_read_binary(apdu, extended=True)
if cla != CLA_STD and cla != CLA_ST:
return b"\x6E\x00" # class not supported
if ins == INS_SELECT:
return self._handle_select(apdu)
if ins == INS_READ_BINARY:
return self._handle_read_binary(apdu)
if ins == INS_UPDATE_BINARY:
return self._handle_update_binary(apdu)
if ins == INS_VERIFY:
return self._handle_verify(apdu)
if ins == INS_ENABLE_VERIF:
return self._handle_toggle_verif(apdu, enable=True)
if ins == INS_DISABLE_VERIF:
return self._handle_toggle_verif(apdu, enable=False)
if ins == INS_CHANGE_REF_DATA:
return self._handle_change_ref(apdu)
return b"\x6D\x00" # INS not supported
def _handle_select(self, apdu: bytes) -> bytes:
p1, p2 = apdu[2], apdu[3]
lc = apdu[4] if len(apdu) > 4 else 0
data = apdu[5:5 + lc] if lc else b""
if p1 == 0x04: # select by AID
if data == NDEF_AID:
self._app_selected = True
self._selected_file = None
return SW_OK
return SW_FILE_NOT_FOUND
if p1 == 0x00 and p2 == 0x0C: # select by file id
if not self._app_selected:
return SW_FILE_NOT_FOUND
if data in (CC_FILE_ID, NDEF_FILE_ID, SYSTEM_FILE_ID):
self._selected_file = data
return SW_OK
return SW_FILE_NOT_FOUND
return SW_WRONG_P1P2
def _selected_bytes(self) -> bytes | None:
if self._selected_file == CC_FILE_ID:
return self._cc_file
if self._selected_file == NDEF_FILE_ID:
return self._ndef_file
if self._selected_file == SYSTEM_FILE_ID:
return self._current_system_file()
return None
def _handle_read_binary(self, apdu: bytes, extended: bool = False) -> bytes:
content = self._selected_bytes()
if content is None:
return SW_FILE_NOT_FOUND
if self._selected_file == NDEF_FILE_ID and not self._read_verified:
return SW_SEC_NOT_SATISFIED
offset = (apdu[2] << 8) | apdu[3]
le = apdu[4] if len(apdu) > 4 else 0
if offset >= len(content):
return SW_WRONG_P1P2
self._maybe_bump_counter(is_write=False)
return content[offset:offset + le] + SW_OK
def _handle_update_binary(self, apdu: bytes) -> bytes:
if self._selected_file != NDEF_FILE_ID:
return SW_FILE_NOT_FOUND
if not self._write_verified:
return SW_SEC_NOT_SATISFIED
offset = (apdu[2] << 8) | apdu[3]
lc = apdu[4] if len(apdu) > 4 else 0
data = apdu[5:5 + lc]
if offset + len(data) > len(self._ndef_file):
return SW_WRONG_LE_LC # write past end of file
ndef = bytearray(self._ndef_file)
ndef[offset:offset + len(data)] = data
self._ndef_file = bytes(ndef)
self._maybe_bump_counter(is_write=True)
return SW_OK
# ----- password protection -----
def _pwd_for(self, p2: int):
if p2 == 0x01:
return "read"
if p2 == 0x02:
return "write"
return None
def _handle_verify(self, apdu: bytes) -> bytes:
p2 = apdu[3]
which = self._pwd_for(p2)
if which is None:
return SW_WRONG_LE_LC
lc = apdu[4] if len(apdu) > 4 else 0
pwd = self._read_password if which == "read" else self._write_password
verified_attr = "_read_verified" if which == "read" else "_write_verified"
if lc == 0: # query: is a password required?
if pwd is None:
return SW_OK
return SW_OK if getattr(self, verified_attr) else SW_PWD_REQUIRED
if lc != 0x10:
return SW_WRONG_LE_LC
if pwd is None:
return SW_OK
retries_attr = "_read_retries" if which == "read" else "_write_retries"
if getattr(self, retries_attr) == 0:
return SW_COND_NOT_SATISFIED
supplied = apdu[5:5 + 0x10]
if supplied == pwd:
setattr(self, verified_attr, True)
setattr(self, retries_attr, 3)
return SW_OK
n = getattr(self, retries_attr) - 1
setattr(self, retries_attr, n)
return bytes([0x63, 0xC0 | n])
def _handle_toggle_verif(self, apdu: bytes, enable: bool) -> bytes:
which = self._pwd_for(apdu[3])
if which is None:
return SW_WRONG_LE_LC
if enable:
if which == "read":
self._read_password = self._read_password or b"\x00" * 16
self._read_verified = False
else:
self._write_password = self._write_password or b"\x00" * 16
self._write_verified = False
else:
# disabling requires the write password to have been verified
if not self._write_verified:
return SW_SEC_NOT_SATISFIED
if which == "read":
self._read_password = None
self._read_verified = True
else:
self._write_password = None
self._write_verified = True
return SW_OK
def _handle_change_ref(self, apdu: bytes) -> bytes:
which = self._pwd_for(apdu[3])
if which is None:
return SW_WRONG_LE_LC
if not self._write_verified:
return SW_SEC_NOT_SATISFIED
lc = apdu[4] if len(apdu) > 4 else 0
if lc != 0x10:
return SW_WRONG_LE_LC
new_pwd = apdu[5:5 + 0x10]
if which == "read":
self._read_password = new_pwd
else:
self._write_password = new_pwd
return SW_OK
# ----- event counter -----
def _maybe_bump_counter(self, is_write: bool) -> None:
if not self._has_counter or not (self._counter_cfg & 0x02):
return
source_is_write = bool(self._counter_cfg & 0x01)
if is_write != source_is_write:
return
if not self._counter_bumped:
self._counter_bumped = True
if self._counter < 0xFFFFF:
self._counter += 1
@property
def event_counter(self) -> int:
return self._counter
def enable_counter(self, on_write: bool = False) -> None:
"""Enable the 20-bit event counter (02KB family)."""
self._counter_cfg = 0x02 | (0x01 if on_write else 0x00)
# --------------------------------------------------------------------------
# Concrete ICs
# --------------------------------------------------------------------------
class ST25TA512B(ST25TA):
"""ST25TA512B — 64-byte NDEF file."""
_cc_bytes = bytes.fromhex("000F2000400036040600010040 0000".replace(" ", ""))
_max_ndef = 0x40
_ic_ref = 0xE5
_product_code = 0xE4
_ats_bytes = b"\x05\x75\x80\x60\x02"
class ST25TA02KB(ST25TA):
"""ST25TA02KB — 256-byte NDEF file, 20-bit event counter + TruST25."""
_cc_bytes = bytes.fromhex("000F2000FF0036040600010100 0000".replace(" ", ""))
_max_ndef = 0x100
_ic_ref = 0xE2
_product_code = 0xE3
_ats_bytes = b"\x05\x75\x80\x60\x02"
_has_counter = True
class ST25TA16K(ST25TA):
"""ST25TA16K — 2048-byte NDEF file."""
_cc_bytes = bytes.fromhex("000F2000F600F6040600010800 0000".replace(" ", ""))
_max_ndef = 0x800
_ic_ref = 0xC5
_product_code = 0xC5
_ats_bytes = b"\x05\x78\x80\x90\x02"
class ST25TA64K(ST25TA):
"""ST25TA64K — 8192-byte NDEF file.
Not covered by a supplied datasheet; CC/memory-size constants are
extrapolated from the ST25TA family (FLEN=0x2000). Verify against
hardware before relying on the exact CC bytes.
"""
_cc_bytes = bytes.fromhex("000F2000F600F6040600012000 0000".replace(" ", ""))
_max_ndef = 0x2000
_ic_ref = 0xC5
_product_code = 0xC5
_ats_bytes = b"\x05\x78\x80\x90\x02"

View File

@@ -0,0 +1,99 @@
"""ST25TN512 / ST25TN01K — STMicroelectronics NFC Forum Type 2 tags.
Sourced from ST datasheet DS13433 Rev 5. ISO/IEC 14443 Type A, 7-byte UID with
ST manufacturer code 0x02. 64 blocks (256 bytes) of 4 bytes each.
Commands: READ (0x30), WRITE (0xA2) only — no GET_VERSION / FAST_READ / PWD_AUTH.
Write protection is via OTP lock bits. The only password is the 32-bit Kill
password (block 2Fh); a WRITE of that password to the kill keyhole (block 30h)
permanently mutes the RF interface.
"""
from __future__ import annotations
import os
from pm3py.sim.frame import RFFrame
from ..nxp.type2 import NxpType2Tag, _ack, _nak, NAK_ARG, READ_CMD, WRITE_CMD
SYSBLOCK_PAGE = 0x02 # block 2, byte 1 holds the SYSBLOCK value
PRODUCT_ID_PAGE = 0x2D
ANDEF_CFG_PAGE = 0x2E
KILL_PWD_PAGE = 0x2F
KILL_KEYHOLE_PAGE = 0x30
SYSBLOCK_VALUE = 0x2C # points at the product-identification block
class ST25TN(NxpType2Tag):
"""Base for ST25TN. Concrete ICs set memory size / CC / product code."""
_version_bytes = None
_has_fast_read = False
_has_read_sig = False
_has_pwd_auth = False
_has_cc = True
_product_code = b"\x00\x00" # LE product code in product-id block
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
kill_password: bytes = b"\x00\x00\x00\x00",
kill_enabled: bool = True):
if uid is None:
uid = b"\x02" + os.urandom(6) # ST manufacturer code 0x02
self._kill_armed = False
self._killed = False
self._kill_enabled = kill_enabled
super().__init__(uid=uid, cc=self._cc_default, ndef_message=ndef_message,
total_pages=64, atqa=b"\x44\x00", sak=0x00)
# System blocks: SYSBLOCK pointer, product identification, kill password.
p2 = bytearray(self._get_page(SYSBLOCK_PAGE))
p2[1] = SYSBLOCK_VALUE
self._set_page(SYSBLOCK_PAGE, bytes(p2))
self._set_page(PRODUCT_ID_PAGE, self._product_code + b"\x13\x05") # PC, REV, KID
self._set_page(KILL_PWD_PAGE, kill_password)
@property
def killed(self) -> bool:
return self._killed
def _is_config_page(self, page: int) -> bool:
# KILL_PWD (2Fh) and kill keyhole (30h) always read back as 00h.
return page in (KILL_PWD_PAGE, KILL_KEYHOLE_PAGE)
async def power_on(self) -> None:
await super().power_on()
if self._kill_armed:
self._killed = True
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
if self._killed:
return None # KILLED state: mute forever
return await super().handle_frame(frame)
def _handle_write(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 6:
return _nak(NAK_ARG)
page = frame.data[1]
# Kill keyhole: WRITE the kill password here to permanently kill.
if page == KILL_KEYHOLE_PAGE:
if not self._kill_enabled:
return _nak(NAK_ARG)
if bytes(frame.data[2:6]) == self._get_page(KILL_PWD_PAGE):
self._kill_armed = True
return _ack()
return _nak(NAK_ARG)
# Product identification block is read-only.
if page == PRODUCT_ID_PAGE:
return _nak(NAK_ARG)
return super()._handle_write(frame)
class ST25TN512(ST25TN):
"""ST25TN512 — 64 bytes user (blocks 04h-13h)."""
_cc_default = b"\xE1\x10\x08\x00"
_product_code = b"\x91\x90" # PC = 0x9091, LE on wire
class ST25TN01K(ST25TN):
"""ST25TN01K — 160 bytes user (blocks 04h-2Bh)."""
_cc_default = b"\xE1\x10\x14\x00"
_product_code = b"\x90\x90" # PC = 0x9090

View File

@@ -0,0 +1 @@
"""ISO 14443 Type B transponders (ST25TB / SRIX family)."""

View File

@@ -0,0 +1,270 @@
"""ISO 14443 Type B transponder base — SRIX / ST25TB slot-marker family.
This family does NOT use the standard ISO 14443-3 Type B REQB/ATQB/ATTRIB flow.
It uses the simplified SRIX anticollision built from Initiate / Pcall16 /
Slot_marker with an 8-bit Chip_ID, plus Select(Chip_ID) / Get_UID / Read_block /
Write_block / Completion / Reset_to_inventory. RF framing (SOF/EOF/CRC_B) is
handled at the wire layer; this model works on the command payload like the
14443-A and 15693 models.
"""
from __future__ import annotations
import os
from enum import IntEnum
from pm3py.sim.frame import RFFrame
from pm3py.sim.transponder import Transponder
from pm3py.trace.decode_iso14b import decode_14443b
# Command opcodes
CMD_INITIATE = 0x06 # 06 00
CMD_PCALL16 = 0x06 # 06 04 (same first byte)
CMD_READ_BLOCK = 0x08
CMD_WRITE_BLOCK = 0x09
CMD_GET_UID = 0x0B
CMD_RESET_TO_INVENTORY = 0x0C
CMD_SELECT = 0x0E
CMD_COMPLETION = 0x0F
class StateB(IntEnum):
POWER_OFF = 0
READY = 1
INVENTORY = 2
SELECTED = 3
DEACTIVATED = 4
class Tag14443B(Transponder):
"""SRIX-style ISO 14443 Type B tag: anticollision + block memory."""
def __init__(self, uid: bytes):
super().__init__()
if len(uid) != 8:
raise ValueError(f"Type B UID must be 8 bytes, got {len(uid)}")
self._uid = uid # MSB-first (uid[0]=D0h, uid[7]=LS byte)
self._state = StateB.POWER_OFF
self._chip_id = 0x00
self._slot = 0 # slot drawn on last Pcall16
def decode_trace(self, direction: int, payload: bytes) -> str | None:
return decode_14443b(direction, payload)
@property
def state(self) -> str:
return self._state.name
@property
def chip_id(self) -> int:
return self._chip_id
async def power_on(self) -> None:
self._state = StateB.READY
self._chip_id = os.urandom(1)[0]
async def power_off(self) -> None:
self._state = StateB.POWER_OFF
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
data = frame.data
if not data:
return None
b0 = data[0]
# Anticollision opcode 0x06 carries a sub-command byte.
if b0 == 0x06 and len(data) >= 2:
if data[1] == 0x00:
return self._handle_initiate()
if data[1] == 0x04:
return self._handle_pcall16()
return None
# Slot marker: low nibble 6, high nibble = slot number (1..15).
if (b0 & 0x0F) == 0x06 and (b0 >> 4) != 0:
return self._handle_slot_marker(b0 >> 4)
if b0 == CMD_SELECT and len(data) >= 2:
return self._handle_select(data[1])
if b0 == CMD_COMPLETION:
return self._handle_completion()
if b0 == CMD_RESET_TO_INVENTORY:
return self._handle_reset_to_inventory()
# Memory commands work only in Selected state.
if self._state != StateB.SELECTED:
return None
if b0 == CMD_GET_UID:
return RFFrame.from_bytes(self._uid[::-1])
if b0 == CMD_READ_BLOCK and len(data) >= 2:
return self._handle_read_block(data[1])
if b0 == CMD_WRITE_BLOCK and len(data) >= 6:
self._handle_write_block(data[1], bytes(data[2:6]))
return None # Write_block has no response
return None
# ----- anticollision -----
def _handle_initiate(self) -> RFFrame | None:
if self._state in (StateB.READY, StateB.INVENTORY):
self._state = StateB.INVENTORY
self._chip_id = os.urandom(1)[0]
return RFFrame.from_bytes(bytes([self._chip_id]))
return None
def _handle_pcall16(self) -> RFFrame | None:
if self._state != StateB.INVENTORY:
return None
self._slot = os.urandom(1)[0] & 0x0F
if self._slot == 0:
return RFFrame.from_bytes(bytes([self._chip_id]))
return None
def _handle_slot_marker(self, slot: int) -> RFFrame | None:
if self._state != StateB.INVENTORY:
return None
if slot == self._slot:
return RFFrame.from_bytes(bytes([self._chip_id]))
return None
def _handle_select(self, chip_id: int) -> RFFrame | None:
if chip_id == self._chip_id:
self._state = StateB.SELECTED
return RFFrame.from_bytes(bytes([self._chip_id]))
if self._state == StateB.SELECTED:
self._state = StateB.INVENTORY # deselected
return None
def _handle_completion(self) -> RFFrame | None:
if self._state == StateB.SELECTED:
self._state = StateB.DEACTIVATED
return None
def _handle_reset_to_inventory(self) -> RFFrame | None:
if self._state == StateB.SELECTED:
self._state = StateB.INVENTORY
return None
# ----- memory (override in subclasses) -----
def _handle_read_block(self, addr: int) -> RFFrame | None:
return None
def _handle_write_block(self, addr: int, data: bytes) -> None:
pass
# --------------------------------------------------------------------------
# Standard ISO 14443-3/4 Type B (REQB / ATQB / ATTRIB / ISO-DEP)
# --------------------------------------------------------------------------
APF = 0x05 # REQB/WUPB/Slot-MARKER anticollision prefix
ATTRIB_CMD = 0x1D
HLTB_CMD = 0x50
class StateB4(IntEnum):
IDLE = 0
READY = 1 # answered REQB/WUPB (ATQB sent)
ACTIVE = 2 # ATTRIB done, ISO-DEP running
HALT = 3
class Tag14443B_4(Transponder):
"""Standard ISO 14443 Type B tag with ISO-DEP (ISO 14443-4).
Implements the REQB/WUPB -> ATQB, Slot-MARKER, ATTRIB -> answer flow and
an I-block APDU transport, with a ``_handle_apdu`` hook for the higher
layer (e.g. NFC Forum Type 4 NDEF). Used by the TI RF430CL330H.
"""
def __init__(self, pupi: bytes | None = None, afi: int = 0x00,
app_data: bytes = b"\x00\x00\x00\x00",
fsci: int = 0x08, fwi: int = 0x04):
super().__init__()
self._pupi = pupi if pupi is not None else os.urandom(4)
self._afi = afi
self._app_data = app_data
self._fsci = fsci & 0x0F
self._fwi = fwi & 0x0F
self._state = StateB4.IDLE
def decode_trace(self, direction: int, payload: bytes) -> str | None:
return decode_14443b(direction, payload)
@property
def state(self) -> str:
return self._state.name
@property
def pupi(self) -> bytes:
return self._pupi
async def power_on(self) -> None:
self._state = StateB4.IDLE
async def power_off(self) -> None:
self._state = StateB4.IDLE
def _atqb(self) -> RFFrame:
# 50 | PUPI(4) | AppData(4) | ProtInfo(3)
prot_info = bytes([
0x00, # bit-rate capability: 106 kbps only (default)
(self._fsci << 4) | 0x01, # max frame size | protocol type (ISO 14443-4)
(self._fwi << 4), # FWI | ADC(00) | FO(00)
])
return RFFrame.from_bytes(bytes([0x50]) + self._pupi + self._app_data + prot_info)
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
data = frame.data
if not data:
return None
b0 = data[0]
# REQB / WUPB / Slot-MARKER (all start with APf=0x05)
if b0 == APF and len(data) >= 3:
afi = data[1]
param = data[2]
is_wupb = bool(param & 0x08)
if self._state == StateB4.HALT and not is_wupb:
return None # halted tags answer only WUPB
if afi != 0x00 and afi != self._afi:
return None # AFI filter
self._state = StateB4.READY
return self._atqb()
# Slot-MARKER: APn = (slot<<4) | 0x05
if (b0 & 0x0F) == APF and (b0 >> 4) != 0:
if self._state == StateB4.READY:
return self._atqb()
return None
if b0 == ATTRIB_CMD and len(data) >= 5:
if bytes(data[1:5]) != self._pupi:
return None
self._state = StateB4.ACTIVE
cid = data[8] & 0x0F if len(data) > 8 else 0
return RFFrame.from_bytes(bytes([cid])) # Answer-to-ATTRIB: MBLI|CID
if b0 == HLTB_CMD and len(data) >= 5:
if bytes(data[1:5]) == self._pupi:
self._state = StateB4.HALT
return RFFrame.from_bytes(b"\x00")
return None
# ISO-DEP I-block in Active state
if self._state == StateB4.ACTIVE:
return self._handle_layer4(frame)
return None
def _handle_layer4(self, frame: RFFrame) -> RFFrame | None:
data = frame.data
pcb = data[0]
if pcb & 0xC0 == 0x00: # I-block
resp = self._handle_apdu(bytes(data[1:]))
resp_pcb = 0x02 | (pcb & 0x01)
return RFFrame.from_bytes(bytes([resp_pcb]) + resp)
return None
def _handle_apdu(self, apdu: bytes) -> bytes:
"""Override in subclasses. Default: 6A82 (file not found)."""
return b"\x6A\x82"

View File

@@ -0,0 +1 @@
"""STMicroelectronics ISO 14443 Type B transponders (ST25TB / SRIX)."""

View File

@@ -0,0 +1,118 @@
"""ST25TB512-AC / ST25TB512-AT / ST25TB02K / ST25TB04K — ST ISO 14443 Type B.
Sourced from ST datasheets DS11469/DS11364/DS11495/DS11456. 32-bit blocks,
64-bit UID (prefix D0 02 <product-code>), two decrement-only count-down counters
at blocks 5 and 6, a resettable OTP area (blocks 0-4, except the -AT variant),
and the OTP_Lock_Reg system block at address 255.
"""
from __future__ import annotations
import os
from pm3py.sim.frame import RFFrame
from ..base import Tag14443B
SYSTEM_BLOCK = 0xFF
COUNTER_BLOCKS = (5, 6)
OTP_BLOCKS = range(0, 5) # blocks 0-4
class ST25TB(Tag14443B):
"""Base for the ST25TB family. Concrete ICs set block count / product code."""
_num_blocks: int = 16
_product_code: int = 0x1B
_resettable_otp: bool = True # False for -AT (blocks 0-4 are plain EEPROM)
def __init__(self, uid: bytes | None = None):
if uid is None:
uid = bytes([0xD0, 0x02, self._product_code]) + os.urandom(5)
super().__init__(uid=uid)
self._memory = bytearray(self._num_blocks * 4)
self._system = bytearray(4) # block 255: OTP_Lock_Reg + reserved
# Resettable OTP area ships erased to all-1s (bits then only clear).
if self._resettable_otp:
for blk in OTP_BLOCKS:
self._set_block(blk, b"\xFF\xFF\xFF\xFF")
# Count-down counters factory values.
self._set_block(5, (0xFFFFFFFE).to_bytes(4, "little"))
self._set_block(6, (0xFFFFFFFF).to_bytes(4, "little"))
# ----- block helpers -----
def _get_block(self, addr: int) -> bytes:
if addr == SYSTEM_BLOCK:
return bytes(self._system)
off = addr * 4
return bytes(self._memory[off:off + 4])
def _set_block(self, addr: int, data: bytes) -> None:
if addr == SYSTEM_BLOCK:
self._system[:] = data[:4]
return
off = addr * 4
self._memory[off:off + 4] = data[:4]
def _valid_addr(self, addr: int) -> bool:
return addr < self._num_blocks or addr == SYSTEM_BLOCK
# ----- read / write -----
def _handle_read_block(self, addr: int) -> RFFrame | None:
if not self._valid_addr(addr):
return None
return RFFrame.from_bytes(self._get_block(addr))
def _handle_write_block(self, addr: int, data: bytes) -> None:
if not self._valid_addr(addr):
return
new_val = int.from_bytes(data, "little")
if addr in COUNTER_BLOCKS:
# Decrement-only: accept only a strictly lower value.
cur = int.from_bytes(self._get_block(addr), "little")
if new_val < cur:
self._set_block(addr, new_val.to_bytes(4, "little"))
return
if self._resettable_otp and addr in OTP_BLOCKS:
# OTP: no auto-erase, bits can only clear 1 -> 0.
cur = int.from_bytes(self._get_block(addr), "little")
self._set_block(addr, (cur & new_val).to_bytes(4, "little"))
return
if addr == SYSTEM_BLOCK:
# OTP_Lock_Reg: bit-clear only.
cur = int.from_bytes(self._get_block(addr), "little")
self._set_block(addr, (cur & new_val).to_bytes(4, "little"))
return
self._set_block(addr, data)
class ST25TB512AC(ST25TB):
"""ST25TB512-AC — 16 blocks, resettable OTP area."""
_num_blocks = 16
_product_code = 0x1B
_resettable_otp = True
class ST25TB512AT(ST25TB):
"""ST25TB512-AT — 16 blocks, blocks 0-4 are plain EEPROM (no OTP reset)."""
_num_blocks = 16
_product_code = 0x33
_resettable_otp = False
class ST25TB02K(ST25TB):
"""ST25TB02K — 64 blocks (2 Kbit)."""
_num_blocks = 64
_product_code = 0x3F
_resettable_otp = True
class ST25TB04K(ST25TB):
"""ST25TB04K — 128 blocks (4 Kbit)."""
_num_blocks = 128
_product_code = 0x1F
_resettable_otp = True

View File

@@ -0,0 +1 @@
"""Texas Instruments ISO 14443 Type B transponders (RF430CL330H NFC Type 4B)."""

View File

@@ -0,0 +1,152 @@
"""RF430CL330H — Texas Instruments Dynamic NFC Interface Transponder.
Sourced from TI SLAS916C. NFC Forum Type 4 tag over ISO/IEC 14443 Type B
(standard REQB/ATQB/ATTRIB + ISO-DEP), backed by 3 KB of volatile SRAM that a
host controller fills over SPI/I2C. This models the RF/NFC side: Type B
activation (via Tag14443B_4) plus the ISO 7816 Type 4 NDEF file system.
The datasheet defers the low-level ISO 14443-3 Type B byte layouts to the ISO
standard; the CC contents (MLe=00F9, MLc=00F6), the AID, and the APDU flow are
taken from SLAS916C §5.8-5.9. The NDEF file ID is host-programmable; this model
defaults it to the NFC Forum well-known E104 for phone compatibility.
"""
from __future__ import annotations
import struct
from pm3py.sim.frame import RFFrame
from ..base import Tag14443B_4
NDEF_AID = b"\xD2\x76\x00\x00\x85\x01\x01"
CC_FILE_ID = b"\xE1\x03"
NDEF_FILE_ID = b"\xE1\x04"
INS_SELECT = 0xA4
INS_READ_BINARY = 0xB0
INS_UPDATE_BINARY = 0xD6
SW_OK = b"\x90\x00"
SW_FILE_NOT_FOUND = b"\x6A\x82"
SW_WRONG_P1P2 = b"\x6A\x86"
SW_WRONG_LENGTH = b"\x67\x00"
SW_SEC_NOT_SATISFIED = b"\x69\x82"
class RF430CL330H(Tag14443B_4):
"""TI RF430CL330H NFC Type 4 dynamic tag (ISO 14443 Type B)."""
def __init__(self, ndef_message: bytes = b"", max_ndef_size: int = 2048,
pupi: bytes | None = None, ndef_file_id: bytes = NDEF_FILE_ID):
# ISO-DEP: FSCI=8 (256-byte frame), FWI=4. AppData=0.
super().__init__(pupi=pupi, afi=0x00, fsci=0x08, fwi=0x04)
self._max_ndef = max_ndef_size
self._ndef_file_id = ndef_file_id
self._ndef_message = ndef_message
self._app_selected = False
self._selected_file: bytes | None = None
self._build_files()
def _build_files(self) -> None:
# CC file (15 bytes) — MLe=00F9, MLc=00F6 per SLAS916C.
self._cc_file = (
struct.pack(">H", 0x000F) # CCLEN
+ b"\x20" # mapping version 2.0
+ struct.pack(">H", 0x00F9) # MLe (max R-APDU)
+ struct.pack(">H", 0x00F6) # MLc (max C-APDU)
+ b"\x04\x06" # NDEF file control TLV: T=04, L=06
+ self._ndef_file_id # NDEF file id
+ struct.pack(">H", self._max_ndef) # max NDEF file size
+ b"\x00" # read access: granted
+ b"\x00" # write access: granted
)
self._update_ndef_file()
def _update_ndef_file(self) -> None:
nlen = len(self._ndef_message)
self._ndef_file = struct.pack(">H", nlen) + self._ndef_message
if len(self._ndef_file) < self._max_ndef:
self._ndef_file += b"\x00" * (self._max_ndef - len(self._ndef_file))
def set_ndef(self, message: bytes) -> None:
"""Set the NDEF message (takes effect immediately, no sync needed)."""
if len(message) > self._max_ndef - 2:
raise ValueError(
f"NDEF message too large: {len(message)} bytes, "
f"max is {self._max_ndef - 2} bytes")
self._ndef_message = message
self._update_ndef_file()
def clear_ndef(self) -> None:
self._ndef_message = b""
self._update_ndef_file()
async def power_on(self) -> None:
await super().power_on()
self._app_selected = False
self._selected_file = None
# ----- ISO 7816 Type 4 APDU handling -----
def _handle_apdu(self, apdu: bytes) -> bytes:
if len(apdu) < 4: # need at least CLA INS P1 P2
return SW_WRONG_LENGTH
ins = apdu[1]
if ins == INS_SELECT:
return self._handle_select(apdu)
if ins == INS_READ_BINARY:
return self._handle_read_binary(apdu)
if ins == INS_UPDATE_BINARY:
return self._handle_update_binary(apdu)
return b"\x6D\x00" # INS not supported
def _handle_select(self, apdu: bytes) -> bytes:
p1, p2 = apdu[2], apdu[3]
lc = apdu[4] if len(apdu) > 4 else 0
data = apdu[5:5 + lc] if lc else b""
if p1 == 0x04: # select by AID
if data == NDEF_AID:
self._app_selected = True
self._selected_file = None
return SW_OK
return SW_FILE_NOT_FOUND
if p1 == 0x00 and p2 == 0x0C: # select by file id
if not self._app_selected:
return SW_FILE_NOT_FOUND
if data in (CC_FILE_ID, self._ndef_file_id):
self._selected_file = data
return SW_OK
return SW_FILE_NOT_FOUND
return SW_WRONG_P1P2
def _selected_bytes(self) -> bytes | None:
if self._selected_file == CC_FILE_ID:
return self._cc_file
if self._selected_file == self._ndef_file_id:
return self._ndef_file
return None
def _handle_read_binary(self, apdu: bytes) -> bytes:
content = self._selected_bytes()
if content is None:
return SW_FILE_NOT_FOUND
offset = (apdu[2] << 8) | apdu[3]
le = apdu[4] if len(apdu) > 4 else 0
if offset >= len(content):
return SW_WRONG_P1P2
return content[offset:offset + le] + SW_OK
def _handle_update_binary(self, apdu: bytes) -> bytes:
if self._selected_file != self._ndef_file_id:
return SW_FILE_NOT_FOUND
offset = (apdu[2] << 8) | apdu[3]
lc = apdu[4] if len(apdu) > 4 else 0
data = apdu[5:5 + lc]
if offset + len(data) > len(self._ndef_file):
return SW_WRONG_P1P2 # write past end of file
ndef = bytearray(self._ndef_file)
ndef[offset:offset + len(data)] = data
self._ndef_file = bytes(ndef)
# keep _ndef_message in sync with NLEN
nlen = (self._ndef_file[0] << 8) | self._ndef_file[1]
self._ndef_message = self._ndef_file[2:2 + nlen]
return SW_OK

View File

@@ -0,0 +1 @@
"""Infineon ISO 15693 transponders (my-d vicinity SRF55VxxP)."""

View File

@@ -0,0 +1,150 @@
"""Infineon my-d vicinity — SRF55V02P / SRF55V10P (ISO 15693 / ISO 18000-3 M1).
Sourced from the Infineon my-d vicinity plain Extended Datasheet Rev 4.0.
8-byte UID with Infineon manufacturer code 0x05 (prefix E0 05 <chip-id>).
Notable differences from the NXP/ST 15693 parts:
- **No GET SYSTEM INFO (0x2B)** — identification is via the Chip-ID byte (UID
byte 5). A reader must not rely on 0x2B.
- Two memory views: ISO block mode (4-byte blocks) and a custom page mode
(8-byte pages) reached through the single custom opcode 0xA0. Plain variants
have no password/authentication (open access).
- Any user page can be a decrement-only value counter (custom Restricted Write).
This model implements the ISO block interface plus the custom Read/Write page
commands; the secure (SRF55VxxS) authentication protocol is not modelled.
"""
from __future__ import annotations
from pm3py.sim.frame import RFFrame
from ..base import Tag15693, FLAG_ADDRESS, FLAG_INVENTORY
INFINEON_MFG = 0x05
# Custom sub-commands (inside the 0xA0 custom frame)
SUB_READ = 0x10 # read one 8-byte page
SUB_WRITE = 0x30 # write one 8-byte page
SUB_WRITE_BYTE = 0x90 # write one byte (admin bytes included)
SUB_WRITE_REREAD = 0xB0 # write then return the page
SUB_RESTRICTED_WRITE = 0x00 # value-counter decrement
SUB_RESTRICTED_WRITE_REREAD = 0x80
class MydVicinity(Tag15693):
"""Infineon my-d vicinity plain. Concrete ICs set the memory size."""
_uid_prefix = b"\xE0\x05" # E0 + Infineon mfg 0x05; chip-id appended
_chip_id: int = 0x40 # UID byte 5 (also encodes EEPROM size)
def __init__(self, uid: bytes | str | None = None, dsfid: int = 0, afi: int = 0):
super().__init__(uid=uid, dsfid=dsfid, afi=afi,
block_size=4, num_blocks=self._num_blocks_cls,
ic_reference=0x00)
# Page-mode view: 8-byte data pages (custom addressing). Physical
# memory is shared with the block view (block N ↔ its bytes).
self._page_size = 8
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 2:
return None
cmd = frame.data[1]
if cmd == 0x2B:
# my-d vicinity does not implement Get System Info.
return self._make_error(0x01)
if cmd == 0xA0:
return self._handle_custom(frame)
return await super().handle_frame(frame)
# ----- custom page commands (opcode 0xA0, mfg 0x05) -----
def _handle_custom(self, frame: RFFrame) -> RFFrame | None:
data = frame.data
flags = data[0]
if len(data) < 3 or data[2] != INFINEON_MFG:
return None
addressed = bool(flags & FLAG_ADDRESS) and not (flags & FLAG_INVENTORY)
# flags, cmd(A0), mfg [+uid], then <sub-cmd, addr0(page), addr1(00), data...>
off = 3 + (8 if addressed else 0)
if addressed and (len(data) < 11 or bytes(reversed(data[3:11])) != self._uid):
return None
if len(data) < off + 3:
return self._make_error(0x0F)
sub = data[off]
page = data[off + 1]
params = data[off + 3:]
if sub == SUB_READ:
return self._read_page(page)
if sub == SUB_WRITE:
return self._write_page(page, params, reread=False)
if sub == SUB_WRITE_REREAD:
return self._write_page(page, params, reread=True)
if sub == SUB_WRITE_BYTE:
return self._write_byte(page, params)
if sub in (SUB_RESTRICTED_WRITE, SUB_RESTRICTED_WRITE_REREAD):
return self._restricted_write(page, params,
reread=(sub == SUB_RESTRICTED_WRITE_REREAD))
return self._make_error(0x01)
def _page_offset(self, page: int) -> int:
return page * self._page_size
def _num_pages(self) -> int:
return (self._num_blocks * self._block_size) // self._page_size
def _read_page(self, page: int) -> RFFrame | None:
if page >= self._num_pages():
return self._make_error(0x10)
off = self._page_offset(page)
return self._make_response(bytes(self._memory[off:off + self._page_size]))
def _write_page(self, page: int, data: bytes, reread: bool) -> RFFrame | None:
if page >= self._num_pages():
return self._make_error(0x10)
if len(data) < self._page_size:
return self._make_error(0x0F)
off = self._page_offset(page)
self._memory[off:off + self._page_size] = data[:self._page_size]
if reread:
return self._make_response(bytes(self._memory[off:off + self._page_size]))
return self._make_response(b"")
def _write_byte(self, page: int, params: bytes) -> RFFrame | None:
# params = byte-number(1) + value(1)
if len(params) < 2:
return self._make_error(0x0F)
byte_no, value = params[0], params[1]
if page >= self._num_pages() or byte_no > 0x09:
return self._make_error(0x10)
# Only the 8 data bytes live in _memory; admin bytes 8/9 are accepted
# but not stored in the block view.
if byte_no < 8:
self._memory[self._page_offset(page) + byte_no] = value
return self._make_response(b"")
def _restricted_write(self, page: int, params: bytes, reread: bool) -> RFFrame | None:
# Decrement-only value counter: accept only a strictly lower 32-bit value.
if page >= self._num_pages() or len(params) < 4:
return self._make_error(0x0F)
off = self._page_offset(page)
new_val = int.from_bytes(params[0:4], "little")
cur = int.from_bytes(self._memory[off:off + 4], "little")
if new_val >= cur and cur != 0:
return self._make_error(0x0F) # not lower → rejected
self._memory[off:off + 4] = new_val.to_bytes(4, "little")
if reread:
return self._make_response(bytes(self._memory[off:off + self._page_size]))
return self._make_response(b"")
class SRF55V02P(MydVicinity):
"""my-d vicinity SRF55V02P — 2.5 kbit, 56 ISO blocks (00h-37h)."""
_uid_prefix = b"\xE0\x05\x40"
_chip_id = 0x40
_num_blocks_cls = 56
class SRF55V10P(MydVicinity):
"""my-d vicinity SRF55V10P — 10 kbit, 248 ISO blocks (00h-F7h)."""
_uid_prefix = b"\xE0\x05\x00"
_chip_id = 0x00
_num_blocks_cls = 248

View File

@@ -12,6 +12,8 @@ from __future__ import annotations
import os import os
from pm3py.transponders.bitfield import BitField, Register
try: try:
from Crypto.Cipher import AES as _AES from Crypto.Cipher import AES as _AES
_HAS_AES = True _HAS_AES = True
@@ -34,6 +36,39 @@ PRIV_CRYPTO_CONFIG = 0x20
PRIV_AREA1_READ = 0x40 PRIV_AREA1_READ = 0x40
PRIV_AREA1_WRITE = 0x80 PRIV_AREA1_WRITE = 0x80
class NxpKeyPrivileges(Register):
"""NXP ICODE DNA / NTAG 5 AES key-privilege mask (Table 5) — the 8 bits defining what a
successful authentication with a key slot is allowed to do. Build one to configure a slot,
or decode a stored byte::
NxpKeyPrivileges.build(read=True, write=True, crypto_config=True)
NxpKeyPrivileges(0x21).crypto_config # -> True
"""
_width_bits = 8
_label = "NXP key privileges"
read = BitField(0, doc="read user memory")
write = BitField(1, doc="write user memory")
privacy = BitField(2, doc="toggle privacy (enable/disable) mode")
destroy = BitField(3, doc="permanently destroy the tag")
eas_afi = BitField(4, doc="set/reset EAS and write the AFI")
crypto_config = BitField(5, doc="change crypto configuration — keys, privileges, GCH")
area1_read = BitField(6, doc="read AREA1 (Link/Boost second memory area)")
area1_write = BitField(7, doc="write AREA1")
@classmethod
def build(cls, **flags: bool) -> "NxpKeyPrivileges":
"""Compose a privilege mask from named flags, e.g. ``build(read=True, write=True)``."""
valid = {name for name, _ in cls.fields()}
reg = cls(0)
for name, value in flags.items():
if name not in valid:
raise TypeError(f"unknown privilege {name!r}; valid: {sorted(valid)}")
setattr(reg, name, bool(value))
return reg
# NFC Global Crypto Header values # NFC Global Crypto Header values
GCH_DEACTIVATED = 0x81 GCH_DEACTIVATED = 0x81
GCH_DEACTIVATED_PRIV_LOCKED = 0x87 GCH_DEACTIVATED_PRIV_LOCKED = 0x87
@@ -83,6 +118,14 @@ class NxpAesAuth:
return False return False
return bool(self._key_privileges[key_id] & privilege) return bool(self._key_privileges[key_id] & privilege)
def key_privileges(self, key_id: int) -> NxpKeyPrivileges:
"""Key slot ``key_id``'s privilege mask as a self-describing :class:`NxpKeyPrivileges`."""
return NxpKeyPrivileges(self._key_privileges[key_id])
def set_key_privileges(self, key_id: int, value: "NxpKeyPrivileges | int") -> None:
"""Program key slot ``key_id``'s privilege mask (accepts a register or a raw byte)."""
self._key_privileges[key_id] = int(value) & 0xFF
def is_access_enforced(self) -> bool: def is_access_enforced(self) -> bool:
"""Check if GCH is activated (>= 0xC1), meaning access conditions are enforced.""" """Check if GCH is activated (>= 0xC1), meaning access conditions are enforced."""
return self._nfc_gch >= GCH_ACTIVATED return self._nfc_gch >= GCH_ACTIVATED

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