Files
pm3py/docs/plans/2026-03-17-stateful-table-firmware-design.md
michael 518b924b01 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>
2026-03-17 16:50:11 -07:00

488 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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