# 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