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

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-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.