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

118 lines
7.0 KiB
Markdown

# 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](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.