- Add table compiler match pattern rules to main CLAUDE.md (was only in worktree) - Update sim framework description (750+ tests, add DNA/NTAG5 to IC list) - Update "planned" → "in progress" for Python-driven card sim - Fix firmware patch size: ~320 lines (was inconsistently ~340 on line 49)
634 lines
26 KiB
Markdown
634 lines
26 KiB
Markdown
# Python-Driven Card Simulation for Proxmark3
|
|
|
|
## For Future Claude Instances
|
|
|
|
This document describes how to add Python-controlled card simulation to pm3py via a minimal PM3 firmware patch. No new hardware — works on PM3 Easy and RDV4 as-is. The design was developed through analysis of ISO timing constraints and the existing sim framework in the `feature/sim-framework` worktree (257 tests, 11 phases complete).
|
|
|
|
---
|
|
|
|
## Problem
|
|
|
|
pm3py's card simulation is fire-and-forget: Python sends static parameters (UID, ATQA, SAK), firmware runs autonomously, Python gets a status back. No way for Python to handle dynamic challenge-response (DESFire MUA, JCOP/EMV crypto, NTAG 5 Boost mutual auth, etc.).
|
|
|
|
## Solution: Two Mechanisms
|
|
|
|
### 1. Response Table (Layer 3 — 86µs FDT constraint)
|
|
|
|
Python pre-computes a table of `{command_pattern → response}` entries and uploads it to firmware's BigBuf (~40KB) before sim starts. Firmware does a linear scan lookup (~1-2µs on 48MHz ARM7) and responds at wire speed. Handles:
|
|
|
|
- Anticollision (REQA→ATQA, SELECT→UID/SAK)
|
|
- RATS→ATS
|
|
- Static APDUs (READ RECORD, SELECT AID, GET DATA)
|
|
- Pre-computed MIFARE Classic auth (tag chooses nonce, pre-computes full exchange)
|
|
- Any command where the response is known ahead of time
|
|
|
|
**Sizing**: NTAG 5 Link ~5KB, NTAG 5 Boost ~13KB, MIFARE Classic 1K ~8KB, JCOP/EMV static APDUs ~3.5KB. All fit in BigBuf.
|
|
|
|
### 2. WTX Relay (Layer 4 — seconds available)
|
|
|
|
ISO 14443-4 (ISO-DEP) has S(WTX) — the card requests more time and the reader **must** wait. On a Layer 4 table miss:
|
|
|
|
1. Firmware sends S(WTX) to reader (spec-compliant, readers expect this)
|
|
2. Firmware relays the APDU to Python over USB (~1ms)
|
|
3. Python computes crypto response using transponder model (~1-5ms)
|
|
4. Python sends response back to firmware (~1ms)
|
|
5. Firmware sends response to reader. Total: ~3-7ms (well within WTX limits)
|
|
|
|
Handles: DESFire AES auth, JCOP GENERATE AC, EMV INTERNAL AUTHENTICATE, any dynamic Layer 4 APDU.
|
|
|
|
### 3. 15693 Retry Relay
|
|
|
|
ISO 15693 FDT is 311µs — too tight for USB but readers retry 2-3x per spec. On a table miss, firmware doesn't respond (reader times out), relays to Python, caches response, serves it on retry. Already spec'd in `FIRMWARE_PATCH_15693_RELAY.md` in the worktree.
|
|
|
|
---
|
|
|
|
## Firmware Patch Specification
|
|
|
|
### Overview
|
|
|
|
~320 lines of C across 6 files. All changes are additive — no existing behavior is modified. Existing sim commands continue to work exactly as before; the table/relay only activates when a table has been uploaded.
|
|
|
|
### New Command IDs
|
|
|
|
Add to `include/pm3_cmd.h`:
|
|
|
|
```c
|
|
// Response table management
|
|
#define CMD_SIM_TABLE_UPLOAD 0x0900 // Host→FW: upload response table (NG frame)
|
|
#define CMD_SIM_TABLE_CLEAR 0x0901 // Host→FW: clear table
|
|
#define CMD_SIM_TABLE_UPDATE 0x0902 // Host→FW: add/modify single entry
|
|
|
|
// Generic relay (14443-A Layer 4 WTX relay)
|
|
#define CMD_SIM_RELAY_EVENT 0x0910 // FW→Host: table miss, needs response
|
|
#define CMD_SIM_RELAY_RESP 0x0911 // Host→FW: response for relay event
|
|
|
|
// 15693-specific relay (retry-based)
|
|
#define CMD_HF_ISO15693_SIM_RELAY 0x0334 // FW→Host: unknown 15693 cmd received
|
|
#define CMD_HF_ISO15693_SIM_RELAY_RESP 0x0335 // Host→FW: response for 15693 relay
|
|
```
|
|
|
|
These IDs are in unused ranges — 0x0900-0x09FF is currently unallocated, and 0x0334-0x0335 sit between existing 15693 commands.
|
|
|
|
### Response Table Structure
|
|
|
|
New file `armsrc/sim_table.c` + `armsrc/sim_table.h`:
|
|
|
|
```c
|
|
#define SIM_TABLE_MAX_MATCH 32
|
|
#define SIM_TABLE_MAX_RESPONSE 64
|
|
|
|
typedef struct {
|
|
uint8_t match[SIM_TABLE_MAX_MATCH]; // command bytes to match
|
|
uint8_t match_len; // how many bytes to compare
|
|
uint8_t match_mode; // 0=exact, 1=prefix, 2=masked
|
|
uint8_t match_mask[SIM_TABLE_MAX_MATCH]; // for masked mode
|
|
uint8_t response[SIM_TABLE_MAX_RESPONSE]; // response bytes
|
|
uint8_t response_len;
|
|
uint8_t flags; // bit 0: append CRC
|
|
// bit 1: stateful (consume after use)
|
|
uint8_t _pad[2]; // alignment
|
|
} sim_table_entry_t; // 132 bytes per entry
|
|
|
|
// API
|
|
void sim_table_init(void); // point table at BigBuf
|
|
void sim_table_clear(void); // zero all entries
|
|
uint16_t sim_table_count(void); // current entry count
|
|
bool sim_table_add(const uint8_t *data, uint16_t len); // deserialize + add entry
|
|
sim_table_entry_t *sim_table_lookup(const uint8_t *cmd, uint8_t cmd_len); // find match
|
|
|
|
// BigBuf layout during sim:
|
|
// [0 .. sim_table_count * 132] = table entries
|
|
// Remaining BigBuf = available for other sim buffers
|
|
```
|
|
|
|
~300 entries fit in 40KB BigBuf. Lookup is linear scan — at 50 entries, ~1µs on ARM7. For larger tables, entries are ordered by frequency (most common commands first).
|
|
|
|
### Modified ISO 14443-A Sim Handler
|
|
|
|
In `armsrc/iso14443a.c`, function `SimulateIso14443aTag()`. Insert at the top of the command processing loop, **before** the existing switch statement:
|
|
|
|
```c
|
|
// --- BEGIN PATCH: Response table + WTX relay ---
|
|
|
|
// Check response table (works for both Layer 3 and Layer 4)
|
|
if (sim_table_count() > 0) {
|
|
sim_table_entry_t *entry = sim_table_lookup(receivedCmd, len);
|
|
if (entry) {
|
|
if (entry->flags & 0x01) {
|
|
// Append CRC-A
|
|
AddCrc14A(entry->response, entry->response_len);
|
|
EmSendCmd(entry->response, entry->response_len + 2);
|
|
} else {
|
|
EmSendCmd(entry->response, entry->response_len);
|
|
}
|
|
if (entry->flags & 0x02) {
|
|
// Stateful: remove entry after use (for one-time auth nonces)
|
|
entry->match_len = 0; // mark as empty
|
|
}
|
|
continue; // skip firmware's default handler
|
|
}
|
|
|
|
// Layer 4 (ISO-DEP) table miss → WTX relay
|
|
// Detect ISO-DEP by checking if we've completed RATS (ATS sent)
|
|
if (iso_dep_active) {
|
|
// Send S(WTX) — request waiting time extension
|
|
uint8_t wtx_req[2] = {0xF2, 0x3B}; // S(WTX), WTXM=59 (~4.8s max)
|
|
AddCrc14A(wtx_req, 2);
|
|
EmSendCmd(wtx_req, 4);
|
|
|
|
// Wait for reader's S(WTX) response (reader echoes back)
|
|
uint8_t wtx_ack[10];
|
|
int wtx_ack_len = 0;
|
|
GetIso14443aCommandFromReader(wtx_ack, &wtx_ack_len, 2000);
|
|
|
|
// Relay APDU to host
|
|
reply_ng(CMD_SIM_RELAY_EVENT, PM3_SUCCESS, receivedCmd, len);
|
|
|
|
// Wait for host response
|
|
PacketCommandNG rx;
|
|
int res = PM3_SUCCESS;
|
|
// Use existing data_available() + receive_ng() pattern
|
|
uint32_t start = GetTickCount();
|
|
while ((GetTickCount() - start) < 2000) { // 2s timeout
|
|
if (data_available()) {
|
|
res = receive_ng(&rx);
|
|
if (res == PM3_SUCCESS && rx.cmd == CMD_SIM_RELAY_RESP) {
|
|
EmSendCmd(rx.data.asBytes, rx.length);
|
|
break;
|
|
} else if (res == PM3_SUCCESS) {
|
|
// Other command (BREAK_LOOP etc) — exit sim
|
|
goto out;
|
|
}
|
|
}
|
|
WaitMS(1);
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// --- END PATCH ---
|
|
|
|
// ... existing switch(receivedCmd[0]) follows unchanged ...
|
|
```
|
|
|
|
### Modified ISO 15693 Sim Handler
|
|
|
|
In `armsrc/iso15693.c`, function `SimTagIso15693()`. Changes as already spec'd in `FIRMWARE_PATCH_15693_RELAY.md`:
|
|
|
|
1. Replace `default:` case — relay unknown commands to host, cache response
|
|
2. Add `data_available()` check in main loop — receive cached responses from host
|
|
3. Serve cached response when reader retries the same command
|
|
|
|
See the worktree doc for exact code (~38 lines).
|
|
|
|
**Note (2026-03-17):** The `15693-inventory-enhancements` branch in `/home/work/proxmark3` adds 16-slot inventory with anti-collision to `SendRawCommand15693()` (reader path). The sim handler `SimTagIso15693()` is **not modified** by those changes — our `default:` case insertion point is safe. However, the sim handler currently only responds to 1-slot inventory. If 16-slot reader support is needed in sim mode, that's a future enhancement (respond to EOF slot advancement) separate from the relay patch.
|
|
|
|
### Command Dispatch
|
|
|
|
In `armsrc/appmain.c`, add to the command switch:
|
|
|
|
```c
|
|
case CMD_SIM_TABLE_UPLOAD:
|
|
sim_table_init();
|
|
// Payload: concatenated serialized entries
|
|
for (uint16_t off = 0; off < packet->length; off += 132) {
|
|
sim_table_add(packet->data.asBytes + off, 132);
|
|
}
|
|
reply_ng(CMD_SIM_TABLE_UPLOAD, PM3_SUCCESS, NULL, 0);
|
|
break;
|
|
|
|
case CMD_SIM_TABLE_CLEAR:
|
|
sim_table_clear();
|
|
reply_ng(CMD_SIM_TABLE_CLEAR, PM3_SUCCESS, NULL, 0);
|
|
break;
|
|
|
|
case CMD_SIM_TABLE_UPDATE: {
|
|
sim_table_add(packet->data.asBytes, packet->length);
|
|
reply_ng(CMD_SIM_TABLE_UPDATE, PM3_SUCCESS, NULL, 0);
|
|
break;
|
|
}
|
|
|
|
case CMD_SIM_RELAY_RESP:
|
|
// Handled inline in sim handlers via receive_ng(), not here
|
|
break;
|
|
|
|
case CMD_HF_ISO15693_SIM_RELAY_RESP:
|
|
// Handled inline in iso15693.c sim loop
|
|
break;
|
|
```
|
|
|
|
### Firmware Files Changed
|
|
|
|
| File | Change | Lines |
|
|
|------|--------|-------|
|
|
| `include/pm3_cmd.h` | 7 new command IDs | +7 |
|
|
| `armsrc/sim_table.h` | New: table struct + API declarations | ~30 |
|
|
| `armsrc/sim_table.c` | New: init, clear, add, lookup | ~170 |
|
|
| `armsrc/iso14443a.c` | Table check + WTX relay in SimulateIso14443aTag | ~50 |
|
|
| `armsrc/iso15693.c` | Table check + retry relay in SimTagIso15693 | ~40 |
|
|
| `armsrc/appmain.c` | Dispatch for 3 new commands | ~20 |
|
|
| `armsrc/Makefile` | Add sim_table.o | +1 |
|
|
| **Total** | | **~320 lines** |
|
|
|
|
---
|
|
|
|
## Python Implementation Plan
|
|
|
|
### Phase 1: Merge Sim Framework
|
|
|
|
Merge the `feature/sim-framework` worktree into main. This provides all the transponder models (Tag14443A, MifareClassicTag, DesfireTag, Tag15693, etc.) that the table compiler needs.
|
|
|
|
**Files**: Everything under `.worktrees/sim-framework/pm3py/sim/` → `pm3py/sim/`
|
|
**Tests**: Everything under `.worktrees/sim-framework/tests/test_sim_*.py` → `tests/`
|
|
**Verify**: `python -m pytest tests/ -v` — all 257+ tests pass
|
|
|
|
### Phase 2: Protocol Constants
|
|
|
|
Add new command IDs to `pm3py/protocol.py`:
|
|
|
|
```python
|
|
# In Cmd enum:
|
|
SIM_TABLE_UPLOAD = 0x0900
|
|
SIM_TABLE_CLEAR = 0x0901
|
|
SIM_TABLE_UPDATE = 0x0902
|
|
SIM_RELAY_EVENT = 0x0910
|
|
SIM_RELAY_RESP = 0x0911
|
|
HF_ISO15693_SIM_RELAY = 0x0334
|
|
HF_ISO15693_SIM_RELAY_RESP = 0x0335
|
|
```
|
|
|
|
### Phase 3: Table Compiler
|
|
|
|
New file `pm3py/sim/table_compiler.py`:
|
|
|
|
```python
|
|
@dataclass
|
|
class TableEntry:
|
|
match: bytes
|
|
match_mode: int # 0=exact, 1=prefix, 2=masked
|
|
match_mask: bytes
|
|
response: bytes
|
|
flags: int # bit 0: CRC, bit 1: stateful
|
|
|
|
def serialize(self) -> bytes:
|
|
"""Pack into 132-byte firmware format."""
|
|
buf = bytearray(132)
|
|
buf[0:len(self.match)] = self.match
|
|
buf[32] = len(self.match)
|
|
buf[33] = self.match_mode
|
|
buf[34:34+len(self.match_mask)] = self.match_mask
|
|
buf[66:66+len(self.response)] = self.response
|
|
buf[130] = len(self.response)
|
|
buf[131] = self.flags
|
|
return bytes(buf)
|
|
|
|
class ResponseTable:
|
|
entries: list[TableEntry]
|
|
|
|
def serialize(self) -> bytes:
|
|
"""Concatenate all entries for CMD_SIM_TABLE_UPLOAD."""
|
|
return b"".join(e.serialize() for e in self.entries)
|
|
|
|
def overlay(self, other: ResponseTable) -> None:
|
|
"""Merge another table — other's entries win on match conflict."""
|
|
|
|
@classmethod
|
|
def from_trace(cls, trace: list[TraceEntry]) -> ResponseTable:
|
|
"""Extract card responses from sniffed trace as table entries."""
|
|
entries = []
|
|
reader_cmds = [e for e in trace if e.direction == "reader"]
|
|
tag_resps = [e for e in trace if e.direction == "tag"]
|
|
for cmd, resp in zip(reader_cmds, tag_resps):
|
|
entries.append(TableEntry(
|
|
match=cmd.frame.data, match_mode=0, match_mask=b"",
|
|
response=resp.frame.data, flags=0
|
|
))
|
|
return cls(entries=entries)
|
|
|
|
class TableCompiler:
|
|
@staticmethod
|
|
def compile_14a(tag: Tag14443A) -> ResponseTable:
|
|
"""Walk tag state machine, enumerate all cmd→resp pairs."""
|
|
table = ResponseTable(entries=[])
|
|
# Anticollision
|
|
table.entries.append(TableEntry(b"\x26", 0, b"", tag.atqa, 0)) # REQA
|
|
table.entries.append(TableEntry(b"\x52", 0, b"", tag.atqa, 0)) # WUPA
|
|
# SELECT cascade levels
|
|
for cl, uid_chunk in tag._cascade_entries():
|
|
table.entries.append(TableEntry(
|
|
bytes([cl, NVB_ANTICOL]), 1, b"", # prefix match
|
|
uid_chunk + bytes([_compute_bcc(uid_chunk)]), 0
|
|
))
|
|
# Full SELECT → SAK
|
|
table.entries.append(TableEntry(
|
|
bytes([cl, NVB_SELECT]), 1, b"",
|
|
bytes([tag.sak]), 0x01 # append CRC
|
|
))
|
|
# RATS → ATS (if Part 4)
|
|
if hasattr(tag, 'ats') and tag.ats:
|
|
table.entries.append(TableEntry(b"\xe0", 1, b"", tag.ats, 0x01))
|
|
# Application-specific (delegate to tag model)
|
|
if hasattr(tag, 'enumerate_responses'):
|
|
for cmd, resp, flags in tag.enumerate_responses():
|
|
table.entries.append(TableEntry(cmd, 0, b"", resp, flags))
|
|
return table
|
|
|
|
@staticmethod
|
|
def compile_15693(tag: Tag15693) -> ResponseTable:
|
|
"""Compile 15693 tag responses — inventory, read/write blocks, sys info."""
|
|
# Similar pattern: walk the tag's known command set
|
|
|
|
@staticmethod
|
|
def compile_mifare(tag: MifareClassicTag) -> ResponseTable:
|
|
"""Compile MIFARE Classic with pre-computed auth sequences."""
|
|
# For each sector+key: pre-pick nT, compute full auth exchange
|
|
|
|
@staticmethod
|
|
def compile_desfire(tag: DesfireTag) -> ResponseTable:
|
|
"""Compile DESFire static APDUs. Auth goes through WTX relay."""
|
|
# Static: SELECT, GetVersion, ReadData, GetFileSettings
|
|
# Auth: NOT tabled — handled by WTX relay + Python crypto
|
|
```
|
|
|
|
### Phase 4: Sim Session
|
|
|
|
New file `pm3py/sim/sim_session.py`:
|
|
|
|
```python
|
|
class SimSession:
|
|
"""Manages an active card simulation with table + relay."""
|
|
|
|
def __init__(self, transport: PM3Transport):
|
|
self._t = transport
|
|
self._active = False
|
|
self._relay_task = None
|
|
|
|
async def upload_table(self, table: ResponseTable) -> None:
|
|
"""Upload response table to firmware BigBuf."""
|
|
data = table.serialize()
|
|
# May need chunking if table > max NG payload (512 bytes)
|
|
for i in range(0, len(data), 512):
|
|
chunk = data[i:i+512]
|
|
cmd = Cmd.SIM_TABLE_UPLOAD if i == 0 else Cmd.SIM_TABLE_UPDATE
|
|
await self._t.send_ng(cmd, chunk)
|
|
|
|
async def start_14a(self, tag: Tag14443A) -> None:
|
|
"""Compile table, upload, start 14443-A sim with WTX relay loop."""
|
|
table = TableCompiler.compile_14a(tag)
|
|
await self.upload_table(table)
|
|
# Start sim (existing command, unchanged)
|
|
self._active = True
|
|
self._relay_task = asyncio.create_task(self._relay_loop(tag))
|
|
await self._t.send_mix(Cmd.HF_ISO14443A_SIMULATE, ...)
|
|
|
|
async def start_15693(self, tag: Tag15693) -> None:
|
|
"""Start 15693 sim with table + relay."""
|
|
table = TableCompiler.compile_15693(tag)
|
|
await self.upload_table(table)
|
|
self._active = True
|
|
self._relay_task = asyncio.create_task(self._relay_loop(tag))
|
|
await self._t.send_ng(Cmd.HF_ISO15693_SIMULATE, ...)
|
|
|
|
async def _relay_loop(self, tag: Transponder) -> None:
|
|
"""Background: listen for relay events, compute responses."""
|
|
while self._active:
|
|
try:
|
|
resp = await self._t.read_response(timeout=0.1)
|
|
except TimeoutError:
|
|
continue
|
|
if resp is None:
|
|
continue
|
|
if resp.cmd in (Cmd.SIM_RELAY_EVENT, Cmd.HF_ISO15693_SIM_RELAY):
|
|
frame = RFFrame.from_bytes(resp.data)
|
|
tag_resp = await tag.handle_frame(frame)
|
|
if tag_resp:
|
|
reply_cmd = (Cmd.SIM_RELAY_RESP
|
|
if resp.cmd == Cmd.SIM_RELAY_EVENT
|
|
else Cmd.HF_ISO15693_SIM_RELAY_RESP)
|
|
await self._t.send_ng(reply_cmd, tag_resp.data)
|
|
|
|
async def stop(self) -> None:
|
|
self._active = False
|
|
await self._t.send_ng_no_response(Cmd.BREAK_LOOP)
|
|
if self._relay_task:
|
|
await self._relay_task
|
|
```
|
|
|
|
### Phase 5: Sniff-to-Sim Pipeline
|
|
|
|
Extend `ResponseTable.from_trace()` and add convenience methods:
|
|
|
|
```python
|
|
# One-liner: sniff a card, then sim it
|
|
async def clone_and_sim(pm3, timeout=10):
|
|
"""Sniff a real card transaction, build table, sim it."""
|
|
trace = await pm3.hf.a14.sniff(timeout=timeout)
|
|
table = ResponseTable.from_trace(trace)
|
|
# Extract UID/ATQA/SAK from anticollision in trace
|
|
uid, atqa, sak = extract_card_identity(trace)
|
|
session = SimSession(pm3._t)
|
|
await session.start_14a_with_table(table, uid, atqa, sak)
|
|
return session
|
|
```
|
|
|
|
### Phase 6: Advanced Crypto (DESFire, JCOP, EMV)
|
|
|
|
The WTX relay means Python handles crypto in real-time for Layer 4 commands. The transponder models in the sim framework already implement the protocol logic — they just need the `cryptography` library for real key operations:
|
|
|
|
- `DesfireTag.handle_frame()` already handles AuthenticateAES challenge-response
|
|
- Add real AES-CMAC computation (currently stubbed with test vectors)
|
|
- Add RSA/ECC for JCOP applets
|
|
- EMV GENERATE AC with real session key derivation
|
|
|
|
---
|
|
|
|
## Firmware Maintenance Strategy
|
|
|
|
### The Problem
|
|
|
|
The PM3 firmware is actively developed (~3-5 commits/day). Our patch touches 6 files. We need to rebase cleanly on upstream updates.
|
|
|
|
### Approach: Minimal, Isolated, Additive
|
|
|
|
The patch is designed to be **maximally rebasing-friendly**:
|
|
|
|
1. **`sim_table.c/h` are new files** — never conflict with upstream
|
|
2. **`pm3_cmd.h` changes are appends** — add IDs at the end, never modify existing
|
|
3. **`appmain.c` changes are new case statements** — add at end of switch, never modify existing
|
|
4. **`iso14443a.c` changes are a single insertion point** — block inserted before existing code, clearly delimited with `// --- BEGIN/END PATCH ---` comments
|
|
5. **`iso15693.c` changes are in the `default:` case** — replace one small block
|
|
|
|
### Git Workflow
|
|
|
|
```
|
|
upstream/master ─────────────────────────→ (PM3 official repo)
|
|
\
|
|
└── pm3py/sim-patch ────→ (our fork, rebased regularly)
|
|
├── commit 1: "feat: add sim_table.c/h"
|
|
├── commit 2: "feat: add CMD_SIM_* to pm3_cmd.h"
|
|
├── commit 3: "feat: table lookup + WTX relay in iso14443a.c"
|
|
├── commit 4: "feat: retry relay in iso15693.c"
|
|
└── commit 5: "feat: dispatch in appmain.c"
|
|
```
|
|
|
|
**Each commit is atomic and touches one file** (except commit 1 which adds two new files). This makes `git rebase upstream/master` trivial — conflicts can only happen in commits 2-5, and each is small enough to resolve by inspection.
|
|
|
|
### Rebase Procedure
|
|
|
|
```bash
|
|
# Update from upstream
|
|
cd proxmark3-firmware/
|
|
git fetch upstream
|
|
git checkout sim-patch
|
|
git rebase upstream/master
|
|
|
|
# If conflicts (rare, since our changes are additive):
|
|
# - pm3_cmd.h: just re-add our IDs at the end
|
|
# - appmain.c: just re-add our cases at the end of switch
|
|
# - iso14443a.c: find SimulateIso14443aTag(), re-insert our block before the switch
|
|
# - iso15693.c: find default: case in SimTagIso15693(), re-apply
|
|
|
|
# Verify build
|
|
make clean && make -j
|
|
|
|
# Run PM3 test suite
|
|
./pm3 --test
|
|
```
|
|
|
|
### Automated Rebase Check
|
|
|
|
Add a CI job (GitHub Actions) that:
|
|
|
|
1. Fetches latest upstream PM3 master nightly
|
|
2. Attempts `git rebase` of our patch branch
|
|
3. If clean: push updated branch
|
|
4. If conflicts: open an issue with the conflicting files
|
|
|
|
```yaml
|
|
# .github/workflows/rebase-check.yml
|
|
name: Upstream Rebase Check
|
|
on:
|
|
schedule:
|
|
- cron: '0 6 * * *' # daily at 6 AM
|
|
jobs:
|
|
rebase:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
ref: sim-patch
|
|
fetch-depth: 0
|
|
- run: |
|
|
git remote add upstream https://github.com/RfidResearchGroup/proxmark3.git
|
|
git fetch upstream master
|
|
if git rebase upstream/master; then
|
|
git push --force-with-lease origin sim-patch
|
|
echo "Rebase clean"
|
|
else
|
|
git rebase --abort
|
|
echo "::error::Rebase conflict — manual resolution needed"
|
|
# Create issue via gh cli
|
|
gh issue create --title "Upstream rebase conflict $(date +%Y-%m-%d)" \
|
|
--body "The sim-patch branch has conflicts with upstream master. Files to check: $(git diff --name-only)"
|
|
fi
|
|
```
|
|
|
|
### Conflict Likelihood Assessment
|
|
|
|
| File | Conflict risk | Why |
|
|
|------|--------------|-----|
|
|
| `sim_table.c/h` | **Zero** | New files, never touched by upstream |
|
|
| `pm3_cmd.h` | **Low** | We append at end; conflicts only if upstream uses our IDs (unlikely, they're in 0x0900 range) |
|
|
| `appmain.c` | **Low** | We add cases at end of switch; conflicts only if upstream restructures the switch |
|
|
| `iso14443a.c` | **Medium** | Our insertion is in `SimulateIso14443aTag()` which upstream modifies occasionally. But our block is clearly delimited and self-contained |
|
|
| `iso15693.c` | **Medium** | We modify the `default:` case. If upstream adds new commands, the `default:` moves but our logic stays the same |
|
|
|
|
### Version Pinning
|
|
|
|
In `pm3py`'s metadata, track the upstream commit we've validated against:
|
|
|
|
```python
|
|
# pm3py/firmware_compat.py
|
|
UPSTREAM_TESTED_COMMIT = "abc123def" # upstream commit hash
|
|
UPSTREAM_TESTED_DATE = "2026-03-17"
|
|
PATCH_VERSION = "1.0.0"
|
|
|
|
def check_firmware_compat(fw_version: str) -> bool:
|
|
"""Warn if connected PM3 firmware doesn't include our patch."""
|
|
```
|
|
|
|
---
|
|
|
|
## Timing Reference
|
|
|
|
| Scenario | Layer | Timing constraint | Mechanism | Who computes |
|
|
|----------|-------|-------------------|-----------|-------------|
|
|
| REQA/WUPA → ATQA | 3 | 86µs FDT | Response table | Firmware |
|
|
| SELECT → UID/SAK | 3 | 86µs FDT | Response table | Firmware |
|
|
| RATS → ATS | 3→4 | 86µs FDT | Response table | Firmware |
|
|
| Static APDUs (READ, SELECT AID) | 4 | WTX available | Table (fast path) | Pre-compiled by Python |
|
|
| MIFARE Classic AUTH | 3 | 86µs FDT | Table (pre-computed nonces) | Pre-compiled by Python |
|
|
| DESFire AES AuthenticateAES | 4 | WTX available | WTX + USB relay | Python real-time |
|
|
| JCOP GENERATE AC / INTERNAL AUTH | 4 | WTX available | WTX + USB relay | Python real-time |
|
|
| 15693 Inventory / Read | — | 311µs FDT | Response table | Firmware |
|
|
| 15693 Custom / unknown cmd | — | 311µs FDT | Retry relay | Python real-time |
|
|
| NTAG 5 Boost mutual auth | — | 311µs FDT | Retry relay | Python real-time |
|
|
|
|
---
|
|
|
|
## Key Design Decisions and Rationale
|
|
|
|
1. **Why not new hardware?** The AT91SAM7S512 at 48MHz does table lookups in ~1-2µs. BigBuf (40KB) fits all realistic card models. USB round-trip (~1-2ms) works for WTX relay and 15693 retry relay. No hardware bottleneck exists.
|
|
|
|
2. **Why response table instead of live relay for 14443-A?** ISO 14443-A Layer 3 FDT is 86µs. USB round-trip is ~1-2ms. Even with an SBC directly connected via SPI, Linux cannot guarantee <86µs response latency. The table eliminates this constraint.
|
|
|
|
3. **Why is the table sufficient?** When simming a card, we choose our own random values (nonces, challenges). Since we know the keys and we pick our randomness, the full auth exchange is deterministic and pre-computable. The only truly untableable commands are ones where the reader sends unpredictable data — and those are Layer 4 APDUs where WTX is available.
|
|
|
|
4. **Why WTX instead of other approaches?** WTX is part of the ISO 14443-4 spec. Real JavaCards use it routinely (RSA operations take 50-200ms). Readers must support it. It's the standard mechanism for "card needs time to compute."
|
|
|
|
5. **Why atomic single-file commits for the firmware?** Makes rebasing against upstream trivial. Each commit can be resolved independently. New files never conflict. Appended code rarely conflicts.
|
|
|
|
---
|
|
|
|
## Files Reference
|
|
|
|
### Existing files to modify
|
|
|
|
| File | Change |
|
|
|------|--------|
|
|
| `pm3py/protocol.py` | Add 7 new Cmd entries |
|
|
| `pm3py/transport.py` | May need multi-response read for relay loop |
|
|
| `pm3py/sim/__init__.py` | Export new modules |
|
|
|
|
### New Python files
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `pm3py/sim/table_compiler.py` | ResponseTable, TableEntry, TableCompiler |
|
|
| `pm3py/sim/sim_session.py` | SimSession — upload table, start sim, relay loop |
|
|
| `pm3py/firmware_compat.py` | Firmware version check |
|
|
|
|
### Existing sim framework files (from worktree merge)
|
|
|
|
| File | Role in this plan |
|
|
|------|-------------------|
|
|
| `pm3py/sim/iso14443a.py` | Tag14443A — state machine that TableCompiler walks |
|
|
| `pm3py/sim/iso15693.py` | Tag15693 — same for 15693 |
|
|
| `pm3py/sim/mifare.py` | MifareClassicTag — auth pre-computation |
|
|
| `pm3py/sim/desfire.py` | DesfireTag — WTX relay handler for AES auth |
|
|
| `pm3py/sim/replay.py` | TraceEntry — basis for from_trace() |
|
|
| `pm3py/sim/frame.py` | RFFrame — used in relay loop |
|
|
| `pm3py/sim/medium.py` | Medium ABC — SimSession implements this |
|
|
|
|
### Firmware files (in PM3 firmware fork)
|
|
|
|
| File | New/Modified |
|
|
|------|-------------|
|
|
| `armsrc/sim_table.h` | **New** |
|
|
| `armsrc/sim_table.c` | **New** |
|
|
| `include/pm3_cmd.h` | Modified (7 lines appended) |
|
|
| `armsrc/appmain.c` | Modified (~20 lines added) |
|
|
| `armsrc/iso14443a.c` | Modified (~50 lines inserted) |
|
|
| `armsrc/iso15693.c` | Modified (~40 lines) |
|
|
| `armsrc/Makefile` | Modified (1 line) |
|