- 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>
7.0 KiB
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
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-sectorREAD/FAST_READ, plus vendor reads (ST25TN system block / product ID). Today an unknown L3 command falls throughSimulateIso14443aTagEx's finalelseand 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
- 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.
- Reuse over reinvention. The firmware is a fork maintained with atomic
single-file commits for easy rebase against upstream PM3. The 15693
sim_tableserver, the 120-byteTableEntrywire format, theTableCompiler, and the0x0950-0x0952upload command IDs already exist and are fork-local. - 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:
- Host-poll at loop top —
data_available()/receive_ng()handlingCMD_SIM_TABLE_UPLOAD/UPDATE/CLEAR, EML load, relay responses (mirrorsiso15693.c:2472-2508). Prerequisite: without it the table is empty forever. - 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 00switch so the table overrides those fakes. On an L4 I-block miss with ISO-DEP active, falls into the WTX relay. - Sector-offset graft on native
READ/FAST_READ— addsim_active_sector()*0x400to theemlGetsource 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 2and 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_tableentries 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 bytes 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 transientGRP_SEL_PENDINGgroup and 4-bit-ACKs. - Phase 2 (
s 00 00 00) matches only insideGRP_SEL_PENDING(so a stray00/01frame can't false-flip the sector), setsGRP_SECTOR_s, clears the pending group, and responds withresponse_len == 0= the passive-ACK silence. - An invalid-sector catch-all in
GRP_SEL_PENDINGreturns 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.