Files
pm3py/docs/plans/2026-07-05-14a-custom-commands-plan.md
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

20 KiB
Raw Blame History

Custom ISO14443-A command handling — Implementation Plan

Status: Planned, unimplemented · Date: 2026-07-05 · Design: 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/09020x0950/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_EVENTRESELECTtag.power_on(); L4out=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)*120B 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 E1EmSend4bit(0x0A), active_groups |= SEL_PENDING.
  3. Reader → 01 00 00 00 (bare sector byte) → :2493 in SEL_PENDING hit E3response_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 0Fnative 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 00E5 → 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 driftprotocol.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.