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

16 KiB
Raw Permalink Blame History

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:

  • 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