From 518b924b01b932ca49135c9f2ea61b24153e6545 Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 17 Mar 2026 16:50:11 -0700 Subject: [PATCH] docs: stateful response table firmware design Covers: sim_table entry structure with EML actions and group-based state machine, AES-CMAC for TAM/MAM, native handler access control, 14443-A integration, timing budget analysis. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...26-03-17-stateful-table-firmware-design.md | 487 ++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100644 docs/plans/2026-03-17-stateful-table-firmware-design.md diff --git a/docs/plans/2026-03-17-stateful-table-firmware-design.md b/docs/plans/2026-03-17-stateful-table-firmware-design.md new file mode 100644 index 0000000..839f5d2 --- /dev/null +++ b/docs/plans/2026-03-17-stateful-table-firmware-design.md @@ -0,0 +1,487 @@ +# Stateful Response Table — Firmware Patch Design + +## Problem + +The PM3 firmware's 15693 sim handler only covers standard ISO commands (inventory, read, write, system info). NXP custom commands (READ/WRITE_CONFIG, GET_RANDOM, SET_PASSWORD, TAM/MAM) are unhandled. The USB relay approach fails at 311µs FDT. We need firmware to handle the full command set at wire speed. + +For 14443-A, Layer 3 (86µs FDT) requires pre-computed table responses. Layer 4 (ISO-DEP) has WTX, so the existing relay path works there. + +## Architecture + +Two mechanisms work together: + +**1. Firmware native handler** — standard 15693 commands with access control: +- Inventory (with random UID privacy flag) +- Read/Write Single/Multiple Block (with per-block lock/auth checks) +- Get System Info, Stay Quiet, Reset to Ready, Lock Block +- Get Multiple Block Security + +**2. Stateful response table** (`sim_table.c`) — everything else: +- Vendor-specific custom commands (NXP, TI, ST, etc.) +- Auth state transitions (group activate/deactivate) +- EML read/write actions for config registers +- AES-CMAC computation for TAM/MAM authentication + +**Lookup order:** Native handler checks standard commands first (0x01-0x2F). If unrecognized, table lookup runs. No overlap. + +For 14443-A: table handles Layer 3 (anticollision, SELECT, RATS). WTX relay handles Layer 4 (dynamic APDUs). Table can also compute AES-CMAC inline for speed when WTX isn't needed. + +--- + +## Table Entry Structure + +```c +#define SIM_TABLE_MAX_MATCH 32 +#define SIM_TABLE_MAX_RESPONSE 64 +#define SIM_TABLE_MAX_ENTRIES 300 + +typedef struct { + // ---- Match ---- + uint8_t match[SIM_TABLE_MAX_MATCH]; + uint8_t match_len; + uint8_t match_mode; // 0=exact, 1=prefix + + // ---- Response ---- + uint8_t response[SIM_TABLE_MAX_RESPONSE]; + uint8_t response_len; + uint8_t response_flags; // bit 0: append CRC + + // ---- EML Actions ---- + uint8_t eml_action; // 0=none, 1=read into response, 2=write from cmd, + // 3=AES-CMAC (key from EML, challenge from cmd) + uint16_t eml_offset; // EML byte offset for read/write/key + uint8_t eml_len; // bytes to read/write + uint8_t eml_resp_insert; // position in response to insert EML data (action 1) + // or MAC output (action 3) + uint8_t cmd_data_offset; // position in command for write data (action 2) + // or challenge data (action 3) + uint8_t cmd_data_len; // length of challenge for AES-CMAC (action 3) + + // ---- State ---- + uint8_t group; // group this entry belongs to (0 = always active) + uint32_t activate_groups; // bitmask: groups to activate on match + uint32_t deactivate_groups; // bitmask: groups to deactivate on match + uint8_t set_auth; // auth_state bits to SET on match + uint8_t clear_auth; // auth_state bits to CLEAR on match + + // ---- Flags ---- + uint8_t flags; // bit 0: consume after use (one-shot entry) + + uint8_t _pad[1]; // alignment +} PACKED sim_table_entry_t; // ~128 bytes per entry +``` + +~300 entries fit in 40KB BigBuf. At 128 bytes each, 300 entries = 38.4KB. + +### Entry sizing rationale + +- `match[32]`: longest 15693 command is addressed + UID(8) + data. 14443-A SELECT is 9 bytes. 32 is plenty. +- `response[64]`: longest response is Read Multiple Block (up to 64 blocks × 4 bytes = too big). For large reads, firmware native handler serves from EML. Table handles custom commands with shorter responses. 64 bytes covers all NXP custom responses. + +--- + +## Table Runtime State + +```c +typedef struct { + sim_table_entry_t *entries; // pointer into BigBuf + uint16_t count; // number of active entries + uint32_t active_groups; // bitmask: which groups are currently active +} sim_table_state_t; +``` + +Stored as a global in `sim_table.c`. Initialized at table upload, persists for duration of sim. + +--- + +## Lookup Algorithm + +```c +sim_table_entry_t *sim_table_lookup(const uint8_t *cmd, uint8_t cmd_len) { + for (uint16_t i = 0; i < state.count; i++) { + sim_table_entry_t *e = &state.entries[i]; + + // Skip if entry's group is not active (group 0 always active) + if (e->group != 0 && !(state.active_groups & (1 << e->group))) { + continue; + } + + // Match + if (e->match_mode == 0) { // exact + if (cmd_len != e->match_len) continue; + if (memcmp(cmd, e->match, e->match_len) != 0) continue; + } else { // prefix + if (cmd_len < e->match_len) continue; + if (memcmp(cmd, e->match, e->match_len) != 0) continue; + } + + // Match found — execute state changes + state.active_groups |= e->activate_groups; + state.active_groups &= ~e->deactivate_groups; + + // Auth state changes propagated to iso15_tag_t + if (e->set_auth) tag->auth_state |= e->set_auth; + if (e->clear_auth) tag->auth_state &= ~e->clear_auth; + + // Consume if one-shot + if (e->flags & 0x01) { + e->match_len = 0; // mark as empty + } + + return e; + } + return NULL; +} +``` + +First match wins. Python orders entries: exact matches before prefix matches, specific before general. Correct password entries before error fallback entries. + +--- + +## Action Executor + +After lookup returns an entry, the sim handler executes the action: + +```c +void sim_table_execute(sim_table_entry_t *e, const uint8_t *cmd, uint8_t cmd_len, + uint8_t *resp_buf, uint16_t *resp_len) { + // Start with static response + memcpy(resp_buf, e->response, e->response_len); + *resp_len = e->response_len; + + switch (e->eml_action) { + case 0: // none — static response only + break; + + case 1: // RESPOND_EML — read from EML into response + memcpy(resp_buf + e->eml_resp_insert, + tag->data + e->eml_offset, e->eml_len); + if (e->eml_resp_insert + e->eml_len > *resp_len) + *resp_len = e->eml_resp_insert + e->eml_len; + break; + + case 2: // WRITE_EML — write command data to EML + if (e->cmd_data_offset + e->eml_len <= cmd_len) { + memcpy(tag->data + e->eml_offset, + cmd + e->cmd_data_offset, e->eml_len); + } + break; + + case 3: { // AES_CMAC — compute MAC + uint8_t key[16]; + memcpy(key, tag->data + e->eml_offset, 16); + uint8_t mac[16]; + ulaes_cmac(key, 16, + cmd + e->cmd_data_offset, e->cmd_data_len, + mac); + memcpy(resp_buf + e->eml_resp_insert, mac, e->eml_len); + if (e->eml_resp_insert + e->eml_len > *resp_len) + *resp_len = e->eml_resp_insert + e->eml_len; + break; + } + } + + // Append CRC if flagged + if (e->response_flags & 0x01) { + // CRC-16 appended by caller (Iso15693AddCrc / AddCrc14A) + } +} +``` + +--- + +## iso15_tag_t Extensions + +```c +typedef struct { + // ... existing fields ... + uint8_t uid[8]; + uint8_t dsfid; + bool dsfidLock; + uint8_t afi; + bool afiLock; + uint8_t bytesPerPage; + uint8_t pagesCount; + uint8_t ic; + uint8_t locks[ISO15693_TAG_MAX_PAGES]; + uint8_t data[ISO15693_TAG_MAX_SIZE]; + uint8_t random[2]; + uint8_t privacyPasswd[4]; + enum { ... } state; + bool expectFast; + bool expectFsk; + + // Existing relay fields + bool relay_all; + uint8_t relay_cmd[64]; + // ... + bool trace_enabled; + + // ---- NEW FIELDS ---- + uint8_t random_uid; // if set, inventory returns random UID + uint8_t auth_state; // bitmask: which passwords are authenticated + // bit 0: read, bit 1: write, bit 2: privacy, bit 3: destroy + uint8_t lock_mode[ISO15693_TAG_MAX_PAGES]; + // per-block access control: + // 0x00 = open (no restriction) + // 0x01 = locked (permanent, error always) + // 0x02 = read needs auth (check auth_state bit 0) + // 0x04 = write needs auth (check auth_state bit 1) +} PACKED iso15_tag_t; +``` + +Python uploads `lock_mode[]` to EML during sim setup, based on the tag's protection pointer and password configuration. + +--- + +## Native Handler Modifications (iso15693.c) + +### Read Single Block — add access check + +```c +case ISO15693_READ_SINGLE_BLOCK: { + uint8_t block = cmd[cmd_offset]; + uint8_t lm = tag->lock_mode[block]; + + if (lm == 0x01) { + // Permanently locked — error + resp[0] = ISO15693_RESP_ERROR; + resp[1] = 0x13; // block locked + recvLen = 2; + break; + } + if ((lm & 0x02) && !(tag->auth_state & 0x01)) { + // Read needs auth, not authenticated + resp[0] = ISO15693_RESP_ERROR; + resp[1] = 0x0F; + recvLen = 2; + break; + } + // ... existing read logic unchanged ... +} +``` + +### Write Single Block — add access check + +Same pattern, checking `lm & 0x04` against `auth_state & 0x02`. + +### Inventory — random UID + +```c +case ISO15693_INVENTORY: { + if (tag->random_uid) { + uint8_t fake_uid[8]; + for (int i = 0; i < 8; i++) fake_uid[i] = prand() & 0xFF; + // Use fake_uid in inventory response instead of tag->uid + } + // ... existing inventory logic ... +} +``` + +### Custom command fallback — table lookup + +```c +default: { + // Unknown command — try table lookup + sim_table_entry_t *entry = sim_table_lookup(cmd, cmd_len); + if (entry) { + sim_table_execute(entry, cmd, cmd_len, recv, &recvLen); + break; + } + // No table entry — no response (tag stays silent) + recvLen = 0; + break; +} +``` + +--- + +## 14443-A Integration + +In `SimulateIso14443aTag()`, insert before existing command processing: + +```c +// Table lookup for Layer 3 +if (sim_table_count() > 0) { + sim_table_entry_t *entry = sim_table_lookup(receivedCmd, len); + if (entry) { + sim_table_execute(entry, receivedCmd, len, response, &respLen); + if (entry->response_flags & 0x01) { + AddCrc14A(response, respLen); + respLen += 2; + } + EmSendCmd(response, respLen); + if (entry->flags & 0x01) entry->match_len = 0; + continue; + } + + // Layer 4 table miss — WTX relay (existing code) + if (iso_dep_active) { + // ... existing WTX relay code unchanged ... + } +} +``` + +--- + +## Command IDs + +0x0900 range is taken by SAM commands. Use 0x0950: + +```c +#define CMD_SIM_TABLE_UPLOAD 0x0950 // Host→FW: upload table (chunked) +#define CMD_SIM_TABLE_CLEAR 0x0951 // Host→FW: clear table +#define CMD_SIM_TABLE_UPDATE 0x0952 // Host→FW: add/modify single entry +``` + +Existing 15693 relay/trace commands remain: +```c +#define CMD_HF_ISO15693_SIM_RELAY 0x0334 // (existing) +#define CMD_HF_ISO15693_SIM_RELAY_RESP 0x0335 // (existing) +#define CMD_HF_ISO15693_SIM_TRACE 0x0336 // (existing) +``` + +--- + +## Shared Crypto Module + +`armsrc/sim_crypto.c/h` wraps the existing `ulaes_cmac()`: + +```c +// sim_crypto.h +#ifndef __SIM_CRYPTO_H +#define __SIM_CRYPTO_H +#include "common.h" + +// AES-CMAC: compute MAC over msg using key, output to mac_out +// Returns true on success +bool sim_aes_cmac(const uint8_t key[16], const uint8_t *msg, + uint8_t msg_len, uint8_t *mac_out, uint8_t mac_len); +#endif + +// sim_crypto.c +#include "sim_crypto.h" +#include "cmac_calc.h" + +bool sim_aes_cmac(const uint8_t key[16], const uint8_t *msg, + uint8_t msg_len, uint8_t *mac_out, uint8_t mac_len) { + uint8_t full_mac[16]; + ulaes_cmac(key, 16, msg, msg_len, full_mac); + memcpy(mac_out, full_mac, MIN(mac_len, 16)); + return true; +} +``` + +Used by `sim_table_execute()` for action 3 (AES-CMAC). Also callable directly from native handlers or the 14443-A path. + +--- + +## API (sim_table.h) + +```c +#ifndef __SIM_TABLE_H +#define __SIM_TABLE_H + +#include "common.h" +#include "iso15.h" + +// Entry structure (see above) +typedef struct { ... } sim_table_entry_t; + +// Initialize table in BigBuf, set initial active groups +void sim_table_init(uint32_t initial_groups); + +// Clear all entries +void sim_table_clear(void); + +// Add entry from serialized data +bool sim_table_add(const uint8_t *data, uint16_t len); + +// Current entry count +uint16_t sim_table_count(void); + +// Lookup: find first matching entry for command +sim_table_entry_t *sim_table_lookup(const uint8_t *cmd, uint8_t cmd_len); + +// Execute: build response from matched entry +void sim_table_execute(sim_table_entry_t *e, const uint8_t *cmd, uint8_t cmd_len, + uint8_t *resp_buf, uint16_t *resp_len, + iso15_tag_t *tag); + +#endif +``` + +--- + +## Python Side (TableCompiler changes) + +The existing `TableCompiler` in `pm3py/sim/table_compiler.py` needs to emit entries in the new format. Key changes: + +- `TableEntry` dataclass gains: `eml_action`, `eml_offset`, `eml_len`, `eml_resp_insert`, `cmd_data_offset`, `cmd_data_len`, `group`, `activate_groups`, `deactivate_groups`, `set_auth`, `clear_auth`, `flags` +- `serialize()` packs into 128-byte firmware format +- New compiler methods for NXP custom commands: + - `compile_nxp_icode(tag)` — config registers, GET_RANDOM, SET_PASSWORD entries with groups + - `compile_slix2(tag)` — privacy mode groups, protection pointer entries + - `compile_tam_mam(tag)` — TAM/MAM entries with AES-CMAC action + +SimSession uploads the initial `active_groups` mask as part of the table upload command. + +--- + +## Files Changed Summary + +| File | Change | Lines (est) | +|------|--------|-------------| +| `armsrc/sim_table.h` | **New**: entry struct, API declarations | ~50 | +| `armsrc/sim_table.c` | **New**: init, clear, add, lookup, execute | ~200 | +| `armsrc/sim_crypto.h` | **New**: AES-CMAC wrapper declaration | ~15 | +| `armsrc/sim_crypto.c` | **New**: AES-CMAC wrapper over ulaes_cmac | ~20 | +| `include/pm3_cmd.h` | 3 new CMD_SIM_TABLE_* IDs | ~5 | +| `include/iso15.h` | Extend iso15_tag_t: random_uid, auth_state, lock_mode[] | ~10 | +| `armsrc/iso15693.c` | Lock/auth checks in read/write, random UID, table fallback in default case | ~60 | +| `armsrc/iso14443a.c` | Table lookup before existing command processing | ~30 | +| `armsrc/appmain.c` | Dispatch for CMD_SIM_TABLE_* | ~20 | +| `armsrc/Makefile` | Add sim_table.o, sim_crypto.o | ~2 | +| **Total** | | **~410 lines** | + +--- + +## Timing Budget (15693, 311µs FDT) + +| Operation | Time | +|-----------|------| +| Receive + decode command | ~20µs | +| Table lookup (50 entries, linear scan) | ~2µs | +| Execute static response (memcpy) | ~1µs | +| Execute EML read/write | ~1µs | +| Execute AES-CMAC (2 blocks) | ~100-150µs | +| Encode + begin transmission | ~20µs | +| **Total (worst case, AES-CMAC)** | **~195µs** | +| **Total (typical, static response)** | **~45µs** | + +Both well under 311µs. + +--- + +## Example: SLIX2 with Privacy + Password Protection + +Python compiles a SLIX2 tag with privacy password `0xDEADBEEF`, read password `0x12345678`, protection pointer at block 40: + +**Groups:** +- 0: always active (GET_RANDOM, SET_PASSWORD error fallbacks) +- 1: normal operation (standard commands via native handler — controlled by lock_mode) +- 2: activated after read password auth + +**Table entries (abbreviated):** +1. GET_RANDOM → `[0x00, R1, R2]` (pre-picked random, group 0) +2. SET_PASSWORD pwd=privacy, correct XOR → `[0x00]`, activate group 1, set auth bit 2 (group 0) +3. SET_PASSWORD pwd=read, correct XOR → `[0x00]`, set auth bit 0 (group 1) +4. SET_PASSWORD prefix match (wrong password) → `[0x01, 0x0F]` error (group 0) +5. READ_CONFIG reg 0 → RESPOND_EML from config offset (group 1) +6. WRITE_CONFIG reg 0 → WRITE_EML to config offset (group 1) +7. ... (more config regs, more commands) + +**Initial state:** +- `active_groups = 0x01` (group 0 only — privacy mode ON) +- `lock_mode[40..79] = 0x02` (read needs auth) +- AES key at EML offset for TAM/MAM