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) <noreply@anthropic.com>
This commit is contained in:
michael
2026-03-17 16:50:11 -07:00
parent f9dc17cf98
commit 518b924b01

View File

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