- 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>
20 KiB
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 thesim_tableactive-group bits →{0,1,3}; native READ (:2033-2058) and FAST_READ (:2081-2093) addsim_active_sector()*0x400to theemlGetsource 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:2493hook): 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 A–D.
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:2493hook): deterministic ISO-DEP APDUs — Type-4 NDEF SELECT-App(AID) / SELECT-File(CC,NDEF) / ReadBinary, canned GET/PUT DATA, fixed90 00. Firmware masks the PCB ((pcb&0xE2)==0x02= I-block), computesoff = 1 + CID + NAD, strips the trailing CRC,MATCH_PREFIXon the bare APDU; on hit re-wraps (response[0]=pcb, CID re-inserted atresponse[1]whenpcb&0x08, payload atresponse+off,AddCrc14A). Overrides the bogus blanket90 00at: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; fixprotocol.Cmdstale0x0900series).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 viaemlGet).SIM_FLAG_RESP_4BIT = 0x04(new) response-flag (fwsim_table.h+RESP_FLAG_4BITintable_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-4bytes;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, activateGRP_SEL_PENDING.E2[00 00 00 00]EXACT, group=GRP_SEL_PENDING,response_len=0, activateGRP_SECTOR0, deactivateSECTOR1|SECTOR3|SEL_PENDING.E3[01 00 00 00]→GRP_SECTOR1.E4[03 00 00 00]→GRP_SECTOR3.E5[]PREFIX (last) inGRP_SEL_PENDING→[00]4-bit NAK, deactivateSEL_PENDING.
Runtime:
- Native anticoll/SELECT (unchanged).
sim_active_sector()==0. - Reader →
C2 FF→:2493hitE1→EmSend4bit(0x0A),active_groups |= SEL_PENDING. - Reader →
01 00 00 00(bare sector byte) →:2493inSEL_PENDINGhitE3→response_len==0⇒ apply state (+SECTOR1, clear others +SEL_PENDING), transmit nothing = the >1ms passive-ACK.sim_active_sector()now returns 1. - Reader →
FAST_READ 3A 00 0F→ native FAST_READ fires first (correct), readsemlGetatbase + 1*0x400= sector-1 memory, returns viaEmSendCmd. Sector-correct, O(1), wire-speed, no table scan, no USB. - Back to sector 0:
C2 FF(E1),00 00 00 00(E2 →SECTOR0, silence). - Invalid sector:
C2 FFthen07 00 00 00→E5→ 4-bit NAK,SEL_PENDINGcleared.
All frames trace via EmLogTrace/sim_trace_push automatically.
Stateful command handling — five representations
- In-table group state (no host, wire-speed): SECTOR_SELECT active sector,
SLIX2-style privacy, PWD_AUTH-gated read sets.
activate_groups/deactivate_groupsmasks; 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. - Firmware sector projection (the graft):
sim_active_sector()derives the active sector read-only fromactive_groups— single source of truth, native reads O(1). - EML-backed mutable state (no host): live WRITE persists into EM BigBuf; later native reads reflect it.
- Native-auth state (no host): UL-C/UL-AES/MFC via existing firmware branches with
key in EML;
auth_stateword + firmware nonce chain carry session state. First cut fixed-nonce. - Relay state (host, L4 only): dynamic ISO-DEP crypto; Python owns per-session
nonce/IV/auth in
handle_frame. A challenge-prefix entry may beSIM_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-tripsRESP_4BIT/group fields at exact byte offsets (99, 107-118),sizeof==120. - UNIT:
compile_ntag_i2cemits 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.Cmddrift fixed (SIM_TABLE_* == 0x0950/1/2, relay ids); sim_session constants match; FLAG_SIM_TRACE bit confirmed against firmware. - UNIT:
_relay_loopparsesCMD_SIM_RELAY_EVENT{L4}, callshandle_frame, replies with correctresp_flags; RESELECT triggerspower_on. - FIRMWARE UNIT (host build of
sim_table.c): group gating + activate/deactivate transitions E1→E3;response_len==0path;RESP_4BITpath;sim_active_sector()maps bits →{0,1,3}. - FIRMWARE UNIT: worst-case
sim_table_lookuplatency 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 00path. - 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 (
0xAFchaining) 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_executesignature change; full pytest suite green.
Sequencing
L3 track (ship first, independently testable):
- FW-1 (
sim_tablesignature +RESP_4BIT) → FW-2 (iso15693.ccall site, keeps 15693 green). - FW-3 (commit A: include, statics, buffer bump, host-poll upload).
- FW-4 (commit B: L3 table-check + sector-offset graft).
- PY-1 (id fix) → PY-2 (
compile_ntag_i2c) → PY-3 (build_eml_image+ entries) → PY-4 (start_14aupload + dispatch). - 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
FLAG_SIM_TRACEbit:sim_session.pyuses0x2000, the 14a live-trace plan doc says0x4000; confirm the firmware's actual bit before wiring the async relay. (Note: reconciled this session — firmware F1 landedFLAG_SIM_TRACE 0x2000; the doc's0x4000is stale. Verify againstpm3_cmd.h.)- Multi-sector EML layout: confirm the EM BigBuf
CARD_MEMORYfits sector0+1+3 (~2KB + config) and thatCMD_HF_MIFARE_EML_MEMSETwrites at the offset the sector graft expects; else add a dedicatedCMD_HF_ISO14443A_EML_SETMEM(fallback id0x033A). - 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.
- 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).
- 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. - 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. - 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.
- Apply hooks to
SimulateIso14443aTagAID(:4716)? Deferred;TagExis the primaryFLAG_SIM_TRACEpath.
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.