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>
16 KiB
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
#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
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
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:
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
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
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
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
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:
// 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:
#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:
#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():
// 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)
#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:
TableEntrydataclass 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,flagsserialize()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 groupscompile_slix2(tag)— privacy mode groups, protection pointer entriescompile_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):
- GET_RANDOM →
[0x00, R1, R2](pre-picked random, group 0) - SET_PASSWORD pwd=privacy, correct XOR →
[0x00], activate group 1, set auth bit 2 (group 0) - SET_PASSWORD pwd=read, correct XOR →
[0x00], set auth bit 0 (group 1) - SET_PASSWORD prefix match (wrong password) →
[0x01, 0x0F]error (group 0) - READ_CONFIG reg 0 → RESPOND_EML from config offset (group 1)
- WRITE_CONFIG reg 0 → WRITE_EML to config offset (group 1)
- ... (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