- 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>
118 lines
7.0 KiB
Markdown
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.
|