# 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 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 `: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.