From ab3d37e7b39bc02cb15385305874055f6149a9f9 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 18 Mar 2026 19:07:26 -0700 Subject: [PATCH] docs: sync CLAUDE.md with worktree, fix PYTHON_SIM_DESIGN line count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- CLAUDE.md | 28 ++ docs/PYTHON_SIM_DESIGN.md | 633 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 661 insertions(+) create mode 100644 docs/PYTHON_SIM_DESIGN.md diff --git a/CLAUDE.md b/CLAUDE.md index 8684af9..46bd451 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,34 @@ pip install -e . PWM-capable: Easy = A,B. RDV4 = A,D. +## Sim framework (worktree `feature/sim-framework`) + +Software-defined transponder/reader simulation framework — 750+ tests. Pure-Python models for ISO 14443-A, 15693, MIFARE Classic, DESFire, JCOP, LF (EM4100, HID, T5577), NDEF, NXP ICODE/SLIX2/DNA/NTAG5, access control (Wiegand, OSDP), implant profiles. See `.worktrees/sim-framework/docs/SIM_FRAMEWORK_STATUS.md` for full status. + +## Table compiler: proprietary command match patterns + +**Critical:** For NXP custom commands (0xA0+), table entry match patterns must **NOT** include the manufacturer code byte (0x04). The firmware's UID addressing logic consumes the mfg byte as part of UID parsing, so after normalization the mfg byte is absent from the command passed to table lookup. + +Addressing flow for `22 AB 04 `: +1. Firmware sees `cmd[0] & ADDRESS` → addressed mode +2. `cmd[2]` (mfg code 0x04) doesn't match UID → tries `cmd[3:11]` → UID matches +3. `cmdCpt` advances past mfg + UID → `cmdCpt = 11` +4. Normalization: `norm = [flags & ~ADDRESS, cmd] + cmd[cmdCpt:]` → `02 AB` (no mfg code!) +5. Table lookup on `02 AB` → match pattern must be `[0x02, 0xAB]` (PREFIX), NOT `[0x02, 0xAB, 0x04]` + +For unaddressed commands (`02 AB 04`), mfg code stays → `02 AB 04`. PREFIX match on `[0x02, 0xAB]` matches both forms. + +**Rule:** All NXP custom command table entries use `match=bytes([flags, cmd_byte])` with `MATCH_PREFIX`. Never include `0x04` in the match. + +## Python-driven card simulation (in progress) + +Design doc: `docs/PYTHON_SIM_DESIGN.md`. Firmware patch (~320 lines of C) + Python extensions to enable Python-controlled card simulation on unmodified PM3 Easy/RDV4 hardware. Two mechanisms: +- **Response table** in BigBuf — pre-compiled by Python, served by firmware at wire speed (86µs FDT for 14443-A Layer 3) +- **WTX relay** — firmware sends S(WTX) on Layer 4 table miss, relays APDU to Python over USB for real-time crypto (DESFire, JCOP, EMV) +- **15693 retry relay** — reader retry-based relay for unknown commands + +Firmware maintenance: atomic single-file commits for easy rebase against upstream PM3. See design doc for CI workflow. + ## Adding new commands 1. Find the `CMD_*` constant in `include/pm3_cmd.h` and add to `Cmd` enum in `protocol.py` diff --git a/docs/PYTHON_SIM_DESIGN.md b/docs/PYTHON_SIM_DESIGN.md new file mode 100644 index 0000000..d925b77 --- /dev/null +++ b/docs/PYTHON_SIM_DESIGN.md @@ -0,0 +1,633 @@ +# 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) |