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:
487
docs/plans/2026-03-17-stateful-table-firmware-design.md
Normal file
487
docs/plans/2026-03-17-stateful-table-firmware-design.md
Normal 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
|
||||
Reference in New Issue
Block a user