feat: migrate sim framework from worktree to master

Migrates all 43 sim modules and 23 test files (~7.8k LOC, 686 tests)
from .worktrees/sim-framework/ into pm3py/sim/. Import fixes:
- 4 files: ..protocol/..transport → ..core.protocol/..core.transport
- trace_fmt.py: pm3py.hf_15 → pm3py.trace.ndef
- test_sim_pm3medium.py: flat imports → core.*
- test_sim_trace_fmt.py: flat imports → core.*
- Added sim Cmd entries to core/protocol.py (EML_SETMEM, SIM_TABLE_*)

751 tests passing (686 sim + 65 core).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
michael
2026-03-18 20:04:55 -07:00
parent 7fc2470b01
commit 668170457e
68 changed files with 16951 additions and 1 deletions

View File

@@ -0,0 +1,146 @@
# Sim Framework Migration Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Migrate the sim framework (~7.8k LOC, 687 tests) from `.worktrees/sim-framework/` to master's `pm3py/sim/`, making all transponder models testable on master.
**Architecture:** Copy sim/ files as-is (preserving internal imports), fix 2 external imports that reference the old flat package layout (`..protocol``..core.protocol`, `..transport``..core.transport`). Copy 23 test files. Verify 687+ tests pass. The sim/ package keeps its flat internal structure — reorganization into `transponders/` and `reader/` sub-packages is a follow-up.
**Tech Stack:** Python 3.12, pytest, AsyncMock
---
## Context
### Dependency graph (acyclic, bottom-up)
**Foundation (no sim/ deps):** frame, memory, mcu_protocol, trace_fmt, crypto1, auth_aes, access_control/*
**Core abstractions:** transponder (←frame, memory), medium (←frame, transponder), reader (←frame, medium), auth_password (←frame)
**LF:** lf_base → em4100 → t5577, hid
**HF 14443-A:** iso14443a → mifare (+ crypto1), desfire, ndef
**HF 15693:** iso15693 → type5 → nxp_icode → icode_slix → icode_slix2 → icode3, icode_dna → ntag5_platform → ntag5_switch, ntag5_link → ntag5_boost
**Integration:** replay, fuzzer, table_compiler, relay, pm3medium, implants, sim_session, mcu_bridge, dual_session
### External imports (only 2 files)
- `pm3medium.py`: `from ..protocol import Cmd` and `from ..transport import PM3Transport`
- `sim_session.py`: `from ..protocol import Cmd` and `from ..transport import encode_ng_frame, decode_response_frame, RESP_PREAMBLE_MAGIC, RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE`
### Overlap with trace/
`sim/trace_fmt.py` (560 lines) overlaps with `pm3py/trace/` but is more complete (has 14443-A decoders, NXP-specific decoders, TraceFormatter class). Keep both — sim references its own `trace_fmt.py` internally. Unify later.
---
### Task 1: Copy sim/ source files
**Files:**
- Source: `.worktrees/sim-framework/pm3py/sim/*.py` and `access_control/`
- Target: `pm3py/sim/`
**Step 1:** Copy all .py files from sim worktree into master's pm3py/sim/, overwriting the scaffold `__init__.py`
```bash
cp .worktrees/sim-framework/pm3py/sim/*.py pm3py/sim/
mkdir -p pm3py/sim/access_control
cp .worktrees/sim-framework/pm3py/sim/access_control/*.py pm3py/sim/access_control/
```
**Step 2:** Verify file count matches
```bash
find pm3py/sim -name '*.py' | wc -l
# Expected: ~40+ files
```
---
### Task 2: Fix external imports
**Files:**
- Modify: `pm3py/sim/pm3medium.py`
- Modify: `pm3py/sim/sim_session.py`
**Step 1:** In `pm3medium.py`, change:
- `from ..protocol import``from ..core.protocol import`
- `from ..transport import``from ..core.transport import`
**Step 2:** In `sim_session.py`, change:
- `from ..protocol import``from ..core.protocol import`
- `from ..transport import``from ..core.transport import`
**Step 3:** Verify no other files reference outside sim/:
```bash
grep -r "from \.\." pm3py/sim/ | grep -v "from \.\.core\." | grep -v "from \.\.trace"
```
Expected: no output (all external refs fixed).
---
### Task 3: Copy test files
**Files:**
- Source: `.worktrees/sim-framework/tests/test_sim_*.py` (23 files)
- Target: `tests/`
**Step 1:** Copy test files
```bash
cp .worktrees/sim-framework/tests/test_sim_*.py tests/
```
**Step 2:** Check if any test files import from old flat paths (like `from pm3py.protocol import`)
```bash
grep "from pm3py\." tests/test_sim_*.py | grep -v "from pm3py\.sim" | grep -v "from pm3py\.core"
```
Fix any that reference old flat layout.
---
### Task 4: Run tests — all green
**Step 1:** Run just sim tests first to isolate issues
```bash
python -m pytest tests/test_sim_*.py -v --tb=short 2>&1 | tail -30
```
Expected: 687 tests, 0 failures.
**Step 2:** Run full test suite
```bash
python -m pytest tests/ -v
```
Expected: 687 + 65 = 752 tests, 0 failures.
**Step 3:** Commit
```bash
git add pm3py/sim/ tests/test_sim_*.py
git commit -m "feat: migrate sim framework from worktree to master
Migrates all 41 sim modules and 23 test files (~7.8k LOC, 687 tests)
from .worktrees/sim-framework/ into pm3py/sim/. Only 2 import fixes
needed (pm3medium.py, sim_session.py) for the core/ package rename.
Internal sim/ imports unchanged.
"
```
---
### Task 5: Update docs
**Step 1:** Update `CLAUDE.md` package structure to show sim/ is populated
**Step 2:** Update `docs/plans/2026-03-18-refactor-progress.md`
**Step 3:** Commit docs

View File

@@ -200,6 +200,12 @@ class Cmd(IntEnum):
HF_ISO15693_COMMAND = 0x0313 HF_ISO15693_COMMAND = 0x0313
HF_ISO15693_FINDAFI = 0x0315 HF_ISO15693_FINDAFI = 0x0315
HF_ISO15693_CSETUID = 0x0316 HF_ISO15693_CSETUID = 0x0316
HF_ISO15693_EML_SETMEM = 0x0331
# Sim table commands (firmware response table)
SIM_TABLE_UPLOAD = 0x0900
SIM_TABLE_CLEAR = 0x0901
SIM_TABLE_UPDATE = 0x0902
HF_ISO14443A_SNIFF = 0x0383 HF_ISO14443A_SNIFF = 0x0383
HF_ISO14443A_SIMULATE = 0x0384 HF_ISO14443A_SIMULATE = 0x0384

View File

@@ -1 +1,68 @@
"""pm3py.sim — Card simulation sessions, table compiler, relay.""" """pm3py.sim — Software-defined transponder/reader simulation framework."""
from .frame import RFFrame
from .memory import DirtyByteArray, MemoryRegion, BlockAccess
from .medium import Medium, SoftwareMedium
from .transponder import Transponder
from .reader import Reader, ScriptedReader, InteractiveReader, ReaderStep, StepResult
from .iso14443a import (
Tag14443A, Tag14443A_3, Tag14443A_4, Reader14443A, State14443A,
)
from .crypto1 import Crypto1
from .mifare import MifareClassicTag, MifareClassicReader
from .iso15693 import Tag15693, Reader15693, State15693
from .lf_base import TagLF, ReaderLF, Modulation
from .em4100 import EM4100Tag, EM4100Reader
from .hid import HIDProxTag, HIDReader
from .t5577 import T5577Tag, T5577Reader
from .ndef import NfcType2Tag, NfcType4Tag
from .type5 import NfcType5Tag, ndef_text, ndef_uri, ndef_mime
from .nxp_icode import NxpIcodeTag
from .icode_slix import IcodeSlixTag
from .icode_slix2 import IcodeSlix2Tag
from .icode3 import Icode3Tag
from .icode_dna import IcodeDnaTag
from .ntag5_platform import Ntag5PlatformTag
from .ntag5_switch import Ntag5SwitchTag
from .ntag5_link import Ntag5LinkTag
from .ntag5_boost import Ntag5BoostTag
from .desfire import DesfireTag, DesfireReader
from .implants import xEM, xNT, xM1, FlexDF, NExT, MagicMifareClassicTag
from .fuzzer import MutationFuzzer, GrammarFuzzer
from .relay import RelayTransponder, MitMProxy
from .replay import TraceRecorder, TraceReplayer, TraceEntry
from .pm3medium import PM3ReaderMedium
from .table_compiler import TableEntry, ResponseTable, TableCompiler
from .sim_session import SimSession
from .mcu_bridge import McuBridge
from .dual_session import DualInterfaceSession
__all__ = [
"RFFrame",
"DirtyByteArray", "MemoryRegion", "BlockAccess",
"Medium", "SoftwareMedium",
"Transponder",
"Reader", "ScriptedReader", "InteractiveReader", "ReaderStep", "StepResult",
"Tag14443A", "Tag14443A_3", "Tag14443A_4", "Reader14443A", "State14443A",
"Crypto1",
"MifareClassicTag", "MifareClassicReader",
"Tag15693", "Reader15693", "State15693",
"TagLF", "ReaderLF", "Modulation",
"EM4100Tag", "EM4100Reader",
"HIDProxTag", "HIDReader",
"T5577Tag", "T5577Reader",
"NfcType2Tag", "NfcType4Tag", "NfcType5Tag",
"ndef_text", "ndef_uri", "ndef_mime",
"NxpIcodeTag", "IcodeSlixTag", "IcodeSlix2Tag",
"Icode3Tag", "IcodeDnaTag",
"Ntag5PlatformTag", "Ntag5SwitchTag", "Ntag5LinkTag", "Ntag5BoostTag",
"DesfireTag", "DesfireReader",
"xEM", "xNT", "xM1", "FlexDF", "NExT", "MagicMifareClassicTag",
"MutationFuzzer", "GrammarFuzzer",
"RelayTransponder", "MitMProxy",
"TraceRecorder", "TraceReplayer", "TraceEntry",
"PM3ReaderMedium",
"TableEntry", "ResponseTable", "TableCompiler",
"SimSession",
"McuBridge",
"DualInterfaceSession",
]

View File

@@ -0,0 +1,4 @@
"""Access control system I/O: credentials, Wiegand, OSDP."""
from .credential import Credential, encode_wiegand, decode_wiegand, from_uid
from .wiegand import WiegandOutput, WiegandInput
from .osdp import OSDPFrame, OSDPChannel, osdp_crc16

View File

@@ -0,0 +1,78 @@
"""Credential model and Wiegand encoding/decoding."""
from __future__ import annotations
from dataclasses import dataclass
import struct
FORMATS = {
"H10301": {"bits": 26, "fc_bits": 8, "cn_bits": 16},
"H10304": {"bits": 37, "fc_bits": 16, "cn_bits": 19},
"C1000_35": {"bits": 35, "fc_bits": 12, "cn_bits": 20},
}
@dataclass
class Credential:
"""Access control credential."""
facility_code: int
card_number: int
format: str = "H10301"
def encode_wiegand(cred: Credential) -> list[int]:
"""Encode credential to Wiegand bit stream."""
f = FORMATS[cred.format]
total = f["bits"]
fc_bits = f["fc_bits"]
cn_bits = f["cn_bits"]
# Data bits: FC + CN (MSB first)
data = []
for i in range(fc_bits - 1, -1, -1):
data.append((cred.facility_code >> i) & 1)
for i in range(cn_bits - 1, -1, -1):
data.append((cred.card_number >> i) & 1)
if total == 26 or total == 37:
# Standard: EP(1) + data + OP(1)
half = len(data) // 2
even_parity = sum(data[:half]) % 2
odd_parity = 1 - (sum(data[half:]) % 2)
return [even_parity] + data + [odd_parity]
elif total == 35:
# C1000_35: EP(1) + data(32) + OP(1) + company_parity(1)
half = len(data) // 2
even_parity = sum(data[:half]) % 2
odd_parity = 1 - (sum(data[half:]) % 2)
company_parity = sum(data[:12]) % 2
return [even_parity] + data + [odd_parity, company_parity]
else:
return data
def decode_wiegand(bits: list[int], format: str) -> Credential:
"""Decode Wiegand bit stream to credential."""
f = FORMATS[format]
fc_bits = f["fc_bits"]
cn_bits = f["cn_bits"]
data = bits[1:-1] # strip parity
fc = 0
for i in range(fc_bits):
fc = (fc << 1) | data[i]
cn = 0
for i in range(cn_bits):
cn = (cn << 1) | data[fc_bits + i]
return Credential(facility_code=fc, card_number=cn, format=format)
def from_uid(uid: bytes, format: str = "H10301") -> Credential:
"""Derive a credential from a tag UID."""
f = FORMATS[format]
uid_int = int.from_bytes(uid, "big")
fc_mask = (1 << f["fc_bits"]) - 1
cn_mask = (1 << f["cn_bits"]) - 1
fc = (uid_int >> f["cn_bits"]) & fc_mask
cn = uid_int & cn_mask
return Credential(facility_code=fc, card_number=cn, format=format)

View File

@@ -0,0 +1,91 @@
"""OSDP v2 protocol — framing, CRC, reader/controller communication."""
from __future__ import annotations
import struct
from dataclasses import dataclass, field
SOM = 0x53 # Start of Message
def osdp_crc16(data: bytes) -> int:
"""CRC-16/AUG-CCITT used by OSDP."""
crc = 0x1D0F
for byte in data:
crc ^= byte << 8
for _ in range(8):
if crc & 0x8000:
crc = (crc << 1) ^ 0x1021
else:
crc <<= 1
crc &= 0xFFFF
return crc
@dataclass
class OSDPFrame:
"""OSDP protocol frame."""
address: int
command: int
data: bytes = b""
sequence: int = 0
def encode(self) -> bytes:
"""Encode to wire format: SOM + ADDR + LEN(2) + CTRL + CMD + DATA + CRC(2)."""
ctrl = self.sequence & 0x03
payload = bytes([SOM, self.address & 0x7F])
msg_len = 6 + len(self.data) + 2 # header(6) + data + crc(2)
payload += struct.pack("<H", msg_len)
payload += bytes([ctrl, self.command])
payload += self.data
crc = osdp_crc16(payload)
payload += struct.pack("<H", crc)
return payload
@classmethod
def decode(cls, raw: bytes) -> OSDPFrame:
"""Decode from wire format."""
if len(raw) < 8 or raw[0] != SOM:
raise ValueError("Invalid OSDP frame")
address = raw[1] & 0x7F
msg_len = struct.unpack("<H", raw[2:4])[0]
ctrl = raw[4]
command = raw[5]
data = raw[6:msg_len - 2]
# Verify CRC
expected_crc = struct.unpack("<H", raw[msg_len - 2:msg_len])[0]
actual_crc = osdp_crc16(raw[:msg_len - 2])
if expected_crc != actual_crc:
raise ValueError(f"CRC mismatch: {expected_crc:#06x} != {actual_crc:#06x}")
return cls(address=address, command=command, data=data,
sequence=ctrl & 0x03)
class OSDPChannel:
"""OSDP communication channel — software loopback or UART.
In software mode, frames are queued in a buffer for testing.
For hardware, pass a pigpio serial handle.
"""
def __init__(self, pi=None, serial_handle=None):
self._pi = pi
self._serial = serial_handle
self._buffer: list[OSDPFrame] = []
@property
def buffer(self) -> list[OSDPFrame]:
return self._buffer
def send(self, frame: OSDPFrame) -> None:
"""Send an OSDP frame."""
if self._pi is not None and self._serial is not None:
raw = frame.encode()
self._pi.serial_write(self._serial, raw)
else:
self._buffer.append(frame)
def receive(self) -> OSDPFrame | None:
"""Receive an OSDP frame (software mode: pop from buffer)."""
if self._buffer:
return self._buffer.pop(0)
return None

View File

@@ -0,0 +1,73 @@
"""Wiegand protocol — encode/decode, software and hardware I/O."""
from __future__ import annotations
from .credential import Credential, encode_wiegand, decode_wiegand
class WiegandOutput:
"""Wiegand output — software mode or GPIO (via pigpio)."""
def __init__(self, d0_pin: int | None = None, d1_pin: int | None = None,
pulse_width_us: int = 50, interval_us: int = 2000,
pi=None):
self._d0_pin = d0_pin
self._d1_pin = d1_pin
self._pulse_width_us = pulse_width_us
self._interval_us = interval_us
self._pi = pi
self._last_bits: list[int] = []
@property
def pulse_width_us(self) -> int:
return self._pulse_width_us
@property
def interval_us(self) -> int:
return self._interval_us
@property
def last_bits(self) -> list[int]:
return self._last_bits
def send(self, cred: Credential) -> None:
"""Send credential as Wiegand bit stream."""
bits = encode_wiegand(cred)
self._last_bits = bits
if self._pi is not None and self._d0_pin is not None:
self._send_gpio(bits)
def _send_gpio(self, bits: list[int]) -> None:
"""Send Wiegand bits via GPIO (requires pigpio)."""
for bit in bits:
if bit == 0:
self._pi.gpio_trigger(self._d0_pin, self._pulse_width_us, 0)
else:
self._pi.gpio_trigger(self._d1_pin, self._pulse_width_us, 0)
# Inter-pulse interval handled by pigpio timing
class WiegandInput:
"""Wiegand input — software mode or GPIO (via pigpio)."""
def __init__(self, d0_pin: int | None = None, d1_pin: int | None = None,
pi=None):
self._d0_pin = d0_pin
self._d1_pin = d1_pin
self._pi = pi
self._bits: list[int] = []
def decode(self, bits: list[int], format: str = "H10301") -> Credential:
"""Decode Wiegand bit stream to credential."""
return decode_wiegand(bits, format)
def feed_bit(self, bit: int) -> None:
"""Feed a single bit (for GPIO callback use)."""
self._bits.append(bit)
@property
def accumulated_bits(self) -> list[int]:
return self._bits
def reset(self) -> None:
self._bits.clear()

348
pm3py/sim/auth_aes.py Normal file
View File

@@ -0,0 +1,348 @@
"""NXP AES-128 key management mixin.
Reusable mixin for ICODE DNA and NTAG 5 Link/Boost AES auth:
- 4 key slots with key headers and privilege bitmasks
- Global Crypto Header (GCH) for access enforcement
- Authentication limit with failure counting
- TAM1 (Tag Authentication Method 1) — one-way tag authentication
- MAM1/MAM2 (Mutual Authentication Method) — two-phase mutual auth
- Helper methods for key state and privilege checks
"""
from __future__ import annotations
import os
try:
from Crypto.Cipher import AES as _AES
_HAS_AES = True
except ImportError:
_HAS_AES = False
# Key header values
KEY_HEADER_NOT_ACTIVE = 0x81
KEY_HEADER_ACTIVE_LOCKED = 0xE7
KEY_HEADER_DISABLED = 0xFF
# Key privilege bits (Table 5)
PRIV_READ = 0x01
PRIV_WRITE = 0x02
PRIV_PRIVACY = 0x04
PRIV_DESTROY = 0x08
PRIV_EAS_AFI = 0x10
PRIV_CRYPTO_CONFIG = 0x20
PRIV_AREA1_READ = 0x40
PRIV_AREA1_WRITE = 0x80
# NFC Global Crypto Header values
GCH_DEACTIVATED = 0x81
GCH_DEACTIVATED_PRIV_LOCKED = 0x87
GCH_ACTIVATED = 0xC1
GCH_ACTIVATED_PRIV_LOCKED = 0xC7
GCH_FINAL = 0xE7
class NxpAesAuth:
"""Mixin providing AES-128 key management for NXP ICODE/NTAG 5 tags.
Call ``_init_aes_auth(aes_keys)`` from the concrete class's
``__init__`` to set up AES key state.
"""
def _init_aes_auth(self, aes_keys: list[bytes | None] | None = None) -> None:
"""Initialize AES key management state.
Args:
aes_keys: optional list of up to 4 AES-128 keys (or None per slot).
"""
self._aes_keys: list[bytes | None] = list(aes_keys) if aes_keys else [None] * 4
self._aes_authenticated: set[int] = set()
# Key headers: controls whether key is active for authentication
self._key_headers: list[int] = [KEY_HEADER_NOT_ACTIVE] * 4
# Key privileges: bitmask per key defining what auth grants
self._key_privileges: list[int] = [0x00] * 4
# Global Crypto Header — controls enforcement of access conditions
self._nfc_gch: int = GCH_DEACTIVATED
# Authentication limit (0 = unlimited)
self._auth_limit: int = 0
self._auth_fail_count: int = 0
def is_key_active(self, key_id: int) -> bool:
"""Check if AES key slot is active (header == 0xE7)."""
return self._key_headers[key_id] == KEY_HEADER_ACTIVE_LOCKED
def has_privilege(self, key_id: int, privilege: int) -> bool:
"""Check if key_id is authenticated, active, and has the given privilege bit."""
if key_id not in self._aes_authenticated:
return False
if not self.is_key_active(key_id):
return False
return bool(self._key_privileges[key_id] & privilege)
def is_access_enforced(self) -> bool:
"""Check if GCH is activated (>= 0xC1), meaning access conditions are enforced."""
return self._nfc_gch >= GCH_ACTIVATED
# ----- TAM1 (Tag Authentication Method 1) -----
# Crypto constants — first 2 bytes of plaintext block
_C_TAM1 = b"\x96\xC5"
_C_MAM1 = b"\xDA\x83"
_C_MAM2_PURPOSE = b"\xDA\x80"
def _handle_challenge(self, frame) -> None:
"""Handle CHALLENGE (0x39) — compute and store TResponse.
Format: flags(1) cmd(1) CSI(1) AuthMethod(1) KeyID(1) IChallenge(10)
Tag computes: AES-ECB-ENC(key, C_TAM1 || TRnd || IChallenge)
Stores TResponse reversed (wire byte order).
Returns None — CHALLENGE has no RF response.
"""
self._tam1_response = None
if not _HAS_AES:
return
data = frame.data
if len(data) < 15: # flags + cmd + CSI + AuthMethod + KeyID + 10 IChallenge
return
auth_method = data[3]
if auth_method != 0x00: # TAM1 only
return
key_id = data[4]
if key_id > 3:
return
if not self.is_key_active(key_id):
return
key = self._aes_keys[key_id]
if key is None:
return
ichallenge = data[5:15]
if len(ichallenge) != 10:
return
# Build plaintext: C_TAM1(2) + TRnd(4) + IChallenge(10) = 16 bytes
trnd = os.urandom(4)
plaintext = self._C_TAM1 + trnd + ichallenge
cipher = _AES.new(key, _AES.MODE_ECB)
tresponse = cipher.encrypt(plaintext)
# Store reversed for wire format
self._tam1_response = tresponse[::-1]
def _handle_readbuffer(self, frame):
"""Handle READBUFFER (0x3A) — return stored TResponse.
Format: flags(1) cmd(1)
Returns: flags(00) + TResponse_reversed(16 bytes)
Returns None if no TResponse is stored.
"""
from .frame import RFFrame
resp = getattr(self, "_tam1_response", None)
if resp is None:
return None
result = RFFrame.from_bytes(bytes([0x00]) + resp)
# Clear after read (one-shot)
self._tam1_response = None
return result
# ----- MAM (Mutual Authentication Method) -----
def _handle_authenticate(self, frame):
"""Handle AUTHENTICATE (0x35) — dispatch MAM1 or MAM2.
Format: flags(1) cmd(1) CSI(1) AuthMethod(1) ...
AuthMethod 0x02 = MAM1, 0x06 = MAM2.
Returns RFFrame response or error frame.
"""
from .frame import RFFrame
data = frame.data
if len(data) < 4:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
auth_method = data[3]
if auth_method == 0x02:
return self._handle_mam1(frame)
elif auth_method == 0x06:
return self._handle_mam2(frame)
else:
# Unsupported AuthMethod (0x80, 0x90, etc.)
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
def _handle_mam1(self, frame):
"""Handle MAM1 (AuthMethod=0x02) — tag proves identity to reader.
Format: flags(1) cmd(1) CSI(1) AuthMethod(1) KeyID(1) IChallenge_reversed(10)
Total: 15 bytes minimum.
Tag computes: AES-ECB-ENC(key, C_MAM1 || TChallenge[31:0] || IChallenge)
Response: flags(04) header(A7) TChallenge_high_reversed(6) encrypted_reversed(16)
"""
from .frame import RFFrame
self._mam_state = None
if not _HAS_AES:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
data = frame.data
if len(data) < 15:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
key_id = data[4]
if key_id > 3:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if not self.is_key_active(key_id):
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
key = self._aes_keys[key_id]
if key is None:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
# De-reverse IChallenge from wire format
ichallenge_reversed = data[5:15]
ichallenge = ichallenge_reversed[::-1]
# Generate 10-byte TChallenge
tchallenge = os.urandom(10)
# Build plaintext: C_MAM1(2) + TChallenge[31:0](4) + IChallenge(10) = 16
# TChallenge[31:0] = last 4 bytes of TChallenge (bytes 6-9)
tc_low = tchallenge[6:10]
plaintext = self._C_MAM1 + tc_low + ichallenge
cipher = _AES.new(key, _AES.MODE_ECB)
encrypted = cipher.encrypt(plaintext)
# TChallenge_high = first 6 bytes (bytes 0-5), reversed for wire
tc_high = tchallenge[0:6]
tc_high_reversed = tc_high[::-1]
# Encrypted block reversed for wire
encrypted_reversed = encrypted[::-1]
# Store state for MAM2 verification
self._mam_state = {
"key_id": key_id,
"ichallenge": ichallenge,
"tchallenge": tchallenge,
}
# Response: flags(04) header(A7) tc_high_reversed(6) encrypted_reversed(16)
resp = bytes([0x04, 0xA7]) + tc_high_reversed + encrypted_reversed
return RFFrame.from_bytes(resp)
def _handle_mam2(self, frame):
"""Handle MAM2 (AuthMethod=0x06) — reader proves identity to tag.
Format: flags(1) cmd(1) CSI(1) AuthMethod(1) IResponse_reversed(16)
Total: 20 bytes minimum.
Tag verifies: AES-ECB-ENC(key, iresponse) == C_MAM2_PURPOSE || IChallenge[31:0] || TChallenge
On success: key_id added to _aes_authenticated, response = flags(00).
"""
from .frame import RFFrame
if not _HAS_AES:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
# Must have prior MAM1 state
mam_state = getattr(self, "_mam_state", None)
if mam_state is None:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
data = frame.data
if len(data) < 20:
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
key_id = mam_state["key_id"]
ichallenge = mam_state["ichallenge"]
tchallenge = mam_state["tchallenge"]
key = self._aes_keys[key_id]
if key is None:
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
# De-reverse IResponse from wire format
iresponse_reversed = data[4:20]
iresponse = iresponse_reversed[::-1]
# Encrypt IResponse to get plaintext
cipher = _AES.new(key, _AES.MODE_ECB)
plaintext = cipher.encrypt(iresponse)
# Parse: C_MAM2[11:0]||Purpose[3:0] (2 bytes) + IChallenge[31:0](4) + TChallenge[79:0](10)
purpose = plaintext[0:2]
ich_31_0 = plaintext[2:6]
tc_check = plaintext[6:16]
# Verify upper 12 bits of C_MAM2 field (0xDA8x)
if purpose[0] != 0xDA or (purpose[1] & 0xF0) != 0x80:
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
purpose_nibble = purpose[1] & 0x0F
# IChallenge[31:0] = last 4 bytes of original IChallenge
if ich_31_0 != ichallenge[6:10]:
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if tc_check != tchallenge:
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
# Check privilege requirements for non-standard purposes
if purpose_nibble in (0x08, 0x09, 0x0A):
if not (self._key_privileges[key_id] & PRIV_PRIVACY):
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
elif purpose_nibble == 0x0B:
if not (self._key_privileges[key_id] & PRIV_DESTROY):
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
elif purpose_nibble != 0x00:
# Unknown purpose nibble
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
# Authentication successful
self._aes_authenticated.add(key_id)
# Dispatch purpose action
if purpose_nibble == 0x08:
# Temporary disable NFC Privacy Mode (until field reset)
if hasattr(self, "_privacy_mode"):
self._privacy_mode = False
elif purpose_nibble == 0x09:
# Enable NFC Privacy Mode
if hasattr(self, "_privacy_mode"):
self._privacy_mode = True
elif purpose_nibble == 0x0A:
# Permanent disable NFC Privacy Mode
if hasattr(self, "_privacy_mode"):
self._privacy_mode = False
elif purpose_nibble == 0x0B:
# Destroy
if hasattr(self, "_destroyed"):
self._destroyed = True
self._mam_state = None
return RFFrame.from_bytes(bytes([0x00]))

116
pm3py/sim/auth_password.py Normal file
View File

@@ -0,0 +1,116 @@
"""NXP XOR password authentication mixin.
Reusable mixin for ICODE SLIX/SLIX2/ICODE 3 password auth:
- Password storage, XOR verification, GET_RANDOM override
- SET_PASSWORD, WRITE_PASSWORD, LOCK_PASSWORD handlers
"""
from __future__ import annotations
import os
import struct
from .frame import RFFrame
class NxpPasswordAuth:
"""Mixin providing XOR password authentication for NXP ICODE tags.
Call ``_init_password_auth(passwords)`` from the concrete class's
``__init__`` to set up password state.
"""
def _init_password_auth(self, passwords: dict[int, int | None]) -> None:
"""Initialize password authentication state.
Args:
passwords: mapping of password ID → 32-bit value (or None).
"""
self._passwords: dict[int, int | None] = dict(passwords)
self._authenticated_passwords: set[int] = set()
self._locked_passwords: set[int] = set()
self._last_random: bytes = b"\x00\x00"
# ----- GET_RANDOM (override to store last random) -----
def _handle_get_random(self, frame: RFFrame) -> RFFrame | None:
random_bytes = os.urandom(2)
self._last_random = random_bytes
return RFFrame.from_bytes(bytes([0x00]) + random_bytes)
# ----- Password XOR helpers -----
def _verify_xor_password(self, pwd_id: int, xored_bytes: bytes) -> bool:
"""Verify XOR'd password against stored value."""
expected = self._passwords.get(pwd_id)
if expected is None:
return False
r = self._last_random
pwd_bytes = bytes([
xored_bytes[0] ^ r[0], xored_bytes[1] ^ r[1],
xored_bytes[2] ^ r[0], xored_bytes[3] ^ r[1],
])
pwd_value = struct.unpack("<I", pwd_bytes)[0]
return pwd_value == expected
def _decode_xor_password(self, xored_bytes: bytes) -> int:
"""Decode XOR'd password bytes to integer value."""
r = self._last_random
pwd_bytes = bytes([
xored_bytes[0] ^ r[0], xored_bytes[1] ^ r[1],
xored_bytes[2] ^ r[0], xored_bytes[3] ^ r[1],
])
return struct.unpack("<I", pwd_bytes)[0]
# ----- SET_PASSWORD -----
def _handle_set_password(self, frame: RFFrame) -> RFFrame | None:
"""SET_PASSWORD (0xB3): authenticate with XOR'd password."""
if len(frame.data) < 8:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
pwd_id = frame.data[3]
pwd_xored = frame.data[4:8]
if not self._verify_xor_password(pwd_id, pwd_xored):
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
self._authenticated_passwords.add(pwd_id)
return RFFrame.from_bytes(bytes([0x00]))
# ----- WRITE PASSWORD -----
def _handle_write_password(self, frame: RFFrame) -> RFFrame | None:
"""WRITE PASSWORD (0xB4): change password value.
Requires prior SET_PASSWORD for the same pwd_id.
"""
if len(frame.data) < 8:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
pwd_id = frame.data[3]
if pwd_id not in self._authenticated_passwords:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if pwd_id in self._locked_passwords:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if self._passwords.get(pwd_id) is None:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
new_value = self._decode_xor_password(frame.data[4:8])
self._passwords[pwd_id] = new_value
self._authenticated_passwords.discard(pwd_id)
return RFFrame.from_bytes(bytes([0x00]))
# ----- LOCK PASSWORD -----
def _handle_lock_password(self, frame: RFFrame) -> RFFrame | None:
"""LOCK PASSWORD (0xB5): permanently lock a password."""
if len(frame.data) < 4:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
pwd_id = frame.data[3]
if pwd_id not in self._authenticated_passwords:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
self._locked_passwords.add(pwd_id)
return RFFrame.from_bytes(bytes([0x00]))

103
pm3py/sim/crypto1.py Normal file
View File

@@ -0,0 +1,103 @@
"""Pure-Python Crypto-1 stream cipher for MIFARE Classic.
48-bit LFSR with non-linear filter function.
Feedback polynomial: x^48 + x^43 + x^39 + x^38 + x^36 + x^34 + x^33 + x^31 +
x^29 + x^24 + x^23 + x^21 + x^19 + x^13 + x^9 + x^7 + x^6 + x^5 + 1
"""
from __future__ import annotations
import struct
import os
# Feedback taps (bit positions, 0-indexed from LSB)
FEEDBACK_TAPS = (0, 5, 6, 7, 9, 13, 19, 21, 23, 24, 29, 31, 33, 34, 36, 38, 39, 43)
# Filter function lookup table (20-bit input → 1-bit output)
# The filter function f(x0..x3, x4..x7, ..., x16..x19) is built from
# layers of 4-input boolean functions.
_FA = 0x9E98 # f_a lookup
_FB = 0xB48E # f_b lookup
_FC = 0xEC57E80A # f_c lookup
def _filter_bit(lfsr: int) -> int:
"""Compute the non-linear filter function output."""
# Extract 20 bits from specific LFSR positions
# Positions: 9,11,13,15,17,19,21,23,25,27,29,31,33,35,37,39,41,43,45,47
x = 0
for i, pos in enumerate((9, 11, 13, 15, 17, 19, 21, 23, 25, 27,
29, 31, 33, 35, 37, 39, 41, 43, 45, 47)):
x |= ((lfsr >> pos) & 1) << i
# Layer 1: four 4-input functions using _FA
a = (_FA >> (x & 0xF)) & 1
b = (_FA >> ((x >> 4) & 0xF)) & 1
c = (_FB >> ((x >> 8) & 0xF)) & 1
d = (_FB >> ((x >> 12) & 0xF)) & 1
e = (_FA >> ((x >> 16) & 0xF)) & 1
# Layer 2: combine with _FC
idx = a | (b << 1) | (c << 2) | (d << 3) | (e << 4)
return (_FC >> idx) & 1
def _feedback_bit(lfsr: int) -> int:
"""Compute LFSR feedback from tapped positions."""
fb = 0
for tap in FEEDBACK_TAPS:
fb ^= (lfsr >> tap) & 1
return fb
class Crypto1:
"""MIFARE Classic Crypto-1 stream cipher."""
def __init__(self, key: bytes):
self._lfsr: int = 0
self._load_key(key)
def _load_key(self, key: bytes) -> None:
"""Load 48-bit key into LFSR."""
# Key is loaded LSB first into the LFSR
key_int = int.from_bytes(key, "little")
self._lfsr = key_int & ((1 << 48) - 1)
def _clock(self, input_bit: int = 0, feedback: bool = True) -> int:
"""Clock the LFSR once. Returns filter output before clocking."""
out = _filter_bit(self._lfsr)
fb = _feedback_bit(self._lfsr) if feedback else 0
new_bit = fb ^ input_bit
self._lfsr = ((self._lfsr << 1) | new_bit) & ((1 << 48) - 1)
return out
def generate_bit(self) -> int:
"""Generate one keystream bit."""
return self._clock(0, True)
def generate_byte(self) -> int:
"""Generate one keystream byte (MSB first)."""
val = 0
for i in range(8):
val = (val << 1) | self.generate_bit()
return val
def encrypt_bytes(self, data: bytes) -> bytes:
"""XOR data with keystream (encryption = decryption for stream cipher)."""
result = bytearray(len(data))
for i, b in enumerate(data):
result[i] = b ^ self.generate_byte()
return bytes(result)
def generate_nonce(self) -> bytes:
"""Generate a 4-byte tag nonce from LFSR state."""
# Use PRNG based on LFSR state
nt = struct.pack(">I", self._lfsr & 0xFFFFFFFF)
return nt
def init_auth(self, uid: int, nt: int) -> None:
"""Initialize cipher for authentication with uid XOR nt fed back."""
# Feed uid ^ nt into LFSR bit by bit
xor_val = uid ^ nt
for i in range(32):
bit = (xor_val >> (31 - i)) & 1
self._clock(bit, True)

276
pm3py/sim/desfire.py Normal file
View File

@@ -0,0 +1,276 @@
"""DESFire EV1/EV2 transponder and reader models."""
from __future__ import annotations
import os
import struct
from .frame import RFFrame
from .medium import Medium
from .iso14443a import Tag14443A_4, Reader14443A, _compute_bcc, _split_uid_cascades
# DESFire status codes
ST_OK = 0x00
ST_AF = 0xAF # Additional Frame
ST_AE = 0xAE # Authentication Error
ST_DE = 0xDE # Duplicate Error
ST_NE = 0x9D # Application not found
ST_FE = 0xF0 # File not found
ST_PE = 0x9E # Permission denied
ST_IE = 0x1C # Integrity error
# DESFire commands
CMD_GET_VERSION = 0x60
CMD_GET_APP_IDS = 0x6A
CMD_SELECT_APP = 0x5A
CMD_CREATE_APP = 0xCA
CMD_DELETE_APP = 0xDA
CMD_CREATE_STD_FILE = 0xCD
CMD_READ_DATA = 0xBD
CMD_WRITE_DATA = 0x3D
CMD_AUTH_AES = 0xAA
CMD_ADDITIONAL_FRAME = 0xAF
PICC_AID = b"\x00\x00\x00"
class DesfireApp:
"""A DESFire application with files and keys."""
def __init__(self, aid: bytes, key_settings: int, num_keys: int,
key_type: str = "aes"):
self.aid = aid
self.key_settings = key_settings
self.num_keys = num_keys
self.key_type = key_type
self.keys: list[bytes] = [b"\x00" * 16 for _ in range(num_keys)]
self.files: dict[int, DesfireFile] = {}
class DesfireFile:
"""A DESFire standard data file."""
def __init__(self, file_no: int, comm_settings: int,
access_rights: int, size: int):
self.file_no = file_no
self.comm_settings = comm_settings
self.access_rights = access_rights
self.data = bytearray(size)
class DesfireTag(Tag14443A_4):
"""DESFire EV1/EV2 transponder.
Application/file structure with AES authentication.
"""
def __init__(self, uid: bytes,
atqa: bytes = b"\x44\x03", sak: int = 0x20,
ats: bytes = b"\x06\x75\x77\x81\x02\x80"):
super().__init__(uid=uid, atqa=atqa, sak=sak, ats=ats)
# PICC master application
picc_app = DesfireApp(PICC_AID, key_settings=0x0F, num_keys=1)
self._apps: dict[bytes, DesfireApp] = {PICC_AID: picc_app}
self._selected_app: DesfireApp = picc_app
self._authenticated_key: int | None = None
self._version_state = 0 # for multi-frame GetVersion
def _handle_apdu(self, apdu: bytes) -> bytes:
"""Process DESFire native command (wrapped in ISO-DEP I-block)."""
if not apdu:
return bytes([ST_IE])
cmd = apdu[0]
data = apdu[1:]
match cmd:
case 0x60: # GetVersion
return self._cmd_get_version(data)
case 0xAF: # AdditionalFrame
return self._cmd_additional_frame(data)
case 0x6A: # GetApplicationIDs
return self._cmd_get_app_ids(data)
case 0x5A: # SelectApplication
return self._cmd_select_app(data)
case 0xCA: # CreateApplication
return self._cmd_create_app(data)
case 0xDA: # DeleteApplication
return self._cmd_delete_app(data)
case 0xCD: # CreateStdDataFile
return self._cmd_create_file(data)
case 0xBD: # ReadData
return self._cmd_read_data(data)
case 0x3D: # WriteData
return self._cmd_write_data(data)
case 0xAA: # AuthenticateAES
return self._cmd_auth_aes(data)
return bytes([ST_IE])
def _cmd_get_version(self, data: bytes) -> bytes:
"""GetVersion part 1: hardware info."""
self._version_state = 1
# hw_vendor=0x04(NXP), hw_type=0x01, hw_subtype=0x01, hw_major=1, hw_minor=0
# hw_storage=0x18(8K), hw_protocol=0x05
return bytes([ST_AF, 0x04, 0x01, 0x01, 0x01, 0x00, 0x18, 0x05])
def _cmd_additional_frame(self, data: bytes) -> bytes:
"""Handle additional frame for multi-part responses."""
if self._version_state == 1:
self._version_state = 2
# sw_vendor, sw_type, sw_subtype, sw_major, sw_minor, sw_storage, sw_protocol
return bytes([ST_AF, 0x04, 0x01, 0x01, 0x01, 0x00, 0x18, 0x05])
elif self._version_state == 2:
self._version_state = 0
# UID(7) + batch(5) + production_week + production_year
uid_bytes = self._uid[:7] if len(self._uid) >= 7 else self._uid + b"\x00" * (7 - len(self._uid))
return bytes([ST_OK]) + uid_bytes + b"\x00" * 5 + b"\x01\x1A"
return bytes([ST_IE])
def _cmd_get_app_ids(self, data: bytes) -> bytes:
aids = b""
for aid in self._apps:
if aid != PICC_AID:
aids += aid
return bytes([ST_OK]) + aids
def _cmd_select_app(self, data: bytes) -> bytes:
if len(data) < 3:
return bytes([ST_IE])
aid = data[:3]
if aid in self._apps:
self._selected_app = self._apps[aid]
self._authenticated_key = None
return bytes([ST_OK])
return bytes([ST_NE])
def _cmd_create_app(self, data: bytes) -> bytes:
if len(data) < 5:
return bytes([ST_IE])
aid = data[:3]
key_settings = data[3]
num_keys = data[4] & 0x3F
key_type = "aes" if data[4] & 0x80 else "des"
if aid in self._apps:
return bytes([ST_DE])
self._apps[aid] = DesfireApp(aid, key_settings, max(num_keys, 1), key_type)
return bytes([ST_OK])
def _cmd_delete_app(self, data: bytes) -> bytes:
if len(data) < 3:
return bytes([ST_IE])
aid = data[:3]
if aid not in self._apps or aid == PICC_AID:
return bytes([ST_NE])
del self._apps[aid]
return bytes([ST_OK])
def _cmd_create_file(self, data: bytes) -> bytes:
if len(data) < 7:
return bytes([ST_IE])
file_no = data[0]
comm_settings = data[1]
access_rights = (data[2] << 8) | data[3]
size = data[4] | (data[5] << 8) | (data[6] << 16)
if file_no in self._selected_app.files:
return bytes([ST_DE])
self._selected_app.files[file_no] = DesfireFile(file_no, comm_settings, access_rights, size)
return bytes([ST_OK])
def _cmd_read_data(self, data: bytes) -> bytes:
if len(data) < 7:
return bytes([ST_IE])
file_no = data[0]
offset = data[1] | (data[2] << 8) | (data[3] << 16)
length = data[4] | (data[5] << 8) | (data[6] << 16)
f = self._selected_app.files.get(file_no)
if f is None:
return bytes([ST_FE])
end = min(offset + length, len(f.data))
return bytes([ST_OK]) + bytes(f.data[offset:end])
def _cmd_write_data(self, data: bytes) -> bytes:
if len(data) < 7:
return bytes([ST_IE])
file_no = data[0]
offset = data[1] | (data[2] << 8) | (data[3] << 16)
length = data[4] | (data[5] << 8) | (data[6] << 16)
write_data = data[7:7 + length]
f = self._selected_app.files.get(file_no)
if f is None:
return bytes([ST_FE])
f.data[offset:offset + len(write_data)] = write_data
return bytes([ST_OK])
def _cmd_auth_aes(self, data: bytes) -> bytes:
"""Start AES authentication: return encrypted challenge."""
if len(data) < 1:
return bytes([ST_IE])
key_no = data[0]
if key_no >= self._selected_app.num_keys:
return bytes([ST_AE])
# Generate random challenge (16 bytes for AES)
challenge = os.urandom(16)
self._auth_challenge = challenge
self._auth_key_no = key_no
return bytes([ST_AF]) + challenge
# ---- DesfireReader ----
class DesfireReader:
"""DESFire reader — high-level operations."""
def __init__(self, medium: Medium):
self._medium = medium
self._reader = Reader14443A(medium)
async def _activate(self, uid: bytes) -> None:
await self._reader.select_tag(uid)
await self._reader.rats()
async def _send_cmd(self, cmd: bytes) -> bytes | None:
"""Send DESFire command, return response (status + data)."""
pcb = 0x02
await self._medium.transmit_reader(RFFrame.from_bytes(bytes([pcb]) + cmd))
resp = await self._medium.receive_reader()
if resp is None:
return None
return resp.data[1:] # strip PCB
async def get_version(self, uid: bytes) -> dict | None:
await self._activate(uid)
# Part 1
r1 = await self._send_cmd(b"\x60")
if r1 is None or r1[0] != ST_AF:
return None
# Part 2
r2 = await self._send_cmd(b"\xAF")
if r2 is None or r2[0] != ST_AF:
return None
# Part 3
r3 = await self._send_cmd(b"\xAF")
if r3 is None or r3[0] != ST_OK:
return None
return {
"hw_vendor": r1[1],
"hw_type": r1[2],
"hw_storage": r1[6],
"sw_vendor": r2[1],
"sw_type": r2[2],
"uid": r3[1:8],
}
async def create_application(self, uid: bytes, aid: bytes,
num_keys: int = 1,
key_settings: int = 0x0F) -> dict:
await self._activate(uid)
cmd = bytes([CMD_CREATE_APP]) + aid + bytes([key_settings, num_keys])
resp = await self._send_cmd(cmd)
return {"success": resp is not None and resp[0] == ST_OK}
async def select_application(self, uid: bytes, aid: bytes) -> dict:
await self._activate(uid)
cmd = bytes([CMD_SELECT_APP]) + aid
resp = await self._send_cmd(cmd)
return {"success": resp is not None and resp[0] == ST_OK}

103
pm3py/sim/dual_session.py Normal file
View File

@@ -0,0 +1,103 @@
"""DualInterfaceSession — coordinates RF (PM3) and I2C (MCU) for dual-interface tag sim."""
from __future__ import annotations
from .mcu_bridge import McuBridge
from .mcu_protocol import MsgType
class DualInterfaceSession:
"""Coordinates SimSession (RF) + McuBridge (I2C) around a tag model.
Usage:
session = DualInterfaceSession(sim, mcu, tag)
session.start(trace=True)
# Both RF and I2C now active
session.stop()
"""
def __init__(self, sim_session, mcu: McuBridge, tag):
self.sim = sim_session
self.mcu = mcu
self.tag = tag
# EH threshold model
self.eh_threshold = 500 # ADC mV — field above this = EH active
self._eh_active = False
self._eh_voltage_mv = 3000 # default output voltage when active
def start(self, trace: bool = False):
"""Start both RF sim and MCU bridge, wire all callbacks."""
# Wire MCU callbacks
self.mcu.on_i2c_write = self._on_i2c_write
self.mcu.on_i2c_read_req = self._on_i2c_read_req
# Wire tag ED pin callback → MCU
self.tag.ed_pin_callback = self._on_ed_pin
# Wire SimSession ADC callback → EH model
self.sim.on_field_strength = self._on_field_strength
# Start MCU bridge
self.mcu.start()
# Push initial SRAM state to MCU
if hasattr(self.tag, '_sram'):
self.mcu.send_write_sram(0, bytes(self.tag._sram))
# Start RF sim
self.sim.start_15693(self.tag, trace=trace)
def stop(self):
"""Stop both sessions."""
self.sim.stop()
self.mcu.stop()
# Unhook callbacks
self.mcu.on_i2c_write = None
self.mcu.on_i2c_read_req = None
self.tag.ed_pin_callback = None
self.sim.on_field_strength = None
def _on_i2c_write(self, addr: int, data: bytes):
"""MCU reports DUT wrote to I2C address."""
result = self.tag.i2c_write(addr, data)
if result and self._is_sram_addr(addr):
# SRAM changed — push updated region to PM3 via EML
self._push_sram_to_eml()
def _on_i2c_read_req(self, addr: int, length: int):
"""MCU reports DUT requesting I2C read — respond with tag data."""
data = self.tag.i2c_read(addr, length)
if data is not None:
self.mcu.send_i2c_read_response(data)
else:
# Access denied (arbitration) — send zeros
self.mcu.send_i2c_read_response(bytes(length))
def _on_ed_pin(self, state: bool):
"""Tag model fired ED event — relay to MCU GPIO."""
self.mcu.send_set_ed(state)
def _on_field_strength(self, adc_mv: int):
"""PM3 reported field strength — drive EH threshold model."""
if adc_mv >= self.eh_threshold and not self._eh_active:
self._eh_active = True
self.mcu.send_set_eh(True)
self.tag.set_nfc_field(True)
elif adc_mv < self.eh_threshold and self._eh_active:
self._eh_active = False
self.mcu.send_set_eh(False)
self.tag.set_nfc_field(False)
def _is_sram_addr(self, addr: int) -> bool:
"""Check if I2C address is in the SRAM range (0x2000-0x20FF)."""
return 0x2000 <= addr <= 0x20FF
def _push_sram_to_eml(self):
"""Push current SRAM to PM3 emulator memory.
In a real session, this would call sim.push_eml() to update
the firmware's EML memory. For now, just mark that it happened
so tests can verify the flow.
"""
self._last_sram_push = bytes(self.tag._sram)

99
pm3py/sim/em4100.py Normal file
View File

@@ -0,0 +1,99 @@
"""EM4100/EM4102 — read-only 64-bit Manchester ASK transponder."""
from __future__ import annotations
from .frame import RFFrame
from .lf_base import TagLF, ReaderLF, Modulation
class EM4100Tag(TagLF):
"""EM4100/EM4102 read-only tag.
64-bit data format:
- 9 bits: header (all 1s)
- 10 rows of 5 bits: 4 data bits + 1 even row parity
- 4 bits: column parity (even)
- 1 bit: stop bit (0)
The 40 data bits encode a 5-byte tag ID (10 hex nibbles).
Manchester-encoded ASK modulation.
"""
def __init__(self, tag_id: int):
super().__init__(modulation=Modulation.ASK)
if tag_id < 0 or tag_id > 0xFFFFFFFFFF:
raise ValueError("tag_id must be 0-0xFFFFFFFFFF (40 bits)")
self._tag_id = tag_id
self._encoded = self._encode(tag_id)
@property
def tag_id(self) -> int:
return self._tag_id
@property
def encoded_data(self) -> list[int]:
"""Return 64-bit encoded data as list of 0/1 values."""
return list(self._encoded)
def _get_response(self) -> RFFrame:
"""Return encoded data as RFFrame."""
from bitarray import bitarray
bits = bitarray(self._encoded)
return RFFrame(bits=bits, bit_count=64)
@staticmethod
def _encode(tag_id: int) -> list[int]:
"""Encode 40-bit tag ID into 64-bit EM4100 format."""
# Extract 10 nibbles (4 bits each) from 40-bit ID, MSB first
nibbles = []
for i in range(9, -1, -1):
nibbles.append((tag_id >> (i * 4)) & 0x0F)
data = []
# Header: 9 ones
data.extend([1] * 9)
# 10 rows: 4 data bits + 1 even parity
for nibble in nibbles:
row = [(nibble >> (3 - b)) & 1 for b in range(4)]
parity = sum(row) % 2
data.extend(row)
data.append(parity)
# Column parity: 4 bits, even parity across each column
for col in range(4):
col_sum = sum(data[9 + row * 5 + col] for row in range(10))
data.append(col_sum % 2)
# Stop bit
data.append(0)
assert len(data) == 64
return data
@staticmethod
def decode_data(data: list[int]) -> int:
"""Decode 64-bit EM4100 data to 40-bit tag ID."""
if len(data) != 64:
raise ValueError(f"Expected 64 bits, got {len(data)}")
tag_id = 0
for row in range(10):
start = 9 + row * 5
nibble = 0
for b in range(4):
nibble = (nibble << 1) | data[start + b]
tag_id = (tag_id << 4) | nibble
return tag_id
class EM4100Reader(ReaderLF):
"""EM4100 reader — decode Manchester ASK response."""
def _decode_response(self, resp: RFFrame) -> dict | None:
if resp.bit_count < 64:
return None
data = [resp.bits[i] for i in range(64)]
try:
tag_id = EM4100Tag.decode_data(data)
except (ValueError, IndexError):
return None
return {"tag_id": tag_id}

81
pm3py/sim/frame.py Normal file
View File

@@ -0,0 +1,81 @@
"""RFFrame — bit-level RF communication frame with collision support."""
from __future__ import annotations
from dataclasses import dataclass, field
from bitarray import bitarray
@dataclass(frozen=True, slots=True)
class RFFrame:
"""A frame on the RF medium. Supports sub-byte bit counts for anticollision."""
bits: bitarray
bit_count: int
parity: bitarray | None = None
crc: bytes | None = None
collision_positions: list[int] | None = None
timestamp_us: int = 0
@property
def data(self) -> bytes:
"""Full bytes (truncates partial trailing bits)."""
full_bytes = self.bit_count // 8
if full_bytes == 0:
return b""
return self.bits[:full_bytes * 8].tobytes()
@property
def has_collision(self) -> bool:
return self.collision_positions is not None and len(self.collision_positions) > 0
@classmethod
def from_bytes(cls, data: bytes, **kw) -> RFFrame:
bits = bitarray()
bits.frombytes(data)
return cls(bits=bits, bit_count=len(data) * 8, **kw)
@classmethod
def from_hex(cls, hex_str: str, **kw) -> RFFrame:
return cls.from_bytes(bytes.fromhex(hex_str), **kw)
@classmethod
def merge(cls, frames: list[RFFrame]) -> RFFrame | None:
"""Bit-level collision merge per ISO 14443-A rules.
At each bit position: if all frames agree, that bit is clean.
If they disagree, it's a collision (defaults to 1 per Manchester encoding).
"""
if not frames:
return None
if len(frames) == 1:
f = frames[0]
return cls(bits=bitarray(f.bits), bit_count=f.bit_count,
collision_positions=[])
max_bits = max(f.bit_count for f in frames)
result_bits = bitarray(max_bits)
collision_positions: list[int] = []
for i in range(max_bits):
values = set()
for f in frames:
if i < f.bit_count:
values.add(f.bits[i])
if len(values) > 1:
collision_positions.append(i)
result_bits[i] = 1 # collision defaults to 1 per ISO 14443-A
elif values:
result_bits[i] = values.pop()
else:
result_bits[i] = 0
return cls(bits=result_bits, bit_count=max_bits,
collision_positions=collision_positions)
def __repr__(self) -> str:
hex_str = self.data.hex().upper() if self.data else ""
extra = self.bit_count % 8
if extra:
hex_str += f"+{extra}b"
col = f" COL@{self.collision_positions}" if self.has_collision else ""
return f"RFFrame({hex_str}{col})"

97
pm3py/sim/fuzzer.py Normal file
View File

@@ -0,0 +1,97 @@
"""Protocol fuzzing — mutation and grammar-based frame generation."""
from __future__ import annotations
import random as _random
from typing import Iterator
from bitarray import bitarray
from .frame import RFFrame
from .iso14443a import CL1, NVB_SELECT, _compute_bcc
class MutationFuzzer:
"""Mutate valid protocol frames to find edge cases."""
def __init__(self, seed_frames: list[RFFrame], seed: int | None = None):
self._seeds = seed_frames
self._rng = _random.Random(seed)
def mutate(self, frame: RFFrame) -> RFFrame:
"""Apply random mutation to a frame."""
strategy = self._rng.choice([
self._flip_bits, self._truncate, self._extend,
self._corrupt_byte,
])
return strategy(frame)
def generate(self, count: int = 100) -> Iterator[RFFrame]:
"""Yield mutated frames."""
for _ in range(count):
seed = self._rng.choice(self._seeds)
yield self.mutate(seed)
def _flip_bits(self, frame: RFFrame) -> RFFrame:
if frame.bit_count == 0:
return frame
bits = bitarray(frame.bits[:frame.bit_count])
num_flips = self._rng.randint(1, max(1, frame.bit_count // 4))
for _ in range(num_flips):
pos = self._rng.randint(0, len(bits) - 1)
bits[pos] = 1 - bits[pos]
return RFFrame(bits=bits, bit_count=len(bits))
def _truncate(self, frame: RFFrame) -> RFFrame:
if frame.bit_count <= 1:
return frame
new_count = self._rng.randint(1, frame.bit_count - 1)
bits = bitarray(frame.bits[:new_count])
return RFFrame(bits=bits, bit_count=new_count)
def _extend(self, frame: RFFrame) -> RFFrame:
extra = self._rng.randint(1, 16)
bits = bitarray(frame.bits[:frame.bit_count])
for _ in range(extra):
bits.append(self._rng.randint(0, 1))
return RFFrame(bits=bits, bit_count=len(bits))
def _corrupt_byte(self, frame: RFFrame) -> RFFrame:
if not frame.data:
return frame
data = bytearray(frame.data)
pos = self._rng.randint(0, len(data) - 1)
data[pos] = self._rng.randint(0, 255)
return RFFrame.from_bytes(bytes(data))
class GrammarFuzzer:
"""Protocol-aware fuzzer that generates frames from grammar rules."""
def __init__(self, protocol: str = "14443a"):
self._protocol = protocol
def generate(self, command: str, **overrides) -> RFFrame | None:
"""Generate a frame for a specific command with optional field overrides."""
if self._protocol == "14443a":
return self._gen_14443a(command, **overrides)
return None
def _gen_14443a(self, command: str, **overrides) -> RFFrame | None:
match command:
case "REQA":
return RFFrame.from_bytes(b"\x26")
case "WUPA":
return RFFrame.from_bytes(b"\x52")
case "SELECT":
uid = overrides.get("uid", b"\x00\x00\x00\x00")
bcc = _compute_bcc(uid)
return RFFrame.from_bytes(bytes([CL1, NVB_SELECT]) + uid + bytes([bcc]))
case "ANTICOL":
nvb = overrides.get("nvb", 0x20)
sel = overrides.get("sel", CL1)
return RFFrame.from_bytes(bytes([sel, nvb]))
case "RATS":
cid = overrides.get("cid", 0)
return RFFrame.from_bytes(bytes([0xE0, 0x50 | (cid & 0x0F)]))
case "HLTA":
return RFFrame.from_bytes(b"\x50\x00")
return None

121
pm3py/sim/hid.py Normal file
View File

@@ -0,0 +1,121 @@
"""HID Proximity — FSK modulated Wiegand credential transponder."""
from __future__ import annotations
from .frame import RFFrame
from .lf_base import TagLF, ReaderLF, Modulation
# Wiegand format definitions
FORMATS = {
"H10301": {"bits": 26, "fc_bits": 8, "cn_bits": 16},
"H10304": {"bits": 37, "fc_bits": 16, "cn_bits": 19},
"C1000_35": {"bits": 35, "fc_bits": 12, "cn_bits": 20},
}
class HIDProxTag(TagLF):
"""HID Proximity card — FSK modulated, Wiegand-format credential."""
def __init__(self, facility_code: int, card_number: int,
format: str = "H10301"):
super().__init__(modulation=Modulation.FSK)
if format not in FORMATS:
raise ValueError(f"Unknown format: {format}. Use: {list(FORMATS)}")
self._facility_code = facility_code
self._card_number = card_number
self._format = format
self._wiegand = self._encode_wiegand(facility_code, card_number, format)
@property
def facility_code(self) -> int:
return self._facility_code
@property
def card_number(self) -> int:
return self._card_number
@property
def format(self) -> str:
return self._format
@property
def wiegand_bits(self) -> list[int]:
return list(self._wiegand)
def _get_response(self) -> RFFrame:
from bitarray import bitarray
bits = bitarray(self._wiegand)
return RFFrame(bits=bits, bit_count=len(self._wiegand))
@staticmethod
def _encode_wiegand(fc: int, cn: int, fmt: str) -> list[int]:
"""Encode facility code + card number into Wiegand bit stream."""
f = FORMATS[fmt]
total = f["bits"]
fc_bits = f["fc_bits"]
cn_bits = f["cn_bits"]
# Data bits: FC + CN (MSB first)
data = []
for i in range(fc_bits - 1, -1, -1):
data.append((fc >> i) & 1)
for i in range(cn_bits - 1, -1, -1):
data.append((cn >> i) & 1)
if total == 26:
# H10301: EP(1) + FC(8) + CN(16) + OP(1)
even_parity = sum(data[:12]) % 2
odd_parity = 1 - (sum(data[12:]) % 2)
return [even_parity] + data + [odd_parity]
elif total == 37:
# H10304: EP(1) + FC(16) + CN(19) + OP(1)
even_parity = sum(data[:18]) % 2
odd_parity = 1 - (sum(data[18:]) % 2)
return [even_parity] + data + [odd_parity]
elif total == 35:
# C1000_35: EP(1) + FC(12) + CN(20) + EP(1) + OP(1)
# Simplified: EP + data + OP
even_parity = sum(data[:16]) % 2
odd_parity = 1 - (sum(data[16:]) % 2)
return [even_parity] + data + [odd_parity]
else:
return data
@staticmethod
def decode_wiegand(bits: list[int], fmt: str) -> dict:
"""Decode Wiegand bits to facility code + card number."""
f = FORMATS[fmt]
fc_bits = f["fc_bits"]
cn_bits = f["cn_bits"]
# Strip parity bits (first and last)
data = bits[1:-1]
fc = 0
for i in range(fc_bits):
fc = (fc << 1) | data[i]
cn = 0
for i in range(cn_bits):
cn = (cn << 1) | data[fc_bits + i]
return {"facility_code": fc, "card_number": cn}
class HIDReader(ReaderLF):
"""HID Proximity reader — decode FSK + Wiegand credential."""
def __init__(self, medium, format: str = "H10301"):
super().__init__(medium)
self._format = format
async def read_credential(self) -> dict | None:
"""Read and decode HID credential."""
result = await self.read_id()
return result
def _decode_response(self, resp: RFFrame) -> dict | None:
f = FORMATS.get(self._format)
if f is None or resp.bit_count < f["bits"]:
return None
bits = [resp.bits[i] for i in range(f["bits"])]
return HIDProxTag.decode_wiegand(bits, self._format)

414
pm3py/sim/icode3.py Normal file
View File

@@ -0,0 +1,414 @@
"""ICODE 3 (SL2S3003) — NXP next-gen vicinity label IC.
Extends IcodeSlix2Tag with:
- 6th password (configuration, pwd_id 0x20)
- 76 user blocks, block 75 = 24-bit counter
- Configuration memory (128 blocks) via READ CONFIG / WRITE CONFIG
- TagTamper status via READ TT (TT variant)
- NFC ASCII mirror (UID, counter, TagTamper into user memory as hex)
- Privacy mode 2 (responds to inventory with zeroed/random UID)
- PICK RANDOM ID (0xC2) for privacy mode 2
- Richer feature flags in GET NXP SYSTEM INFO
"""
from __future__ import annotations
import os
import struct
from .frame import RFFrame
from .icode_slix2 import IcodeSlix2Tag, PWD_READ, PWD_WRITE
from .nxp_icode import NXP_MFG
# ICODE 3 password ID
PWD_CONFIG = 0x20
# Config memory password block addresses (read masks with 0x00)
_CONFIG_PWD_BLOCKS = {41, 42, 43, 44, 45, 46}
# ICODE 3 feature flags (Table 113)
_FEATURE_UM_PP = 1 << 0
_FEATURE_COUNTER = 1 << 1
_FEATURE_EAS_ID = 1 << 2
_FEATURE_EAS_PP = 1 << 3
_FEATURE_AFI_PP = 1 << 4
_FEATURE_INVENTORY_READ_EXT = 1 << 5
_FEATURE_EAS_IR = 1 << 6
_FEATURE_CID = 1 << 7
_FEATURE_ORIGINALITY_SIG = 1 << 8
_FEATURE_TAGTAMPER = 1 << 9
_FEATURE_P_QUIET = 1 << 10
_FEATURE_NFC_MIRROR = 1 << 11
_FEATURE_PRIVACY = 1 << 12
_FEATURE_DESTROY = 1 << 13
_FEATURE_WRITE_CID = 1 << 14
_FEATURE_HIGH_DATA_RATES = 1 << 15
class Icode3Tag(IcodeSlix2Tag):
"""ICODE 3 transponder (SL2S3003 / SL2S3003TT).
Extends IcodeSlix2Tag with configuration memory, 6th password,
24-bit counter, and TagTamper support.
"""
# ICODE 3: TAG type 0x01, type indicator bits 39:36 = 0100 → byte bits 6:3 = 0100 → 0x20
_uid_prefix = b"\xE0\x04\x01\x20"
def __init__(self, uid: bytes | str | None = None,
config_password: int | None = None,
tag_tamper: bool = False,
signature: bytes | None = None,
**kwargs):
kwargs.setdefault("num_blocks", 76)
super().__init__(uid=uid, signature=signature, **kwargs)
# 48-byte signature mode (OS_CFG_MODE bit, SL2S3003 8.5.3.20)
self._signature_48 = False
# 6th password
self._passwords[PWD_CONFIG] = config_password
# Configuration memory: 128 blocks × 4 bytes
self._config_memory = bytearray(128 * 4)
self._config_password_protected = False
self._config_locked = False
# Initialize config memory with defaults
self._init_config_defaults()
# TagTamper (TT variant only)
self._tag_tamper_supported = tag_tamper
self._tt_status_actual = 0x43 # 'C' = Closed
self._tt_status_stored = 0x43
# NFC ASCII mirror
self._nfc_mirror_sel = 0 # 0=off, 1=UID, 2=UID+counter, 3=UID+counter+TT
self._nfc_mirror_block = 0
self._nfc_mirror_byte = 0
# Privacy mode 2 support
self._privacy_mode_sel = 1 # 1=mode 1 (strict), 2=mode 2 (permissive)
self._random_id: bytes | None = None # set by PICK RANDOM ID
# Rebuild access map for 24-bit counter on block 75
self._build_access_map()
def set_nfc_mirror(self, sel: int, block: int, byte_offset: int = 0) -> None:
"""Configure NFC ASCII mirror.
sel: 0=off, 1=UID, 2=UID+counter, 3=UID+counter+TT msg (TT only)
block: starting user memory block
byte_offset: starting byte within the block (0-3)
"""
self._nfc_mirror_sel = sel
self._nfc_mirror_block = block
self._nfc_mirror_byte = byte_offset
def _compute_mirror_data(self) -> bytes:
"""Compute the ASCII hex mirror data based on current settings."""
parts = []
if self._nfc_mirror_sel >= 1:
# UID: 8 bytes → 16 ASCII hex chars, MSB first
parts.append(self._uid.hex().upper().encode())
if self._nfc_mirror_sel >= 2:
# Separator + counter: 3 bytes → 6 ASCII hex chars, MSB first
parts.append(b"x")
offset = 75 * self._block_size
counter_le = self._memory[offset:offset + 3]
counter_val = int.from_bytes(counter_le, 'little')
parts.append(f"{counter_val:06X}".encode())
if self._nfc_mirror_sel >= 3 and self._tag_tamper_supported:
# Separator + TT open message (4 bytes from config block 47) as hex
parts.append(b"x")
tt_msg = self._config_memory[47 * 4:47 * 4 + 4]
parts.append(tt_msg.hex().upper().encode())
return b"".join(parts)
# ----- Read override for NFC mirror -----
def _handle_read_single(self, frame: RFFrame) -> RFFrame | None:
"""Override to apply NFC mirror overlay on reads."""
if self._nfc_mirror_sel == 0:
return super()._handle_read_single(frame)
block = self._extract_block_number(frame)
if block is None:
return None
# Check read protection (inherited)
if self._protection_pointer is not None and block >= self._protection_pointer:
if PWD_READ not in self._authenticated_passwords:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
# Get physical data
offset = block * self._block_size
phys = bytearray(self._memory[offset:offset + self._block_size])
# Apply mirror overlay
mirror_data = self._compute_mirror_data()
mirror_start_abs = self._nfc_mirror_block * self._block_size + self._nfc_mirror_byte
mirror_end_abs = mirror_start_abs + len(mirror_data)
block_start_abs = block * self._block_size
block_end_abs = block_start_abs + self._block_size
# Check overlap
overlap_start = max(mirror_start_abs, block_start_abs)
overlap_end = min(mirror_end_abs, block_end_abs)
if overlap_start < overlap_end:
mirror_offset = overlap_start - mirror_start_abs
block_offset = overlap_start - block_start_abs
length = overlap_end - overlap_start
phys[block_offset:block_offset + length] = mirror_data[mirror_offset:mirror_offset + length]
return RFFrame.from_bytes(bytes([0x00]) + bytes(phys))
# ----- Privacy mode 2 override -----
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
if self._destroyed:
return None
if len(frame.data) < 2:
return None
cmd = frame.data[1]
if self._privacy_mode:
if self._privacy_mode_sel == 2:
return await self._handle_privacy_mode2(frame, cmd)
else:
# Mode 1: strict — only GET_RANDOM + SET_PASSWORD
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
if cmd == 0xB2:
return self._handle_get_random(frame)
if cmd == 0xB3:
return self._handle_set_password(frame)
return None
return await super(IcodeSlix2Tag, self).handle_frame(frame)
async def _handle_privacy_mode2(self, frame: RFFrame, cmd: int) -> RFFrame | None:
"""Privacy mode 2: allows inventory (zeroed/random UID), reads, system info, etc."""
# Swap UID temporarily for inventory/system info
real_uid = self._uid
display_uid = self._random_id if self._random_id else b"\xE0\x04\x00\x00\x00\x00\x00\x00"
try:
self._uid = display_uid
# Custom commands that are allowed
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
if cmd == 0xB2: # GET_RANDOM
return self._handle_get_random(frame)
if cmd == 0xB3: # SET_PASSWORD (unlocks privacy)
result = self._handle_set_password(frame)
if result and result.data[0] == 0x00 and frame.data[3] == 0x04:
self._privacy_mode = False
self._random_id = None
return result
if cmd == 0xBC: # STAY QUIET PERSISTENT
frame_n = self._normalize_custom_frame(frame)
return self._handle_stay_quiet_persistent(frame_n)
if cmd == 0xC2: # PICK RANDOM ID
return self._handle_pick_random_id(frame)
# Standard ISO commands allowed in mode 2
allowed_std = {0x01, 0x02, 0x20, 0x23, 0x25, 0x26, 0x2B}
if cmd in allowed_std:
return await super(IcodeSlix2Tag, self).handle_frame(frame)
return None
finally:
self._uid = real_uid
def _handle_set_password(self, frame: RFFrame) -> RFFrame | None:
"""Override: in privacy mode 2, SET_PASSWORD with privacy pwd exits privacy."""
result = super()._handle_set_password(frame)
if (result is not None and result.data[0] == 0x00 and
self._privacy_mode and frame.data[3] == 0x04):
self._privacy_mode = False
self._random_id = None
return result
def _init_config_defaults(self):
"""Initialize config memory with factory defaults."""
# Block 16: DSFID
self._config_memory[16 * 4] = self._dsfid
# Block 17: AFI
self._config_memory[17 * 4] = self._afi
# Store passwords in config memory (write-only, read-masked)
for pwd_id, cfg_block in [(PWD_CONFIG, 41), (PWD_READ, 42),
(PWD_WRITE, 43), (0x04, 44),
(0x08, 45), (0x10, 46)]:
pwd = self._passwords.get(pwd_id)
if pwd is not None:
offset = cfg_block * 4
struct.pack_into("<I", self._config_memory, offset, pwd)
def _build_access_map(self) -> None:
"""Override: block 75 = 24-bit counter (not block 79 = 16-bit)."""
has_pp = self._protection_pointer is not None
from .memory import BlockAccess
user = self.regions["user"]
access_map = []
for block in range(self._num_blocks):
ba = BlockAccess()
if has_pp and block >= self._protection_pointer:
ba = BlockAccess(read="password", read_key=PWD_READ)
if block == 75:
ba = BlockAccess(read=ba.read, read_key=ba.read_key,
write_mode="counter24")
access_map.append(ba)
user.access_map = access_map
# ----- 48-byte originality signature (SL2S3003 8.5.3.20) -----
def set_signature_mode_48(self, enabled: bool) -> None:
"""Enable/disable 48-byte originality signature mode (OS_CFG_MODE)."""
self._signature_48 = enabled
def _handle_read_signature(self, frame: RFFrame) -> RFFrame | None:
"""READ SIGNATURE: returns 32 or 48 bytes depending on OS_CFG_MODE."""
if self._signature_48:
# Pad/extend signature to 48 bytes
sig = (self._signature + bytes(48))[:48]
return RFFrame.from_bytes(bytes([0x00]) + sig)
# Default 32-byte mode: truncate to 32 bytes
sig = (self._signature + bytes(32))[:32]
return RFFrame.from_bytes(bytes([0x00]) + sig)
# ----- Command dispatch (ICODE 3-specific) -----
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
frame = self._normalize_custom_frame(frame)
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
match cmd:
case 0xC0: # READ CONFIG
return self._handle_read_config(frame)
case 0xC1: # WRITE CONFIG
return self._handle_write_config(frame)
case 0xC2: # PICK RANDOM ID
return self._handle_pick_random_id(frame)
case 0xC4: # READ TT
return self._handle_read_tt(frame)
case 0xAB: # GET NXP SYSTEM INFO — override with ICODE 3 data
return self._handle_get_nxp_system_info(frame)
return super()._handle_custom_command(cmd, frame)
# ----- PICK RANDOM ID (0xC2) -----
def _handle_pick_random_id(self, frame: RFFrame) -> RFFrame | None:
"""PICK RANDOM ID (0xC2): generate random UID for privacy mode 2.
Random ID format: E0 04 00 00 CID1 CID0 RID1 RID0
CID = customer ID from config, RID = random 16-bit.
"""
if not self._privacy_mode:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
# CID from config memory block 19 (CID0, CID1)
cid0 = self._config_memory[19 * 4]
cid1 = self._config_memory[19 * 4 + 1]
rid = os.urandom(2)
self._random_id = bytes([0xE0, 0x04, 0x00, 0x00, cid1, cid0, rid[0], rid[1]])
return RFFrame.from_bytes(bytes([0x00]))
# ----- READ CONFIG (0xC0) -----
def _handle_read_config(self, frame: RFFrame) -> RFFrame | None:
"""READ CONFIG (0xC0): read config memory blocks.
Format: flags(1) + cmd(1) + mfg(1) + block_addr(1) + num_blocks(1)
Response: flags(1) + data(4 * (num_blocks+1))
Passwords (blocks 41-46) are masked with 0x00.
"""
if len(frame.data) < 5:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
block_addr = frame.data[3]
num_blocks = frame.data[4] + 1 # 0 = 1 block
if block_addr + num_blocks > 128:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
resp = bytearray([0x00]) # flags
for blk in range(block_addr, block_addr + num_blocks):
offset = blk * 4
if blk in _CONFIG_PWD_BLOCKS:
resp.extend(bytes(4)) # masked
else:
resp.extend(self._config_memory[offset:offset + 4])
return RFFrame.from_bytes(bytes(resp))
# ----- WRITE CONFIG (0xC1) -----
def _handle_write_config(self, frame: RFFrame) -> RFFrame | None:
"""WRITE CONFIG (0xC1): write 4 bytes to config memory block.
Format: flags(1) + cmd(1) + mfg(1) + block_addr(1) + data(4)
"""
if len(frame.data) < 8:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if self._config_locked:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if self._config_password_protected:
if PWD_CONFIG not in self._authenticated_passwords:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
block_addr = frame.data[3]
if block_addr >= 128:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
data = frame.data[4:8]
offset = block_addr * 4
self._config_memory[offset:offset + 4] = data
return RFFrame.from_bytes(bytes([0x00]))
# ----- READ TT (0xC4) -----
def _handle_read_tt(self, frame: RFFrame) -> RFFrame | None:
"""READ TT (0xC4): TagTamper status.
Response: flags(1) + actual_status(1) + stored_status(1)
"""
if not self._tag_tamper_supported:
return None
return RFFrame.from_bytes(bytes([
0x00, self._tt_status_actual, self._tt_status_stored
]))
# ----- GET NXP SYSTEM INFO override -----
def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None:
"""GET NXP SYSTEM INFO with ICODE 3 feature flags."""
pp = self._protection_pointer if self._protection_pointer is not None else 0
pp_cond = 0x00
if self._protection_pointer is not None:
pp_cond = 0x01 # read protection
lock_bits = 0x00
feature_flags = (
_FEATURE_UM_PP | _FEATURE_COUNTER | _FEATURE_EAS_ID |
_FEATURE_ORIGINALITY_SIG | _FEATURE_P_QUIET |
_FEATURE_PRIVACY | _FEATURE_DESTROY | _FEATURE_HIGH_DATA_RATES
)
if self._tag_tamper_supported:
feature_flags |= _FEATURE_TAGTAMPER
return RFFrame.from_bytes(
bytes([0x00, pp, pp_cond, lock_bits]) +
struct.pack("<I", feature_flags)
)

276
pm3py/sim/icode_dna.py Normal file
View File

@@ -0,0 +1,276 @@
"""ICODE DNA (SL2S6002) — NXP IC with AES-128 mutual authentication.
Extends IcodeSlix2Tag with:
- AES-128 crypto coprocessor (4 keys: Key0-Key3, TAM/MAM)
- 64 blocks (63 user + block 63 = 16-bit counter)
- 48-block configuration memory (READ/WRITE CONFIG)
- Key headers, key privileges, authentication limit
- Flexible memory segmentation with AES-based access conditions
- No TagTamper, no NFC mirror (those are ICODE 3 features)
TAM/MAM authentication uses undocumented AuthMethod values:
0x00 = TAM1, 0x02 = MAM1, 0x06 = MAM2
Standard 0x80/0x90 NOT supported (Error 0x0F).
All data byte-reversed on wire. See docs/NTAG5_SECURITY.md.
The ICODE DNA shares the AES auth protocol with NTAG 5 Link (NTP5332)
and NTAG 5 Boost (NTA5332).
"""
from __future__ import annotations
import struct
from .auth_aes import (
NxpAesAuth,
KEY_HEADER_NOT_ACTIVE,
KEY_HEADER_ACTIVE_LOCKED,
KEY_HEADER_DISABLED,
PRIV_READ,
PRIV_WRITE,
PRIV_PRIVACY,
PRIV_DESTROY,
PRIV_EAS_AFI,
PRIV_CRYPTO_CONFIG,
PRIV_AREA1_READ,
PRIV_AREA1_WRITE,
GCH_DEACTIVATED,
GCH_DEACTIVATED_PRIV_LOCKED,
GCH_ACTIVATED,
GCH_ACTIVATED_PRIV_LOCKED,
GCH_FINAL,
)
from .frame import RFFrame
from .icode_slix2 import IcodeSlix2Tag, PWD_READ
from .nxp_icode import NXP_MFG
# Config memory password/key block addresses (read-masked with 0x00)
_CONFIG_KEY_BLOCKS = set(range(0x20, 0x30)) # KEY0-KEY3 (4 blocks each)
# Config Header values
CONFIG_HEADER_WRITABLE = 0x81
CONFIG_HEADER_LOCKED = 0xE7
class IcodeDnaTag(NxpAesAuth, IcodeSlix2Tag):
"""ICODE DNA transponder (SL2S6002).
Features beyond IcodeSlix2Tag:
- AES-128 tag/mutual authentication (4 keys)
- 64 user blocks, block 63 = 16-bit counter
- 48-block configuration memory
- Key headers, privileges, and authentication limit
Hierarchy: NxpIcodeTag → IcodeSlixTag → IcodeSlix2Tag → IcodeDnaTag
"""
# Verified: real DNA UID E0 04 01 18 00 9C 78 2B → TagInfo = "ICODE DNA (SL2S6002)"
_uid_prefix = b"\xE0\x04\x01\x18"
def __init__(self, uid: bytes | str | None = None,
aes_keys: list[bytes | None] | None = None,
**kwargs):
kwargs.setdefault("num_blocks", 64)
kwargs.setdefault("ic_reference", 0x01)
super().__init__(uid=uid, **kwargs)
# Configuration memory: 48 blocks × 4 bytes
self._config_memory = bytearray(48 * 4)
# AES key management (from NxpAesAuth mixin)
self._init_aes_auth(aes_keys=aes_keys)
# Config Header — controls lock state of signature + CID
self._config_header = CONFIG_HEADER_WRITABLE
# CID (Customer ID) — default 0xC000
self._cid = 0xC000
# Initialize config memory defaults
self._init_dna_config()
def _init_dna_config(self):
"""Initialize config memory with DNA factory defaults."""
# Blocks 0x00-0x07: Originality signature (32 bytes)
sig = getattr(self, '_signature', bytes(32))
self._config_memory[0:32] = sig
# Block 0x08: Config Header
self._config_memory[0x08 * 4] = self._config_header
# Block 0x09: CID
struct.pack_into("<H", self._config_memory, 0x09 * 4, self._cid)
# Blocks 0x10, 0x12, 0x14, 0x16: Key Headers (byte 1)
for i in range(4):
block = 0x10 + i * 2
self._config_memory[block * 4 + 1] = self._key_headers[i]
# Blocks 0x11, 0x13, 0x15, 0x17: Key Privileges (byte 0)
for i in range(4):
block = 0x11 + i * 2
self._config_memory[block * 4] = self._key_privileges[i]
# Blocks 0x20-0x2F: AES keys (4 × 16 bytes)
for i, key in enumerate(self._aes_keys):
if key is not None:
offset = (0x20 + i * 4) * 4
self._config_memory[offset:offset + 16] = key[:16]
def _build_access_map(self) -> None:
"""Override: counter on last block."""
has_pp = self._protection_pointer is not None
last_block = self._num_blocks - 1
if not has_pp and last_block < 0:
self.regions["user"].access_map = None
return
from .memory import BlockAccess
user = self.regions["user"]
access_map = []
for block in range(self._num_blocks):
ba = BlockAccess()
if has_pp and block >= self._protection_pointer:
ba = BlockAccess(read="password", read_key=PWD_READ)
if block == last_block:
ba = BlockAccess(read=ba.read, read_key=ba.read_key,
write_mode="counter")
access_map.append(ba)
user.access_map = access_map
# ----- Command dispatch -----
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
"""Override to intercept ISO 29167-10 CHALLENGE/READBUFFER/AUTHENTICATE.
Commands 0x35 (AUTHENTICATE), 0x39 (CHALLENGE) and 0x3A (READBUFFER)
are standard ISO commands, not NXP custom (>= 0xA0), so they must be
caught here before the base class returns "not supported".
"""
if len(frame.data) >= 2:
cmd = frame.data[1]
if cmd == 0x35: # AUTHENTICATE (MAM1/MAM2)
return self._handle_authenticate(frame)
if cmd == 0x39: # CHALLENGE
self._handle_challenge(frame)
return None # no RF response
if cmd == 0x3A: # READBUFFER
return self._handle_readbuffer(frame)
return await super().handle_frame(frame)
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
frame = self._normalize_custom_frame(frame)
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
match cmd:
case 0xC0: # READ CONFIG
return self._handle_read_config(frame)
case 0xC1: # WRITE CONFIG
return self._handle_write_config(frame)
return super()._handle_custom_command(cmd, frame)
# ----- READ CONFIG -----
def _handle_read_config(self, frame: RFFrame) -> RFFrame | None:
"""READ CONFIG (0xC0): read config memory blocks.
Key blocks (0x20-0x2F) masked with 0x00 when key header is active (0xE7).
"""
if len(frame.data) < 5:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
block_addr = frame.data[3]
num_blocks = frame.data[4] + 1
if block_addr + num_blocks > 48:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
resp = bytearray([0x00])
for blk in range(block_addr, block_addr + num_blocks):
offset = blk * 4
# Mask key storage when header is active/locked
if blk in _CONFIG_KEY_BLOCKS:
key_idx = (blk - 0x20) // 4
if key_idx < 4 and self._key_headers[key_idx] == KEY_HEADER_ACTIVE_LOCKED:
resp.extend(bytes(4))
continue
resp.extend(self._config_memory[offset:offset + 4])
return RFFrame.from_bytes(bytes(resp))
# ----- WRITE CONFIG -----
def _handle_write_config(self, frame: RFFrame) -> RFFrame | None:
"""WRITE CONFIG (0xC1): write 4 bytes to config block."""
if len(frame.data) < 8:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
block_addr = frame.data[3]
if block_addr >= 48:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
data = frame.data[4:8]
# Key storage: reject writes when header is active
if block_addr in _CONFIG_KEY_BLOCKS:
key_idx = (block_addr - 0x20) // 4
if key_idx < 4 and self._key_headers[key_idx] == KEY_HEADER_ACTIVE_LOCKED:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
offset = block_addr * 4
self._config_memory[offset:offset + 4] = data
# Track key header changes
kh_blocks = {0x10: 0, 0x12: 1, 0x14: 2, 0x16: 3}
if block_addr in kh_blocks:
key_idx = kh_blocks[block_addr]
new_header = data[1] # Key header is byte 1
# One-way: only allow lower → higher values
if new_header >= self._key_headers[key_idx]:
self._key_headers[key_idx] = new_header
# Track key privilege changes
kp_blocks = {0x11: 0, 0x13: 1, 0x15: 2, 0x17: 3}
if block_addr in kp_blocks:
key_idx = kp_blocks[block_addr]
self._key_privileges[key_idx] = data[0]
# Track GCH changes (block 0x0C, byte 1)
if block_addr == 0x0C:
new_gch = data[1]
if new_gch >= self._nfc_gch: # one-way
self._nfc_gch = new_gch
# Track config header changes (block 0x08, byte 0)
if block_addr == 0x08:
new_ch = data[0]
if new_ch >= self._config_header: # one-way
self._config_header = new_ch
return RFFrame.from_bytes(bytes([0x00]))
# ----- GET NXP SYSTEM INFO override -----
def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None:
"""GET NXP SYSTEM INFO with DNA feature flags."""
pp = self._protection_pointer if self._protection_pointer is not None else 0
pp_cond = 0x01 if self._protection_pointer is not None else 0x00
lock_bits = 0x00
# DNA feature flags (similar to ICODE 3 Table 113 but without TagTamper/mirror)
feature_flags = (
(1 << 0) | # UM_PP
(1 << 1) | # COUNTER
(1 << 2) | # EAS_ID
(1 << 3) | # EAS_PP
(1 << 4) | # AFI_PP
(1 << 8) | # ORIGINALITY_SIG
(1 << 10) | # P_QUIET
(1 << 12) | # PRIVACY
(1 << 13) | # DESTROY
(1 << 15) # HIGH_DATA_RATES
)
return RFFrame.from_bytes(
bytes([0x00, pp, pp_cond, lock_bits]) +
struct.pack("<I", feature_flags)
)

169
pm3py/sim/icode_slix.py Normal file
View File

@@ -0,0 +1,169 @@
"""ICODE SLIX (SL2S2002) — NXP IC with EAS and single password.
This is the shared base for SLIX, SLIX2, and ICODE 3. Contains:
- EAS subsystem (SET/RESET/LOCK, ALARM, PROTECT EAS/AFI, WRITE EAS ID)
- INVENTORY READ / FAST INVENTORY READ
- Frame normalization for addressed custom commands
Password auth (SET_PASSWORD, WRITE_PASSWORD, LOCK_PASSWORD, GET_RANDOM,
XOR verification) is provided by the NxpPasswordAuth mixin.
"""
from __future__ import annotations
import struct
from .auth_password import NxpPasswordAuth
from .frame import RFFrame
from .nxp_icode import NxpIcodeTag, NXP_MFG
# Password ID shared across all SLIX variants
PWD_EAS_AFI = 0x10
class IcodeSlixTag(NxpPasswordAuth, NxpIcodeTag):
"""ICODE SLIX transponder (SL2S2002).
Features beyond NxpIcodeTag:
- 1 password: EAS/AFI (32-bit, XOR'd with GET_RANDOM)
- EAS (Electronic Article Surveillance) with lock
- INVENTORY READ / FAST INVENTORY READ
- WRITE PASSWORD, LOCK PASSWORD
Subclasses (SLIX2, ICODE 3) add more passwords and features.
"""
_uid_prefix = b"\xE0\x04\x01\x10" # TAG type 0x01, bits 37:36 = 10 → byte bits 4:3 = 10
def __init__(self, uid: bytes | str | None = None,
eas_afi_password: int | None = None,
**kwargs):
# Default to 28 blocks for SLIX OG; subclasses override
kwargs.setdefault("num_blocks", 28)
kwargs.setdefault("ic_reference", 0x01) # SLIX IC type per SL2S2002
super().__init__(uid=uid, **kwargs)
# Initialize password auth from mixin
self._init_password_auth({PWD_EAS_AFI: eas_afi_password})
# EAS state
self._eas_enabled = False
self._eas_locked = False
self._eas_password_protected = False
self._eas_id: int = 0
self._eas_sequence = bytes(32) # 256-bit EAS sequence
@property
def eas_enabled(self) -> bool:
return self._eas_enabled
def set_eas(self, enabled: bool) -> None:
self._eas_enabled = enabled
# ----- Frame normalization -----
def _normalize_custom_frame(self, frame: RFFrame) -> RFFrame:
"""Normalize addressed custom commands by stripping UID.
Addressed: flags(1) + cmd(1) + uid(8) + mfg(1) + data...
Unaddressed: flags(1) + cmd(1) + mfg(1) + data...
Returns unaddressed-format frame for uniform processing.
"""
flags = frame.data[0]
if (flags & 0x20) and not (flags & 0x04): # addressed, not inventory
normalized = bytes([flags & ~0x20]) + frame.data[1:2] + frame.data[10:]
return RFFrame.from_bytes(normalized)
return frame
# ----- Custom command dispatch -----
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
"""Handle SLIX custom commands, fall through to NXP base."""
frame = self._normalize_custom_frame(frame)
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
match cmd:
case 0xA0 | 0xA1: # INVENTORY READ / FAST INVENTORY READ
return self._handle_inventory_read(frame)
case 0xA2: # SET EAS
return self._handle_set_eas(frame)
case 0xA3: # RESET EAS
return self._handle_reset_eas(frame)
case 0xA4: # LOCK EAS
return self._handle_lock_eas(frame)
case 0xA5: # EAS ALARM
return self._handle_eas_alarm(frame)
case 0xA6: # PASSWORD PROTECT EAS/AFI
return self._handle_password_protect_eas_afi(frame)
case 0xB2: # GET RANDOM — override to store random
return self._handle_get_random(frame)
case 0xB3: # SET_PASSWORD
return self._handle_set_password(frame)
case 0xB4: # WRITE PASSWORD
return self._handle_write_password(frame)
case 0xB5: # LOCK PASSWORD
return self._handle_lock_password(frame)
return super()._handle_custom_command(cmd, frame)
# ----- EAS auth check -----
def _check_eas_auth(self) -> bool:
"""Check if EAS/AFI operations are allowed."""
if not self._eas_password_protected:
return True
return PWD_EAS_AFI in self._authenticated_passwords
# ----- EAS subsystem -----
def _handle_set_eas(self, frame: RFFrame) -> RFFrame | None:
if self._eas_locked:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if not self._check_eas_auth():
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
self._eas_enabled = True
return RFFrame.from_bytes(bytes([0x00]))
def _handle_reset_eas(self, frame: RFFrame) -> RFFrame | None:
if self._eas_locked:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if not self._check_eas_auth():
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
self._eas_enabled = False
return RFFrame.from_bytes(bytes([0x00]))
def _handle_lock_eas(self, frame: RFFrame) -> RFFrame | None:
if not self._check_eas_auth():
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
self._eas_locked = True
return RFFrame.from_bytes(bytes([0x00]))
def _handle_eas_alarm(self, frame: RFFrame) -> RFFrame | None:
if not self._eas_enabled:
return None
return RFFrame.from_bytes(bytes([0x00]) + self._eas_sequence)
def _handle_password_protect_eas_afi(self, frame: RFFrame) -> RFFrame | None:
if PWD_EAS_AFI not in self._authenticated_passwords:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
self._eas_password_protected = True
return RFFrame.from_bytes(bytes([0x00]))
# ----- INVENTORY READ -----
def _handle_inventory_read(self, frame: RFFrame) -> RFFrame | None:
"""INVENTORY READ / FAST INVENTORY READ (0xA0/0xA1)."""
if len(frame.data) < 6:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
first_block = frame.data[4]
num_blocks = frame.data[5] + 1
resp = bytearray([0x00])
resp.append(self._dsfid)
resp.extend(self._uid)
for blk in range(first_block, first_block + num_blocks):
if blk >= self._num_blocks:
break
offset = blk * self._block_size
resp.extend(self._memory[offset:offset + self._block_size])
return RFFrame.from_bytes(bytes(resp))

257
pm3py/sim/icode_slix2.py Normal file
View File

@@ -0,0 +1,257 @@
"""ICODE SLIX2 — NXP IC with password protection and privacy mode.
Extends IcodeSlixTag with:
- 4 additional passwords (read, write, privacy, destroy)
- Privacy mode + ENABLE PRIVACY command
- Protection pointer + PROTECT/LOCK PAGE PROTECTION
- DESTROY, 64-BIT PASSWORD PROTECTION, STAY QUIET PERSISTENT
- Block 79 counter
"""
from __future__ import annotations
import struct
from .frame import RFFrame
from .icode_slix import IcodeSlixTag, PWD_EAS_AFI
from .nxp_icode import NXP_MFG
# SLIX2-specific password IDs
PWD_READ = 0x01
PWD_WRITE = 0x02
PWD_PRIVACY = 0x04
PWD_DESTROY = 0x08
# Protection page condition bits
PP_COND_READ_PROTECTION = 0x01
PP_COND_WRITE_PROTECTION = 0x02
class IcodeSlix2Tag(IcodeSlixTag):
"""ICODE SLIX2 transponder (SL2S2602).
Features beyond IcodeSlixTag:
- 4 additional passwords: read, write, privacy, destroy
- Privacy mode: hides UID from inventory
- Protection pointer: blocks above this are password-protected
- ENABLE PRIVACY (0xBA), DESTROY (0xB9), 64-BIT PASSWORD PROTECTION (0xBB)
- STAY QUIET PERSISTENT (0xBC), block 79 counter
"""
_uid_prefix = b"\xE0\x04\x02" # SLIX2 IC type = 0x02
def __init__(self, uid: bytes | str | None = None,
read_password: int | None = None,
write_password: int | None = None,
privacy_password: int | None = None,
destroy_password: int | None = None,
eas_afi_password: int | None = None,
protection_pointer: int | None = None,
**kwargs):
kwargs.setdefault("num_blocks", 80)
kwargs.setdefault("ic_reference", 0x01) # SLIX2 IC type per SL2S2602
super().__init__(uid=uid, eas_afi_password=eas_afi_password, **kwargs)
# Add SLIX2-specific passwords to the inherited dict
self._passwords[PWD_READ] = read_password
self._passwords[PWD_WRITE] = write_password
self._passwords[PWD_PRIVACY] = privacy_password
self._passwords[PWD_DESTROY] = destroy_password
# SLIX2-specific state
self._privacy_mode = False
self._protection_pointer = protection_pointer
self._page_protection_locked = False
self._extended_protection = False
self._destroyed = False
self._persistent_quiet = False
# Build per-block access map
self._build_access_map()
def _build_access_map(self) -> None:
"""Build per-block access map from protection pointer + counter."""
has_counter = self._num_blocks >= 80
has_pp = self._protection_pointer is not None
if not has_counter and not has_pp:
self.regions["user"].access_map = None
return
from .memory import BlockAccess
user = self.regions["user"]
access_map = []
for block in range(self._num_blocks):
ba = BlockAccess()
if has_pp and block >= self._protection_pointer:
ba = BlockAccess(read="password", read_key=PWD_READ)
if has_counter and block == 79:
ba = BlockAccess(read=ba.read, read_key=ba.read_key,
write_mode="counter")
access_map.append(ba)
user.access_map = access_map
async def power_on(self) -> None:
await super().power_on()
if self._persistent_quiet:
from .iso15693 import State15693
self._state = State15693.QUIET
@property
def privacy_mode(self) -> bool:
return self._privacy_mode
def enter_privacy_mode(self) -> None:
if self._passwords[PWD_PRIVACY] is not None:
self._privacy_mode = True
# ----- handle_frame override for destroyed/privacy -----
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
if self._destroyed:
return None
if len(frame.data) < 2:
return None
cmd = frame.data[1]
# In privacy mode: only respond to GET_RANDOM, SET_PASSWORD, and ENABLE_PRIVACY
if self._privacy_mode:
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
if cmd == 0xB2:
return self._handle_get_random(frame)
if cmd == 0xB3:
return self._handle_set_password(frame)
if cmd == 0xBA:
return RFFrame.from_bytes(bytes([0x00]))
return None
return await super().handle_frame(frame)
# ----- SLIX2-specific command dispatch -----
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
"""Handle SLIX2-specific commands, delegate shared ones to parent."""
frame = self._normalize_custom_frame(frame)
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
match cmd:
case 0xB6: # PROTECT PAGE
return self._handle_protect_page(frame)
case 0xB7: # LOCK PAGE PROTECTION
return self._handle_lock_page_protection(frame)
case 0xB9: # DESTROY
return self._handle_destroy(frame)
case 0xBA: # ENABLE_PRIVACY
return self._handle_enable_privacy(frame)
case 0xBB: # 64-BIT PASSWORD PROTECTION
return self._handle_64bit_password_protection(frame)
case 0xBC: # STAY QUIET PERSISTENT
return self._handle_stay_quiet_persistent(frame)
case 0xA7: # WRITE EAS ID (SLIX2+ only, EAS Selective per AN11809)
return self._handle_write_eas_id(frame)
case 0xAB: # GET NXP SYSTEM INFO — override with SLIX2 data
return self._handle_get_nxp_system_info(frame)
case 0xBD: # READ SIGNATURE
return self._handle_read_signature(frame)
# Delegate to parent (IcodeSlixTag) for shared commands
# Re-normalize already happened, but parent will normalize again (no-op for unaddressed)
return super()._handle_custom_command(cmd, frame)
# ----- SET_PASSWORD override (privacy unlock) -----
def _handle_set_password(self, frame: RFFrame) -> RFFrame | None:
"""Override to unlock privacy mode on privacy password auth."""
result = super()._handle_set_password(frame)
if result is not None and result.data[0] == 0x00:
pwd_id = frame.data[3]
if pwd_id == PWD_PRIVACY:
self._privacy_mode = False
return result
# ----- SLIX2-only commands -----
def _handle_write_eas_id(self, frame: RFFrame) -> RFFrame | None:
"""WRITE EAS ID (0xA7): SLIX2+ only (EAS Selective per AN11809)."""
if len(frame.data) < 5:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if not self._check_eas_auth():
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
self._eas_id = struct.unpack_from("<H", frame.data, 3)[0]
return RFFrame.from_bytes(bytes([0x00]))
def _handle_enable_privacy(self, frame: RFFrame) -> RFFrame | None:
"""ENABLE PRIVACY (0xBA): enter privacy mode using XOR'd password."""
if len(frame.data) < 7:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if not self._verify_xor_password(PWD_PRIVACY, frame.data[3:7]):
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
self._privacy_mode = True
return RFFrame.from_bytes(bytes([0x00]))
def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None:
"""GET NXP SYSTEM INFO (0xAB): SLIX2-specific response."""
pp = self._protection_pointer if self._protection_pointer is not None else 0
pp_cond = 0x00
if self._protection_pointer is not None:
pp_cond = PP_COND_READ_PROTECTION
lock_bits = 0x00
feature_flags = struct.pack("<I", 0x00000000)
return RFFrame.from_bytes(bytes([0x00, pp, pp_cond, lock_bits]) + feature_flags)
def _handle_protect_page(self, frame: RFFrame) -> RFFrame | None:
"""PROTECT PAGE (0xB6): set protection pointer and conditions."""
if len(frame.data) < 5:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if self._page_protection_locked:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if PWD_WRITE not in self._authenticated_passwords:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
self._protection_pointer = frame.data[3]
self._build_access_map()
return RFFrame.from_bytes(bytes([0x00]))
def _handle_lock_page_protection(self, frame: RFFrame) -> RFFrame | None:
"""LOCK PAGE PROTECTION (0xB7): permanently lock protection config."""
if PWD_WRITE not in self._authenticated_passwords:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
self._page_protection_locked = True
return RFFrame.from_bytes(bytes([0x00]))
def _handle_destroy(self, frame: RFFrame) -> RFFrame | None:
"""DESTROY (0xB9): irreversibly kill tag."""
if len(frame.data) < 7:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if not self._verify_xor_password(PWD_DESTROY, frame.data[3:7]):
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
self._destroyed = True
return RFFrame.from_bytes(bytes([0x00]))
def _handle_64bit_password_protection(self, frame: RFFrame) -> RFFrame | None:
"""64-BIT PASSWORD PROTECTION (0xBB): requires both read + write auth."""
if PWD_READ not in self._authenticated_passwords:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if PWD_WRITE not in self._authenticated_passwords:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
self._extended_protection = True
return RFFrame.from_bytes(bytes([0x00]))
def _handle_stay_quiet_persistent(self, frame: RFFrame) -> RFFrame | None:
"""STAY QUIET PERSISTENT (0xBC): persistent quiet survives power cycles."""
from .iso15693 import State15693
self._persistent_quiet = True
self._state = State15693.QUIET
return RFFrame.from_bytes(bytes([0x00]))
def _handle_read_single(self, frame: RFFrame) -> RFFrame | None:
"""Override to enforce read protection."""
block = self._extract_block_number(frame)
if block is None:
return None
if self._protection_pointer is not None and block >= self._protection_pointer:
if PWD_READ not in self._authenticated_passwords:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
return super()._handle_read_single(frame)

66
pm3py/sim/implants.py Normal file
View File

@@ -0,0 +1,66 @@
"""Implant preset profiles — pre-configured transponder instances."""
from __future__ import annotations
import os
from .t5577 import T5577Tag
from .ndef import NfcType2Tag
from .mifare import MifareClassicTag
from .desfire import DesfireTag
def _random_uid(length: int) -> bytes:
"""Generate a random UID starting with 0x04 (NXP manufacturer code)."""
return b"\x04" + os.urandom(length - 1)
def xEM(tag_id: int = 0x0000000000) -> T5577Tag:
"""xEM implant — T5577 configured as EM4100."""
return T5577Tag.preset("em4100", tag_id=tag_id)
def xNT(uid: bytes | None = None, ndef_message: bytes = b"") -> NfcType2Tag:
"""xNT implant — NTAG216, 888 bytes, NDEF capable."""
if uid is None:
uid = _random_uid(7)
return NfcType2Tag(uid=uid, ndef_message=ndef_message,
total_pages=231, # NTAG216: 924 bytes / 4
atqa=b"\x44\x00", sak=0x00)
def xM1(uid: bytes | None = None) -> MifareClassicTag:
"""xM1 implant — MIFARE Classic 1K."""
if uid is None:
uid = _random_uid(4)
return MifareClassicTag(uid=uid, size="1k")
def FlexDF(uid: bytes | None = None) -> DesfireTag:
"""FlexDF implant — DESFire EV2, 8K."""
if uid is None:
uid = _random_uid(7)
return DesfireTag(uid=uid)
def NExT(tag_id: int = 0x0000000000,
uid: bytes | None = None) -> tuple[T5577Tag, NfcType2Tag]:
"""NExT implant — dual frequency (xEM LF + xNT HF)."""
lf = xEM(tag_id=tag_id)
hf = xNT(uid=uid)
return lf, hf
class MagicMifareClassicTag(MifareClassicTag):
"""Magic MIFARE Classic — gen1a or gen2/CUID variant.
gen1a: responds to special backdoor commands, allows direct block 0 write
gen2/CUID: allows block 0 write via standard write commands
"""
def __init__(self, uid: bytes, magic_type: str = "gen1a", **kwargs):
super().__init__(uid=uid, **kwargs)
self._magic_type = magic_type
@property
def magic_type(self) -> str:
return self._magic_type

387
pm3py/sim/iso14443a.py Normal file
View File

@@ -0,0 +1,387 @@
"""ISO 14443-A transponder and reader state machines."""
from __future__ import annotations
from enum import IntEnum
from .frame import RFFrame
from .medium import Medium
from .trace_fmt import decode_14443a
from .transponder import Transponder
from .reader import Reader
# ---- Constants ----
REQA = 0x26
WUPA = 0x52
HLTA = 0x50
CL1 = 0x93
CL2 = 0x95
CL3 = 0x97
CT = 0x88 # Cascade Tag
RATS_CMD = 0xE0
NVB_ANTICOL = 0x20 # 2 bytes valid (SEL + NVB only)
NVB_SELECT = 0x70 # 7 bytes valid (full SELECT)
CL_MAP = {CL1: 0, CL2: 1, CL3: 2}
class State14443A(IntEnum):
IDLE = 0
READY = 1
ACTIVE = 2
HALT = 3
PROTOCOL = 4 # Layer 4 (ISO-DEP) active
def _compute_bcc(data: bytes) -> int:
r = 0
for b in data:
r ^= b
return r
def _split_uid_cascades(uid: bytes) -> list[bytes]:
"""Split UID into cascade-level chunks of 4 bytes each."""
if len(uid) == 4:
return [uid]
elif len(uid) == 7:
return [bytes([CT]) + uid[0:3], uid[3:7]]
elif len(uid) == 10:
return [bytes([CT]) + uid[0:3], bytes([CT]) + uid[3:6], uid[6:10]]
else:
raise ValueError(f"UID must be 4, 7, or 10 bytes, got {len(uid)}")
class Tag14443A(Transponder):
"""ISO 14443-A Part 2+3 base: anticollision + selection."""
def __init__(self, uid: bytes | str, atqa: bytes = b"\x04\x00", sak: int = 0x08):
super().__init__()
uid = self._parse_uid(uid)
if len(uid) not in (4, 7, 10):
raise ValueError(f"UID must be 4, 7, or 10 bytes, got {len(uid)}")
self._uid = uid
self._atqa = atqa
self._sak = sak
self._state = State14443A.IDLE
self._cascades = _split_uid_cascades(uid)
self._current_cl = 0
def decode_trace(self, direction: int, payload: bytes) -> str | None:
return decode_14443a(direction, payload)
@property
def atqa(self) -> bytes:
return self._atqa
@property
def sak(self) -> int:
return self._sak
def _cascade_entries(self) -> list[tuple[int, bytes]]:
"""Return (sel_byte, uid_chunk) for each cascade level."""
sels = [CL1, CL2, CL3]
return list(zip(sels[:len(self._cascades)], self._cascades))
async def power_on(self) -> None:
self._state = State14443A.IDLE
self._current_cl = 0
async def power_off(self) -> None:
self._state = State14443A.IDLE
self._current_cl = 0
@property
def state(self) -> str:
return self._state.name
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
if not frame.data:
return None
cmd = frame.data[0]
match self._state:
case State14443A.IDLE:
if cmd == REQA or cmd == WUPA:
self._state = State14443A.READY
self._current_cl = 0
return RFFrame.from_bytes(self._atqa)
return None
case State14443A.READY:
if cmd == REQA or cmd == WUPA:
self._current_cl = 0
return RFFrame.from_bytes(self._atqa)
return self._handle_anticollision(frame)
case State14443A.ACTIVE:
# Application handler gets first chance (e.g., MIFARE auth response)
app_result = self._handle_application(frame)
if app_result is not None:
return app_result
if cmd == HLTA and len(frame.data) >= 2:
self._state = State14443A.HALT
return None
if cmd == REQA or cmd == WUPA:
# Re-selection: reset to READY
self._state = State14443A.READY
self._current_cl = 0
return RFFrame.from_bytes(self._atqa)
if cmd == RATS_CMD:
return self._handle_rats(frame)
return None
case State14443A.HALT:
if cmd == WUPA:
self._state = State14443A.READY
self._current_cl = 0
return RFFrame.from_bytes(self._atqa)
return None
case State14443A.PROTOCOL:
if cmd == REQA or cmd == WUPA:
self._state = State14443A.READY
self._current_cl = 0
return RFFrame.from_bytes(self._atqa)
return self._handle_layer4(frame)
return None
def _handle_anticollision(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 2:
return None
sel = frame.data[0]
nvb = frame.data[1]
if sel not in CL_MAP:
return None
cl_index = CL_MAP[sel]
if cl_index != self._current_cl:
return None
cascade_uid = self._cascades[cl_index]
uid_bcc = cascade_uid + bytes([_compute_bcc(cascade_uid)])
if nvb == NVB_SELECT and len(frame.data) >= 7:
# Full SELECT
incoming_uid = frame.data[2:6]
if incoming_uid == cascade_uid:
self._current_cl += 1
if self._current_cl >= len(self._cascades):
self._state = State14443A.ACTIVE
return RFFrame.from_bytes(bytes([self._sak]))
else:
return RFFrame.from_bytes(bytes([0x04]))
return None
# ANTICOLLISION with known bytes
known_bytes = (nvb >> 4) - 2 # subtract SEL + NVB bytes
if known_bytes < 0:
known_bytes = 0
# Check if our UID matches the known bytes
if known_bytes > 0 and len(frame.data) > 2:
known_data = frame.data[2:2 + known_bytes]
for i in range(min(known_bytes, len(uid_bcc))):
if i < len(known_data) and known_data[i] != uid_bcc[i]:
return None # doesn't match
return RFFrame.from_bytes(uid_bcc)
def _handle_rats(self, frame: RFFrame) -> RFFrame | None:
return None
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
return None
def _handle_layer4(self, frame: RFFrame) -> RFFrame | None:
return None
class Tag14443A_3(Tag14443A):
"""ISO 14443-A Part 3 only — no ISO-DEP. SAK bit 5 = 0."""
def __init__(self, uid: bytes, atqa: bytes = b"\x04\x00", sak: int = 0x08):
super().__init__(uid=uid, atqa=atqa, sak=sak & ~0x20)
class Tag14443A_4(Tag14443A):
"""ISO 14443-A Part 3+4 — ISO-DEP capable. SAK bit 5 = 1."""
def __init__(self, uid: bytes, atqa: bytes = b"\x04\x00", sak: int = 0x20,
ats: bytes = b"\x05\x78\x80\x70\x02"):
super().__init__(uid=uid, atqa=atqa, sak=sak | 0x20)
self._ats = ats
self._block_number = 0
def _handle_rats(self, frame: RFFrame) -> RFFrame | None:
self._state = State14443A.PROTOCOL
self._block_number = 0
return RFFrame.from_bytes(self._ats)
def _handle_layer4(self, frame: RFFrame) -> RFFrame | None:
if not frame.data:
return None
pcb = frame.data[0]
if pcb & 0xC0 == 0x00: # I-block
payload = frame.data[1:]
response_payload = self._handle_apdu(payload)
resp_pcb = 0x02 if (pcb & 0x01) == 0 else 0x03
self._block_number = resp_pcb & 0x01
return RFFrame.from_bytes(bytes([resp_pcb]) + response_payload)
return None
def _handle_apdu(self, apdu: bytes) -> bytes:
"""Override in subclasses. Default: 6A82 (file not found)."""
return b"\x6A\x82"
# ---- Reader14443A ----
class Reader14443A(Reader):
"""ISO 14443-A reader with full anticollision tree walk."""
async def run(self) -> dict:
uids = await self.inventory()
return {"uids": uids}
async def inventory(self) -> list[bytes]:
"""Perform full anticollision, return all UIDs found."""
uids: list[bytes] = []
while True:
uid = await self._select_one_tag()
if uid is None:
break
uids.append(uid)
# HALT so this tag won't respond to REQA
await self._medium.transmit_reader(RFFrame.from_hex("5000"))
await self._medium.receive_reader()
return uids
async def _select_one_tag(self) -> bytes | None:
"""Wake tags with REQA, resolve collisions, return one full UID."""
await self._medium.transmit_reader(RFFrame.from_bytes(bytes([REQA])))
atqa = await self._medium.receive_reader()
if atqa is None:
return None
uid_acc = b""
for sel in (CL1, CL2, CL3):
result = await self._anticol_select(sel)
if result is None:
return None
sak = result["sak"]
cl_uid = result["uid"]
if sak & 0x04:
uid_acc += cl_uid[1:4] if cl_uid[0] == CT else cl_uid
else:
return uid_acc + cl_uid
return None
async def _anticol_select(self, sel: int,
known_bytes: bytes = b"") -> dict | None:
"""Anticollision + SELECT for one cascade level.
Uses byte-level collision resolution: when collision is detected
at byte N, try all 256 values for that byte position until one
produces a clean (no-collision) response.
"""
# Build ANTICOL command with known bytes
nvb_byte_count = len(known_bytes) + 2 # +2 for SEL + NVB
nvb = nvb_byte_count << 4 # no extra bits
anticol = bytes([sel, nvb]) + known_bytes
await self._medium.transmit_reader(RFFrame.from_bytes(anticol))
resp = await self._medium.receive_reader()
if resp is None:
return None
if resp.has_collision and resp.collision_positions:
# Find which response byte has the first collision
first_col = resp.collision_positions[0]
col_byte_idx = first_col // 8
if col_byte_idx >= 4:
# Collision only in BCC byte — use data bytes as-is
pass
else:
# Try each possible value for the collision byte
# Use values from the collision response as starting hints
resp_byte = resp.data[col_byte_idx] if col_byte_idx < len(resp.data) else 0
# Try the collision byte with bit set to 0 and 1 first, then others
from bitarray import bitarray as ba
resp_bits = ba(resp.bits[:resp.bit_count])
resp_bits[first_col] = 0
hint0 = resp_bits[col_byte_idx*8:(col_byte_idx+1)*8].tobytes()[0]
resp_bits[first_col] = 1
hint1 = resp_bits[col_byte_idx*8:(col_byte_idx+1)*8].tobytes()[0]
candidates = [hint0, hint1]
for candidate in candidates:
prefix = known_bytes[:col_byte_idx] + bytes([candidate])
if len(prefix) <= col_byte_idx:
# Need intermediate bytes from response
prefix = resp.data[:col_byte_idx] + bytes([candidate])
result = await self._anticol_select(sel, prefix)
if result is not None:
return result
return None
# No collision — we have a clean UID
if len(resp.data) < 5:
return None
uid_bytes = resp.data[0:4]
bcc = _compute_bcc(uid_bytes)
# SELECT
select_cmd = bytes([sel, NVB_SELECT]) + uid_bytes + bytes([bcc])
await self._medium.transmit_reader(RFFrame.from_bytes(select_cmd))
sak_resp = await self._medium.receive_reader()
if sak_resp is None:
return None
return {"uid": uid_bytes, "sak": sak_resp.data[0]}
async def select_tag(self, uid: bytes) -> dict:
"""Full SELECT sequence for a known UID."""
cascades = _split_uid_cascades(uid)
sels = [CL1, CL2, CL3]
sak = 0
await self._medium.transmit_reader(RFFrame.from_bytes(bytes([WUPA])))
await self._medium.receive_reader()
for i, (sel, cascade_uid) in enumerate(zip(sels, cascades)):
bcc = _compute_bcc(cascade_uid)
select_cmd = bytes([sel, NVB_SELECT]) + cascade_uid + bytes([bcc])
await self._medium.transmit_reader(RFFrame.from_bytes(select_cmd))
resp = await self._medium.receive_reader()
if resp is None:
raise RuntimeError(f"No response to SELECT CL{i+1}")
sak = resp.data[0]
return {"uid": uid, "sak": sak}
async def rats(self, cid: int = 0) -> dict:
"""Send RATS, enter Layer 4. Returns ATS."""
fsd_cid = 0x50 | (cid & 0x0F)
rats_frame = RFFrame.from_bytes(bytes([RATS_CMD, fsd_cid]))
await self._medium.transmit_reader(rats_frame)
resp = await self._medium.receive_reader()
if resp is None:
raise RuntimeError("No response to RATS")
return {"ats": resp.data}
async def transceive_apdu(self, apdu: bytes, block_number: int = 0) -> bytes:
"""Send APDU via I-block, return response payload."""
pcb = 0x02 | (block_number & 0x01)
iblock = bytes([pcb]) + apdu
await self._medium.transmit_reader(RFFrame.from_bytes(iblock))
resp = await self._medium.receive_reader()
if resp is None:
raise RuntimeError("No response to I-block")
return resp.data[1:]

518
pm3py/sim/iso15693.py Normal file
View File

@@ -0,0 +1,518 @@
"""ISO 15693 transponder and reader state machines."""
from __future__ import annotations
import os
import struct
from enum import IntEnum
from .frame import RFFrame
from .medium import Medium
from .trace_fmt import decode_15693
from .transponder import Transponder
from .reader import Reader
# ---- ISO 15693 flag bits ----
FLAG_SUBCARRIER = 0x01
FLAG_HIGH_DATA_RATE = 0x02
FLAG_INVENTORY = 0x04
FLAG_PROTOCOL_EXT = 0x08
FLAG_SELECT = 0x10 # non-inventory: selected
FLAG_ADDRESS = 0x20 # non-inventory: addressed
FLAG_INV_SLOTS1 = 0x20 # inventory: 1-slot (if set)
FLAG_OPTION = 0x40
FLAG_RFU = 0x80
# Response flag bits
RESP_ERROR = 0x01
# ---- ISO 15693 commands ----
CMD_INVENTORY = 0x01
CMD_STAY_QUIET = 0x02
CMD_READ_SINGLE = 0x20
CMD_WRITE_SINGLE = 0x21
CMD_READ_MULTIPLE = 0x23
CMD_RESET_TO_READY = 0x26
CMD_SYSTEM_INFO = 0x2B
CMD_EXT_READ_SINGLE = 0x30
CMD_EXT_WRITE_SINGLE = 0x31
CMD_EXT_READ_MULTIPLE = 0x33
# System info flag bits
SYSINFO_DSFID = 0x01
SYSINFO_AFI = 0x02
SYSINFO_MEMSIZE = 0x04
SYSINFO_ICREF = 0x08
class State15693(IntEnum):
POWER_OFF = 0
READY = 1
QUIET = 2
SELECTED = 3
class Tag15693(Transponder):
"""ISO 15693 transponder with inventory and block operations."""
# Subclasses override to set valid UID prefix for auto-generation.
# Format: [E0, manufacturer, IC_type, ...] — remaining bytes are random.
_uid_prefix: bytes = b"\xE0\x00"
def __init__(self, uid: bytes | str | None = None, dsfid: int = 0, afi: int = 0,
memory: bytearray | None = None,
block_size: int = 4, num_blocks: int = 28,
ic_reference: int = 0x00):
import warnings
super().__init__()
if uid is None:
uid = self._generate_uid()
else:
uid = self._parse_uid(uid)
if len(uid) != 8:
raise ValueError(f"UID must be 8 bytes, got {len(uid)}")
# Validate UID prefix matches expected IC type (only for product-specific subclasses)
prefix = self._uid_prefix
if len(prefix) > 2 and uid[:len(prefix)] != prefix:
expected = prefix.hex().upper()
actual = uid[:len(prefix)].hex().upper()
cls_name = type(self).__name__
warnings.warn(
f"{cls_name}: UID prefix {actual} doesn't match expected {expected}. "
f"Phones/readers may not identify the IC type correctly. "
f"Use uid=None for auto-generated valid UID, or start UID with {expected}.",
stacklevel=3,
)
self._uid = uid
self._dsfid = dsfid
self._afi = afi
self._block_size = block_size
self._num_blocks = num_blocks
self._ic_reference = ic_reference
self._state = State15693.POWER_OFF
self._uid_dirty = True # ensure first sync() pushes header to firmware
# Memory regions
from .memory import DirtyByteArray, MemoryRegion
user_data = DirtyByteArray(memory) if memory else DirtyByteArray(block_size * num_blocks)
self.regions["user"] = MemoryRegion(
name="user", data=user_data, block_size=block_size, eml_offset=175)
self.regions["locks"] = MemoryRegion(
name="locks", data=DirtyByteArray(num_blocks), block_size=1,
eml_offset=15, rf_readable=False)
@property
def _memory(self):
"""Alias for regions['user'].data — backwards compatible."""
return self.regions["user"].data
@_memory.setter
def _memory(self, value):
from .memory import DirtyByteArray
self.regions["user"].data = DirtyByteArray(value) if not isinstance(value, DirtyByteArray) else value
@classmethod
def _generate_uid(cls) -> bytes:
"""Generate a random valid UID with the correct prefix for this IC type."""
prefix = cls._uid_prefix
random_bytes = os.urandom(8 - len(prefix))
return prefix + random_bytes
def decode_trace(self, direction: int, payload: bytes) -> str | None:
return decode_15693(direction, payload)
def set_dsfid(self, dsfid: int) -> None:
"""Change DSFID. Call sync() to push."""
self._dsfid = dsfid
self._uid_dirty = True # reuse uid_dirty to push header
def set_afi(self, afi: int) -> None:
"""Change AFI (Application Family Identifier). Call sync() to push."""
self._afi = afi
self._uid_dirty = True
def _sync_uid(self) -> None:
"""Write tag header (UID, DSFID, AFI, config) to firmware EML offset 0."""
from ..core.transport import encode_ng_frame
from ..core.protocol import Cmd
header = bytearray(15)
header[0:8] = self._uid[::-1] # UID reversed
header[8] = self._dsfid
header[9] = 0 # dsfidLock
header[10] = self._afi
header[11] = 0 # afiLock
header[12] = self._block_size
header[13] = self._num_blocks
header[14] = self._ic_reference
payload = struct.pack("<IH", 0, 15) + header
frame = encode_ng_frame(Cmd.HF_ISO15693_EML_SETMEM, payload)
self._serial.write(frame)
async def power_on(self) -> None:
self._state = State15693.READY
async def power_off(self) -> None:
self._state = State15693.POWER_OFF
@property
def state(self) -> str:
return self._state.name
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 2:
return None
flags = frame.data[0]
cmd = frame.data[1]
# Check if this is an addressed command and verify UID
is_inventory = bool(flags & FLAG_INVENTORY)
if not is_inventory and (flags & FLAG_ADDRESS):
if len(frame.data) < 10:
return None
frame_uid = bytes(reversed(frame.data[2:10]))
if frame_uid != self._uid:
return None
match cmd:
case 0x01: # Inventory
if self._state == State15693.QUIET:
return None
return self._handle_inventory(frame)
case 0x02: # Stay Quiet
self._state = State15693.QUIET
return None # no response per spec
case 0x20: # Read Single Block
return self._handle_read_single(frame)
case 0x21: # Write Single Block
return self._handle_write_single(frame)
case 0x26: # Reset to Ready
self._state = State15693.READY
return self._make_response(b"")
case 0x2B: # Get System Information
return self._handle_system_info(frame)
case 0x30: # Extended Read Single Block
return self._handle_extended_read_single(frame)
case 0x31: # Extended Write Single Block
return self._handle_extended_write_single(frame)
case 0x33: # Extended Read Multiple Blocks
return self._handle_extended_read_multiple(frame)
case _ if cmd >= 0xA0:
return self._handle_custom_command(cmd, frame)
case _:
return self._make_error(0x01) # not supported
def _handle_inventory(self, frame: RFFrame) -> RFFrame | None:
"""Handle inventory command. Returns flags + DSFID + UID.
Supports mask filtering: if mask_len > 0, only respond if our UID
matches the mask bits (compared LSB-first from UID byte 0).
"""
if len(frame.data) < 3:
return None
mask_len = frame.data[2] # number of mask bits
if mask_len > 0:
# Mask bytes follow mask_len
mask_bytes_count = (mask_len + 7) // 8
if len(frame.data) < 3 + mask_bytes_count:
return None
mask_data = frame.data[3:3 + mask_bytes_count]
uid_lsb = self._uid[::-1] # UID in LSB-first order
# Compare mask bytes against UID (byte-level comparison)
# Full bytes must match exactly, partial last byte checked bit-by-bit
full_bytes = mask_len // 8
for i in range(full_bytes):
if i < len(mask_data) and i < len(uid_lsb):
if mask_data[i] != uid_lsb[i]:
return None
# Check remaining bits in partial byte (MSB-first within byte
# to match bitarray ordering used by collision resolution)
remaining_bits = mask_len % 8
if remaining_bits > 0 and full_bytes < len(mask_data) and full_bytes < len(uid_lsb):
# Mask the relevant bits (MSB-first: top N bits of byte)
shift = 8 - remaining_bits
mask_val = mask_data[full_bytes] >> shift
uid_val = uid_lsb[full_bytes] >> shift
if mask_val != uid_val:
return None
resp = bytes([0x00, self._dsfid]) + self._uid[::-1]
return RFFrame.from_bytes(resp)
def _handle_read_single(self, frame: RFFrame) -> RFFrame | None:
"""Handle Read Single Block."""
block = self._extract_block_number(frame)
if block is None:
return None
if block >= self._num_blocks:
return self._make_error(0x10) # block not available
offset = block * self._block_size
data = self._memory[offset:offset + self._block_size]
return self._make_response(bytes(data))
def _handle_write_single(self, frame: RFFrame) -> RFFrame | None:
"""Handle Write Single Block."""
flags = frame.data[0]
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
if is_addressed:
block_offset = 10 # flags + cmd + UID(8)
else:
block_offset = 2 # flags + cmd
if len(frame.data) <= block_offset:
return None
block = frame.data[block_offset]
if block >= self._num_blocks:
return self._make_error(0x10)
data_offset = block_offset + 1
data = frame.data[data_offset:data_offset + self._block_size]
if len(data) < self._block_size:
return self._make_error(0x0F) # unknown error
offset = block * self._block_size
# Check for special write modes (e.g. counter)
access = self.regions["user"].access_for_block(block)
if access.write_mode == "counter":
import struct as _st
increment = _st.unpack_from("<H", data, 0)[0]
current = _st.unpack_from("<H", self._memory, offset)[0]
new_val = min(current + increment, 0xFFFF) # saturate
self._memory[offset:offset + 2] = _st.pack("<H", new_val)
elif access.write_mode == "counter24":
value = int.from_bytes(data[0:3], 'little')
if value == 1:
# Increment current counter by 1
current = int.from_bytes(self._memory[offset:offset + 3], 'little')
new_val = min(current + 1, 0xFFFFFF)
else:
# Preset (overwrite) counter to new value
new_val = value
self._memory[offset:offset + 3] = new_val.to_bytes(3, 'little')
# Write PROT byte (data[3]) to memory
self._memory[offset + 3] = data[3]
else:
self._memory[offset:offset + self._block_size] = data
return self._make_response(b"")
def _handle_system_info(self, frame: RFFrame) -> RFFrame | None:
"""Handle Get System Information."""
info_flags = SYSINFO_DSFID | SYSINFO_AFI | SYSINFO_MEMSIZE | SYSINFO_ICREF
resp = bytes([0x00, info_flags])
resp += self._uid[::-1] # UID LSB first
resp += bytes([self._dsfid]) # DSFID
resp += bytes([self._afi]) # AFI
resp += bytes([self._num_blocks - 1, self._block_size - 1]) # mem size
resp += bytes([self._ic_reference]) # IC reference
return RFFrame.from_bytes(resp)
def _extract_block_number(self, frame: RFFrame) -> int | None:
"""Extract block number from frame, accounting for addressed mode."""
flags = frame.data[0]
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
if is_addressed:
if len(frame.data) < 11:
return None
return frame.data[10]
else:
if len(frame.data) < 3:
return None
return frame.data[2]
def _extract_block_number_ext(self, frame: RFFrame) -> int | None:
"""Extract 2-byte (LE16) block number for extended commands."""
flags = frame.data[0]
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
offset = 10 if is_addressed else 2
if len(frame.data) < offset + 2:
return None
return struct.unpack_from("<H", frame.data, offset)[0]
def _handle_extended_read_single(self, frame: RFFrame) -> RFFrame | None:
"""Handle Extended Read Single Block (0x30) — 2-byte block address."""
block = self._extract_block_number_ext(frame)
if block is None:
return None
if block >= self._num_blocks:
return self._make_error(0x10) # block not available
offset = block * self._block_size
data = self._memory[offset:offset + self._block_size]
return self._make_response(bytes(data))
def _handle_extended_write_single(self, frame: RFFrame) -> RFFrame | None:
"""Handle Extended Write Single Block (0x31) — 2-byte block address."""
flags = frame.data[0]
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
block_offset = 10 if is_addressed else 2
if len(frame.data) < block_offset + 2:
return None
block = struct.unpack_from("<H", frame.data, block_offset)[0]
if block >= self._num_blocks:
return self._make_error(0x10)
data_offset = block_offset + 2
data = frame.data[data_offset:data_offset + self._block_size]
if len(data) < self._block_size:
return self._make_error(0x0F)
mem_offset = block * self._block_size
self._memory[mem_offset:mem_offset + self._block_size] = data
return self._make_response(b"")
def _handle_extended_read_multiple(self, frame: RFFrame) -> RFFrame | None:
"""Handle Extended Read Multiple Blocks (0x33) — 2-byte block address + count."""
flags = frame.data[0]
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
offset = 10 if is_addressed else 2
if len(frame.data) < offset + 4:
return None
block = struct.unpack_from("<H", frame.data, offset)[0]
num_blocks = struct.unpack_from("<H", frame.data, offset + 2)[0] + 1
if block + num_blocks > self._num_blocks:
return self._make_error(0x10)
result = bytearray()
for i in range(num_blocks):
b = block + i
mem_off = b * self._block_size
result.extend(self._memory[mem_off:mem_off + self._block_size])
return self._make_response(bytes(result))
def _make_response(self, data: bytes) -> RFFrame:
"""Build a success response frame."""
return RFFrame.from_bytes(bytes([0x00]) + data)
def _make_error(self, error_code: int) -> RFFrame:
"""Build an error response frame."""
return RFFrame.from_bytes(bytes([RESP_ERROR, error_code]))
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
"""Extension point for vendor-specific commands (0xA0-0xDF).
Override in subclasses (e.g., NxpIcodeTag).
"""
return self._make_error(0x01) # not supported
# ---- Reader15693 ----
class Reader15693(Reader):
"""ISO 15693 reader with inventory and block operations."""
async def run(self) -> dict:
tags = await self.inventory()
return {"tags": tags}
async def inventory(self, slots: int = 1) -> list[dict]:
"""Perform inventory. Returns list of {uid, dsfid}.
Uses 1-slot inventory with mask-based collision resolution:
when collision is detected, narrows the mask to isolate tags.
"""
tags: list[dict] = []
await self._inventory_recursive(tags, mask=b"", mask_bits=0)
return tags
async def _inventory_recursive(self, tags: list[dict],
mask: bytes, mask_bits: int,
depth: int = 0) -> None:
"""Recursive inventory with mask narrowing on collision."""
if depth > 64: # max 64 UID bits
return
inv_flags = 0x26 # high data rate + inventory + 1 slot
mask_bytes_count = (mask_bits + 7) // 8
inv = bytes([inv_flags, CMD_INVENTORY, mask_bits]) + mask[:mask_bytes_count]
await self._medium.transmit_reader(RFFrame.from_bytes(inv))
resp = await self._medium.receive_reader()
if resp is None:
return
if resp.has_collision and resp.collision_positions:
# Collision: find first collision bit in UID portion
# UID starts at byte 2 of response (bit 16 in MSB bitarray)
uid_bit_offset = 16 # flags(8) + dsfid(8)
col_in_uid = None
for pos in resp.collision_positions:
if pos >= uid_bit_offset:
col_in_uid = pos - uid_bit_offset
break
if col_in_uid is not None:
# Try both values at the collision bit
# Mask includes bits 0..col_in_uid (inclusive)
new_mask_bits = col_in_uid + 1
mask_byte_count = (new_mask_bits + 7) // 8
from bitarray import bitarray as ba
resp_bits = ba(resp.bits[:resp.bit_count])
for bit_val in (0, 1):
bits_copy = ba(resp_bits)
bits_copy[uid_bit_offset + col_in_uid] = bit_val
uid_bytes = bits_copy[uid_bit_offset:uid_bit_offset + mask_byte_count * 8].tobytes()
await self._inventory_recursive(tags, uid_bytes, new_mask_bits, depth + 1)
return
if len(resp.data) >= 10 and resp.data[0] == 0x00:
uid = bytes(reversed(resp.data[2:10]))
dsfid = resp.data[1]
# Avoid duplicates
if not any(t["uid"] == uid for t in tags):
tags.append({"uid": uid, "dsfid": dsfid})
async def read_block(self, uid: bytes, block: int) -> dict:
"""Read a single block (addressed mode)."""
cmd = bytes([0x22, CMD_READ_SINGLE]) + uid[::-1] + bytes([block])
await self._medium.transmit_reader(RFFrame.from_bytes(cmd))
resp = await self._medium.receive_reader()
if resp is None or resp.data[0] & RESP_ERROR:
return {"success": False, "data": None}
return {"success": True, "data": resp.data[1:]}
async def write_block(self, uid: bytes, block: int, data: bytes) -> dict:
"""Write a single block (addressed mode)."""
cmd = bytes([0x22, CMD_WRITE_SINGLE]) + uid[::-1] + bytes([block]) + data
await self._medium.transmit_reader(RFFrame.from_bytes(cmd))
resp = await self._medium.receive_reader()
if resp is None or resp.data[0] & RESP_ERROR:
return {"success": False}
return {"success": True}
async def system_info(self, uid: bytes) -> dict:
"""Get System Information (addressed mode)."""
cmd = bytes([0x22, CMD_SYSTEM_INFO]) + uid[::-1]
await self._medium.transmit_reader(RFFrame.from_bytes(cmd))
resp = await self._medium.receive_reader()
if resp is None or resp.data[0] & RESP_ERROR:
return {"uid": None}
data = resp.data
info_flags = data[1]
resp_uid = bytes(reversed(data[2:10]))
idx = 10
dsfid = data[idx] if info_flags & SYSINFO_DSFID else 0
idx += 1 if info_flags & SYSINFO_DSFID else 0
afi = data[idx] if info_flags & SYSINFO_AFI else 0
idx += 1 if info_flags & SYSINFO_AFI else 0
num_blocks = (data[idx] + 1) if info_flags & SYSINFO_MEMSIZE else 0
block_size = (data[idx + 1] + 1) if info_flags & SYSINFO_MEMSIZE else 0
idx += 2 if info_flags & SYSINFO_MEMSIZE else 0
ic_ref = data[idx] if info_flags & SYSINFO_ICREF else 0
return {
"uid": resp_uid,
"dsfid": dsfid,
"afi": afi,
"num_blocks": num_blocks,
"block_size": block_size,
"ic_reference": ic_ref,
}

69
pm3py/sim/lf_base.py Normal file
View File

@@ -0,0 +1,69 @@
"""LF transponder and reader base classes."""
from __future__ import annotations
from enum import Enum
from .frame import RFFrame
from .medium import Medium
from .transponder import Transponder
from .reader import Reader
class Modulation(Enum):
ASK = "ask"
FSK = "fsk"
PSK = "psk"
NRZ = "nrz"
class TagLF(Transponder):
"""Base class for LF (125/134 kHz) transponders.
LF tags are passive — they continuously transmit their data when
energized by the reader's field. The handle_frame() method responds
to any reader command with the tag's encoded data.
"""
def __init__(self, modulation: Modulation = Modulation.ASK):
super().__init__()
self._modulation = modulation
self._powered = False
async def power_on(self) -> None:
self._powered = True
async def power_off(self) -> None:
self._powered = False
@property
def state(self) -> str:
return "POWERED" if self._powered else "OFF"
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
"""LF tags respond to any energize/read command with their data."""
if not self._powered:
return None
return self._get_response()
def _get_response(self) -> RFFrame | None:
"""Override in subclasses to return encoded tag data."""
return None
class ReaderLF(Reader):
"""Base class for LF readers."""
async def run(self) -> dict:
return await self.read_id()
async def read_id(self) -> dict | None:
"""Send energize command, receive tag response."""
await self._medium.transmit_reader(RFFrame.from_bytes(b"\x00"))
resp = await self._medium.receive_reader()
if resp is None:
return None
return self._decode_response(resp)
def _decode_response(self, resp: RFFrame) -> dict | None:
"""Override in subclasses to decode tag-specific response."""
return {"raw": resp.data}

113
pm3py/sim/mcu_bridge.py Normal file
View File

@@ -0,0 +1,113 @@
"""McuBridge — COBS-framed serial bridge to supplemental I2C MCU."""
from __future__ import annotations
import struct
import threading
from .mcu_protocol import MsgType, StreamDeframer, build_frame
class McuBridge:
"""Serial bridge to supplemental MCU (RP2040/ESP32/SAMD21).
The MCU acts as an I2C target and pushes events to Python.
Python sends commands to control ED pin, EH voltage, SRAM contents.
"""
def __init__(self, port):
self._port = port
self._active = False
self._reader_thread: threading.Thread | None = None
self._deframer = StreamDeframer()
self.on_i2c_write: callable | None = None
self.on_i2c_read_req: callable | None = None
self.on_ready: callable | None = None
self.on_error: callable | None = None
@classmethod
def open(cls, port: str = "/dev/ttyACM1", baudrate: int = 115200) -> "McuBridge":
"""Open a McuBridge on the given serial port."""
import serial
ser = serial.Serial()
ser.port = port
ser.baudrate = baudrate
ser.timeout = 0.1
ser.open()
return cls(port=ser)
def start(self):
"""Start background reader thread."""
self._active = True
self._reader_thread = threading.Thread(
target=self._reader_loop, daemon=True
)
self._reader_thread.start()
def stop(self):
"""Stop background reader thread."""
if not self._active:
return
self._active = False
if self._reader_thread:
self._reader_thread.join(timeout=1.0)
self._reader_thread = None
def _reader_loop(self):
"""Background: read serial, decode COBS frames, dispatch callbacks."""
while self._active:
try:
data = self._port.read(64)
if not data:
continue
messages = self._deframer.feed(data)
for msg_type, payload in messages:
self._dispatch(msg_type, payload)
except Exception:
if not self._active:
break
continue
def _dispatch(self, msg_type: int, payload: bytes):
"""Dispatch decoded message to appropriate callback."""
if msg_type == MsgType.I2C_WRITE and self.on_i2c_write:
if len(payload) >= 2:
addr = (payload[0] << 8) | payload[1]
self.on_i2c_write(addr, payload[2:])
elif msg_type == MsgType.I2C_READ_REQ and self.on_i2c_read_req:
if len(payload) >= 3:
addr = (payload[0] << 8) | payload[1]
length = payload[2]
self.on_i2c_read_req(addr, length)
elif msg_type == MsgType.MCU_READY and self.on_ready:
ver = payload[0] if len(payload) > 0 else 0
caps = payload[1] if len(payload) > 1 else 0
self.on_ready(ver, caps)
elif msg_type == MsgType.MCU_ERROR and self.on_error:
code = payload[0] if len(payload) > 0 else 0
self.on_error(code)
def _send(self, msg_type: int, payload: bytes = b""):
"""Send a COBS-framed message to the MCU."""
self._port.write(build_frame(msg_type, payload))
def send_set_ed(self, state: bool):
self._send(MsgType.SET_ED, bytes([0x01 if state else 0x00]))
def send_set_eh(self, state: bool):
self._send(MsgType.SET_EH, bytes([0x01 if state else 0x00]))
def send_set_eh_voltage(self, voltage_mv: int):
self._send(MsgType.SET_EH_VOLTAGE, struct.pack(">H", voltage_mv))
def send_write_sram(self, offset: int, data: bytes):
self._send(MsgType.WRITE_SRAM, bytes([offset]) + data)
def send_i2c_read_response(self, data: bytes):
self._send(MsgType.I2C_READ_RESPONSE, data)
def send_reset(self):
self._send(MsgType.RESET)

116
pm3py/sim/mcu_protocol.py Normal file
View File

@@ -0,0 +1,116 @@
"""MCU bridge protocol — COBS framing and message types."""
from __future__ import annotations
def cobs_encode(data: bytes) -> bytes:
"""COBS-encode data (no trailing zero delimiter — caller adds it)."""
output = bytearray()
# Process data by splitting on zeros
# After the last segment, no implicit zero is added
segments = data.split(b"\x00")
for i, segment in enumerate(segments):
is_last = (i == len(segments) - 1)
if len(segment) == 0:
if not is_last:
# Empty segment between zeros (or at start) — code 0x01, implicit zero
output.append(0x01)
else:
# Trailing empty segment — emit 0x01 as final group (no implicit zero)
output.append(0x01)
else:
# Split long segments into 254-byte chunks
pos = 0
while pos < len(segment):
chunk_len = min(254, len(segment) - pos)
output.append(chunk_len + 1)
output.extend(segment[pos:pos + chunk_len])
pos += chunk_len
# If chunk is exactly 254 bytes (code 0xFF), no implicit zero
# If there's remaining data in this segment, continue
# If this is the last chunk and not the last segment,
# code < 0xFF carries implicit zero — handled by next iteration
if chunk_len == 254 and pos < len(segment):
# 0xFF block, continue to next chunk (no implicit zero)
pass
elif chunk_len == 254 and pos == len(segment) and not is_last:
# Last chunk was exactly 254 (code 0xFF, no implicit zero)
# but we need an implicit zero for the separator — add 0x01
output.append(0x01)
return bytes(output)
def cobs_decode(data: bytes) -> bytes:
"""COBS-decode data (without trailing zero delimiter)."""
if len(data) == 0:
raise ValueError("empty COBS frame")
output = bytearray()
idx = 0
while idx < len(data):
code = data[idx]
if code == 0:
raise ValueError(f"unexpected zero at offset {idx}")
idx += 1
run_len = code - 1
if idx + run_len > len(data):
raise ValueError(f"COBS code {code} at offset {idx-1} overflows frame")
output.extend(data[idx:idx + run_len])
idx += run_len
# Implicit zero between groups (not after last group)
if code < 0xFF and idx < len(data):
output.append(0x00)
return bytes(output)
class MsgType:
"""MCU bridge message types."""
I2C_WRITE = 0x01
I2C_READ_REQ = 0x02
I2C_STOP = 0x03
MCU_READY = 0xF0
MCU_ERROR = 0xF1
I2C_READ_RESPONSE = 0x10
SET_ED = 0x20
SET_EH = 0x21
SET_EH_VOLTAGE = 0x22
WRITE_SRAM = 0x30
RESET = 0xF0
def build_frame(msg_type: int, payload: bytes = b"") -> bytes:
"""Build a COBS-framed, zero-delimited message."""
raw = bytes([msg_type]) + payload
return cobs_encode(raw) + b"\x00"
def parse_frame(raw: bytes) -> tuple[int, bytes]:
"""Parse a decoded (post-COBS) message into (msg_type, payload)."""
if len(raw) < 1:
raise ValueError("empty message")
return raw[0], raw[1:]
class StreamDeframer:
"""Stateful COBS deframer for serial byte streams."""
def __init__(self):
self._buf = bytearray()
def feed(self, data: bytes) -> list[tuple[int, bytes]]:
"""Feed raw serial bytes. Returns list of decoded (msg_type, payload)."""
self._buf.extend(data)
messages = []
while True:
zero_idx = self._buf.find(b"\x00")
if zero_idx == -1:
break
frame_bytes = bytes(self._buf[:zero_idx])
self._buf = self._buf[zero_idx + 1:]
if len(frame_bytes) == 0:
continue
try:
decoded = cobs_decode(frame_bytes)
msg_type, payload = parse_frame(decoded)
messages.append((msg_type, payload))
except (ValueError, IndexError):
pass
return messages

72
pm3py/sim/medium.py Normal file
View File

@@ -0,0 +1,72 @@
"""Medium — the RF communication channel between readers and transponders."""
from __future__ import annotations
import abc
import asyncio
from .frame import RFFrame
from .transponder import Transponder
class Medium(abc.ABC):
"""Abstract RF communication channel."""
@abc.abstractmethod
async def transmit_reader(self, frame: RFFrame) -> None:
"""Reader sends a frame. All transponders on this medium receive it."""
@abc.abstractmethod
async def receive_reader(self, timeout_us: int = 5000) -> RFFrame | None:
"""Reader waits for transponder response(s). Returns merged/collided frame."""
@abc.abstractmethod
async def attach(self, transponder: Transponder) -> int:
"""Place a transponder on the field. Returns transponder_id."""
@abc.abstractmethod
async def detach(self, transponder_id: int) -> None:
"""Remove transponder from field."""
class SoftwareMedium(Medium):
"""Pure-software RF medium with bit-level collision detection."""
def __init__(self, strict_timing: bool = False):
self._transponders: dict[int, Transponder] = {}
self._next_id = 0
self._responses: list[RFFrame] = []
self._strict_timing = strict_timing
async def attach(self, transponder: Transponder) -> int:
tid = self._next_id
self._next_id += 1
self._transponders[tid] = transponder
await transponder.power_on()
return tid
async def detach(self, transponder_id: int) -> None:
if transponder_id not in self._transponders:
raise KeyError(f"No transponder with id {transponder_id}")
transponder = self._transponders.pop(transponder_id)
await transponder.power_off()
async def transmit_reader(self, frame: RFFrame) -> None:
"""Deliver frame to all transponders, collect responses."""
self._responses.clear()
results = await asyncio.gather(
*(t.handle_frame(frame) for t in self._transponders.values()),
return_exceptions=True,
)
for result in results:
if isinstance(result, RFFrame):
self._responses.append(result)
async def receive_reader(self, timeout_us: int = 5000) -> RFFrame | None:
"""Merge all transponder responses. Detect collisions at bit level."""
if not self._responses:
return None
if len(self._responses) == 1:
f = self._responses[0]
return RFFrame(bits=f.bits.__copy__(), bit_count=f.bit_count,
collision_positions=[])
return RFFrame.merge(self._responses)

70
pm3py/sim/memory.py Normal file
View File

@@ -0,0 +1,70 @@
"""Memory primitives: DirtyByteArray, MemoryRegion, BlockAccess."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
class DirtyByteArray(bytearray):
"""Bytearray that tracks whether it's been modified since last clear."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._dirty = False
self.on_dirty: Callable[[], None] | None = None
@property
def dirty(self) -> bool:
return self._dirty
def clear_dirty(self) -> None:
self._dirty = False
def _mark_dirty(self) -> None:
self._dirty = True
if self.on_dirty:
self.on_dirty()
def __setitem__(self, key, value):
super().__setitem__(key, value)
self._mark_dirty()
def extend(self, other):
super().extend(other)
self._mark_dirty()
def append(self, item):
super().append(item)
self._mark_dirty()
@dataclass
class BlockAccess:
"""Per-block access control hints for table compiler."""
read: str = "open" # "open", "password", "key", "aes", "deny"
write: str = "open"
write_mode: str = "normal" # "normal" = overwrite, "counter" = increment LE16
read_key: int | None = None
write_key: int | None = None
@dataclass
class MemoryRegion:
"""A named memory area within a transponder."""
name: str
data: DirtyByteArray
block_size: int = 0 # 0 = not block-addressed
eml_offset: int = -1 # firmware EML offset, -1 = table/relay only
rf_readable: bool = True # if True, table compiler generates READ entries
default_access: BlockAccess = field(default_factory=BlockAccess)
access_map: list[BlockAccess] | None = None
@property
def num_blocks(self) -> int:
if self.block_size <= 0:
return 0
return len(self.data) // self.block_size
def access_for_block(self, block: int) -> BlockAccess:
if self.access_map and block < len(self.access_map):
return self.access_map[block]
return self.default_access

358
pm3py/sim/mifare.py Normal file
View File

@@ -0,0 +1,358 @@
"""MIFARE Classic transponder and reader models with Crypto-1 authentication."""
from __future__ import annotations
import struct
from .frame import RFFrame
from .medium import Medium
from .iso14443a import Tag14443A_3, Reader14443A, _compute_bcc
from .crypto1 import Crypto1
# MIFARE Classic commands
AUTH_A = 0x60
AUTH_B = 0x61
READ_CMD = 0x30
WRITE_CMD = 0xA0
ACK = 0x0A
NACK = 0x00
BLOCK_SIZE = 16
class MifareClassicTag(Tag14443A_3):
"""MIFARE Classic 1K/4K transponder with Crypto-1 authentication."""
_SIZE_CONFIG = {
"1k": {"atqa": b"\x04\x00", "sak": 0x08, "blocks": 64},
"4k": {"atqa": b"\x02\x00", "sak": 0x18, "blocks": 256},
}
def __init__(self, uid: bytes, size: str = "1k",
keys_a: dict[int, bytes] | None = None,
keys_b: dict[int, bytes] | None = None,
data: bytearray | None = None):
cfg = self._SIZE_CONFIG[size]
super().__init__(uid=uid, atqa=cfg["atqa"], sak=cfg["sak"])
self._size = size
self._num_blocks = cfg["blocks"]
self._num_sectors = 16 if size == "1k" else 40
self._keys_a = keys_a or {s: b"\xFF" * 6 for s in range(self._num_sectors)}
self._keys_b = keys_b or {s: b"\xFF" * 6 for s in range(self._num_sectors)}
self._data = data if data is not None else bytearray(self._num_blocks * BLOCK_SIZE)
# Region for block data (14a EML not yet supported, so eml_offset=-1)
from .memory import DirtyByteArray, MemoryRegion
self.regions["blocks"] = MemoryRegion(
name="blocks", data=DirtyByteArray(self._data),
block_size=BLOCK_SIZE, eml_offset=-1)
self._crypto: Crypto1 | None = None
self._auth_sector: int | None = None
self._auth_state: str = "NONE" # NONE, NONCE_SENT, AUTHENTICATED
self._tag_nonce: bytes = b""
def sector_for_block(self, block: int) -> int:
"""Map block number to sector number."""
if block < 128:
return block // 4
else:
return 32 + (block - 128) // 16
def _sector_trailer_block(self, sector: int) -> int:
"""Get the trailer block number for a sector."""
if sector < 32:
return sector * 4 + 3
else:
return 128 + (sector - 32) * 16 + 15
def read_block_raw(self, block: int) -> bytes:
"""Direct read of block data (bypassing auth)."""
offset = block * BLOCK_SIZE
return bytes(self._data[offset:offset + BLOCK_SIZE])
def write_block_raw(self, block: int, data: bytes) -> None:
"""Direct write of block data (bypassing auth)."""
if len(data) != BLOCK_SIZE:
raise ValueError(f"Block data must be {BLOCK_SIZE} bytes")
offset = block * BLOCK_SIZE
self._data[offset:offset + BLOCK_SIZE] = data
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
"""Handle MIFARE Classic commands after SELECT."""
if not frame.data:
return None
# Auth response comes as raw encrypted data (not a command)
if self._auth_state == "NONCE_SENT":
return self._handle_auth_response(frame)
# Write phase 2: receive data after ACK (before command dispatch)
if self._auth_state == "WRITE_PENDING":
return self._handle_write_phase2(frame)
cmd = frame.data[0]
# AUTH commands work in any post-SELECT state
if cmd in (AUTH_A, AUTH_B):
return self._handle_auth_start(frame)
if self._auth_state != "AUTHENTICATED":
return None # not authenticated
# Authenticated commands (plaintext in simulation)
if cmd == READ_CMD:
return self._handle_read(frame)
if cmd == WRITE_CMD:
return self._handle_write_phase1(frame)
return None
def _handle_auth_start(self, frame: RFFrame) -> RFFrame | None:
"""Handle AUTH_A/AUTH_B command: return tag nonce."""
if len(frame.data) < 2:
return None
cmd = frame.data[0]
block = frame.data[1]
sector = self.sector_for_block(block)
key_type = "a" if cmd == AUTH_A else "b"
key = self._keys_a[sector] if key_type == "a" else self._keys_b[sector]
# Store the key for verification during auth response
self._auth_key = key
self._auth_sector = sector
# Generate tag nonce
self._crypto = Crypto1(key)
self._tag_nonce = self._crypto.generate_nonce()
self._auth_state = "NONCE_SENT"
# Initialize cipher with uid ^ nt
uid_int = struct.unpack(">I", self._uid[:4])[0]
nt_int = struct.unpack(">I", self._tag_nonce)[0]
# Re-init crypto with key and auth init
self._crypto = Crypto1(key)
self._crypto.init_auth(uid_int, nt_int)
return RFFrame.from_bytes(self._tag_nonce)
def _handle_auth_response(self, frame: RFFrame) -> RFFrame | None:
"""Handle reader's auth response (nr_enc + ar_enc).
Verifies the reader used the correct key by re-deriving the expected
encrypted response and comparing.
"""
if len(frame.data) < 8 or self._crypto is None:
self._auth_state = "NONE"
return None
nr_enc = frame.data[0:4]
ar_enc = frame.data[4:8]
# Verify: build a reader-side Crypto1 with the tag's key and same
# init_auth parameters. If the reader used the correct key, its
# encrypted output will match what we compute.
uid_int = struct.unpack(">I", self._uid[:4])[0]
nt_int = struct.unpack(">I", self._tag_nonce)[0]
verify = Crypto1(self._auth_key)
verify.init_auth(uid_int, nt_int)
# The reader encrypted `nr` with its keystream. If keys match,
# decrypting with our matching keystream gives the real `nr`.
# Then re-encrypting `nr` with our verify keystream should match nr_enc.
expected_ks = bytearray(8)
for i in range(8):
expected_ks[i] = verify.generate_byte()
# If keys match: nr_enc = nr XOR ks. Both sides have same ks.
# We can verify by checking that nr_enc XOR ks produces the same
# nr that the reader intended, and then checking ar consistency.
# Simpler: just verify the reader's 8 bytes match what our key produces.
# The reader sends nr_enc = nr XOR reader_ks, ar_enc = ar XOR reader_ks.
# If reader has same key, reader_ks == our_ks, so:
# nr_enc XOR our_ks[0:4] == nr (the reader's chosen nonce)
# ar_enc XOR our_ks[4:8] == ar (should be a specific value)
# Since nr is random (reader-chosen), we can't verify it.
# But we CAN verify the relationship: if keys differ, ks differs,
# so the decrypted values will be inconsistent.
# For simulation simplicity: accept if keys match, reject otherwise.
# We detect key mismatch by checking that both sides produce
# identical keystreams after init_auth with the same parameters.
tag_ks = bytearray(8)
# Reset tag's crypto to same state
tag_verify = Crypto1(self._auth_key)
tag_verify.init_auth(uid_int, nt_int)
for i in range(8):
tag_ks[i] = tag_verify.generate_byte()
# Now check if nr_enc was produced by the same keystream
# nr_enc = nr XOR ks_reader. If ks_reader == ks_tag, then
# nr_enc XOR ks_tag gives us the real nr.
# We don't know nr, but we can verify that the READER also used
# this same ks by checking if their encrypted output is valid.
# The reader's key produces a different ks if wrong.
# Detect by: reader computes ks_r = Crypto1(wrong_key).init_auth(uid,nt)
# nr_enc = nr XOR ks_r. ks_r != ks_tag. So nr_enc XOR ks_tag != nr.
# This is fine, but we can't verify nr is "correct" since it's random.
# ACTUAL SIMPLE APPROACH: The reader embeds its key hash in the protocol.
# For simulation, we'll pass the reader's key through the frame metadata.
# But that breaks the protocol abstraction.
# PRAGMATIC APPROACH: Use deterministic nr and verify the full 8-byte
# encrypted block matches what a correct-key reader would produce.
reader_crypto_check = Crypto1(self._auth_key)
reader_crypto_check.init_auth(uid_int, nt_int)
# Generate the same nr the reader uses (we know it's deterministic: 0x01234567)
# and compute what the correct output should be
check_nr = b"\x01\x23\x45\x67" # reader's known nr
expected_nr_enc = reader_crypto_check.encrypt_bytes(check_nr)
expected_ar_enc = reader_crypto_check.encrypt_bytes(b"\x00\x00\x00\x00")
if nr_enc + ar_enc != expected_nr_enc + expected_ar_enc:
self._auth_state = "NONE"
self._crypto = None
return None
# Consume our crypto's keystream to stay in sync
self._crypto.encrypt_bytes(nr_enc)
self._crypto.encrypt_bytes(ar_enc)
self._auth_state = "AUTHENTICATED"
at = self._crypto.encrypt_bytes(b"\x00\x00\x00\x00")
return RFFrame.from_bytes(at)
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
"""Handle READ command — return 16 bytes of block data."""
if len(frame.data) < 2:
return None
block = frame.data[1]
if block >= self._num_blocks:
return None
# Check block is in authenticated sector
if self.sector_for_block(block) != self._auth_sector:
return None
data = self.read_block_raw(block)
return RFFrame.from_bytes(data)
def _handle_write_phase1(self, frame: RFFrame) -> RFFrame | None:
"""Handle WRITE command phase 1 — ACK, wait for data."""
if len(frame.data) < 2:
return None
block = frame.data[1]
if block >= self._num_blocks:
return None
if self.sector_for_block(block) != self._auth_sector:
return None
self._write_target_block = block
self._auth_state = "WRITE_PENDING"
return RFFrame.from_bytes(bytes([ACK]))
def _handle_write_phase2(self, frame: RFFrame) -> RFFrame | None:
"""Handle WRITE data (16 bytes after ACK)."""
if len(frame.data) < BLOCK_SIZE:
self._auth_state = "AUTHENTICATED"
return None
self.write_block_raw(self._write_target_block, frame.data[:BLOCK_SIZE])
self._auth_state = "AUTHENTICATED"
return RFFrame.from_bytes(bytes([ACK]))
class MifareClassicReader:
"""MIFARE Classic reader with Crypto-1 authentication."""
def __init__(self, medium: Medium):
self._medium = medium
self._reader = Reader14443A(medium)
async def read_block(self, uid: bytes, block: int,
key: bytes, key_type: str = "a") -> dict:
"""Authenticate and read a block."""
try:
await self._reader.select_tag(uid)
except RuntimeError:
return {"success": False, "data": None}
# AUTH
auth_cmd = AUTH_A if key_type == "a" else AUTH_B
auth_frame = RFFrame.from_bytes(bytes([auth_cmd, block]))
await self._medium.transmit_reader(auth_frame)
nt_resp = await self._medium.receive_reader()
if nt_resp is None or len(nt_resp.data) < 4:
return {"success": False, "data": None}
nt = nt_resp.data[:4]
# Initialize crypto
crypto = Crypto1(key)
uid_int = struct.unpack(">I", uid[:4])[0]
nt_int = struct.unpack(">I", nt)[0]
crypto.init_auth(uid_int, nt_int)
# Send nr_enc + ar_enc
nr = b"\x01\x23\x45\x67"
nr_enc = crypto.encrypt_bytes(nr)
ar_enc = crypto.encrypt_bytes(b"\x00\x00\x00\x00")
auth_resp_frame = RFFrame.from_bytes(nr_enc + ar_enc)
await self._medium.transmit_reader(auth_resp_frame)
at_resp = await self._medium.receive_reader()
if at_resp is None:
return {"success": False, "data": None}
# Authenticated — send READ
read_frame = RFFrame.from_bytes(bytes([READ_CMD, block]))
await self._medium.transmit_reader(read_frame)
data_resp = await self._medium.receive_reader()
if data_resp is None:
return {"success": False, "data": None}
return {"success": True, "data": data_resp.data[:16]}
async def write_block(self, uid: bytes, block: int, data: bytes,
key: bytes, key_type: str = "a") -> dict:
"""Authenticate and write a block."""
try:
await self._reader.select_tag(uid)
except RuntimeError:
return {"success": False}
# AUTH
auth_cmd = AUTH_A if key_type == "a" else AUTH_B
auth_frame = RFFrame.from_bytes(bytes([auth_cmd, block]))
await self._medium.transmit_reader(auth_frame)
nt_resp = await self._medium.receive_reader()
if nt_resp is None or len(nt_resp.data) < 4:
return {"success": False}
nt = nt_resp.data[:4]
# Initialize crypto
crypto = Crypto1(key)
uid_int = struct.unpack(">I", uid[:4])[0]
nt_int = struct.unpack(">I", nt)[0]
crypto.init_auth(uid_int, nt_int)
# Send nr_enc + ar_enc
nr = b"\x01\x23\x45\x67"
nr_enc = crypto.encrypt_bytes(nr)
ar_enc = crypto.encrypt_bytes(b"\x00\x00\x00\x00")
await self._medium.transmit_reader(RFFrame.from_bytes(nr_enc + ar_enc))
at_resp = await self._medium.receive_reader()
if at_resp is None:
return {"success": False}
# WRITE phase 1
write_frame = RFFrame.from_bytes(bytes([WRITE_CMD, block]))
await self._medium.transmit_reader(write_frame)
ack_resp = await self._medium.receive_reader()
if ack_resp is None or ack_resp.data[0] != ACK:
return {"success": False}
# WRITE phase 2: send data (plaintext in simulation)
await self._medium.transmit_reader(RFFrame.from_bytes(data))
await self._medium.receive_reader() # consume any response
return {"success": True}

282
pm3py/sim/ndef.py Normal file
View File

@@ -0,0 +1,282 @@
"""NFC Forum Type 2 and Type 4 tag models with NDEF support."""
from __future__ import annotations
import struct
from .frame import RFFrame
from .iso14443a import Tag14443A_3, Tag14443A_4
PAGE_SIZE = 4
READ_CMD = 0x30
WRITE_CMD = 0xA2
NDEF_TLV_TYPE = 0x03
TERMINATOR_TLV = 0xFE
NDEF_MAGIC = 0xE1
class NfcType2Tag(Tag14443A_3):
"""NFC Forum Type 2 Tag (NTAG-like).
Memory layout:
- Pages 0-1: UID (managed by 14443-A anticollision)
- Page 2: internal/lock bits
- Page 3: Capability Container (CC)
- Pages 4+: NDEF data area (TLV format)
"""
def __init__(self, uid: bytes, ndef_message: bytes = b"",
total_pages: int = 45, # NTAG213-like
atqa: bytes = b"\x44\x00", sak: int = 0x00):
if len(uid) != 7:
uid = b"\x04" + uid[:6] if len(uid) < 7 else uid[:7]
super().__init__(uid=uid, atqa=atqa, sak=sak)
self._total_pages = total_pages
self._memory = bytearray(total_pages * PAGE_SIZE)
self._init_memory(ndef_message)
def _init_memory(self, ndef_message: bytes) -> None:
"""Initialize memory with CC and NDEF TLV."""
self._ndef_data_offset = 4 * PAGE_SIZE # page 4
self._ndef_capacity = (self._total_pages - 4) * PAGE_SIZE - 3
# Page 3: Capability Container
data_size = (self._total_pages - 4) * PAGE_SIZE
cc = bytes([
NDEF_MAGIC,
0x10, # version 1.0
data_size // 8,
0x00, # read/write access
])
self._memory[3 * PAGE_SIZE:4 * PAGE_SIZE] = cc
if ndef_message:
self.set_ndef(ndef_message)
def set_ndef(self, message: bytes) -> None:
"""Write an NDEF message. Call sync() to push to firmware.
Args:
message: Raw NDEF message bytes. Use ndef_text(), ndef_uri() etc.
from pm3py.sim.type5 to build records.
Raises:
ValueError: If message exceeds available space.
"""
if len(message) > self._ndef_capacity:
raise ValueError(
f"NDEF message too large: {len(message)} bytes, "
f"capacity is {self._ndef_capacity} bytes")
# Clear data area
self._memory[self._ndef_data_offset:] = bytes(
len(self._memory) - self._ndef_data_offset)
offset = self._ndef_data_offset
if len(message) < 0xFF:
tlv = bytes([NDEF_TLV_TYPE, len(message)]) + message
else:
tlv = bytes([NDEF_TLV_TYPE, 0xFF,
(len(message) >> 8) & 0xFF,
len(message) & 0xFF]) + message
tlv += bytes([TERMINATOR_TLV])
self._memory[offset:offset + len(tlv)] = tlv
def clear_ndef(self) -> None:
"""Remove NDEF message."""
self._memory[self._ndef_data_offset:] = bytes(
len(self._memory) - self._ndef_data_offset)
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
if not frame.data:
return None
cmd = frame.data[0]
if cmd == READ_CMD:
return self._handle_read(frame)
if cmd == WRITE_CMD:
return self._handle_write(frame)
return None
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
"""READ: return 16 bytes (4 pages) starting from given page."""
if len(frame.data) < 2:
return None
page = frame.data[1]
if page >= self._total_pages:
return None
# Return 4 pages (16 bytes), wrapping at end
data = bytearray(16)
for i in range(16):
offset = (page * PAGE_SIZE + i) % len(self._memory)
data[i] = self._memory[offset]
return RFFrame.from_bytes(bytes(data))
def _handle_write(self, frame: RFFrame) -> RFFrame | None:
"""WRITE: write 4 bytes to a single page."""
if len(frame.data) < 6:
return None
page = frame.data[1]
if page < 4 or page >= self._total_pages:
return None # can't write to header/CC pages
offset = page * PAGE_SIZE
self._memory[offset:offset + PAGE_SIZE] = frame.data[2:6]
return RFFrame.from_bytes(b"\x0A") # ACK
# ---- NFC Type 4 Tag ----
NDEF_AID = b"\xD2\x76\x00\x00\x85\x01\x01"
CC_FILE_ID = b"\xE1\x03"
NDEF_FILE_ID = b"\xE1\x04"
SW_OK = b"\x90\x00"
SW_NOT_FOUND = b"\x6A\x82"
SW_WRONG_P1P2 = b"\x6A\x86"
SW_WRONG_LENGTH = b"\x67\x00"
# ISO 7816 commands
INS_SELECT = 0xA4
INS_READ_BINARY = 0xB0
INS_UPDATE_BINARY = 0xD6
class NfcType4Tag(Tag14443A_4):
"""NFC Forum Type 4 Tag with NDEF application.
Supports ISO 7816 SELECT, READ BINARY, UPDATE BINARY.
Files: CC file (E103), NDEF file (E104).
"""
def __init__(self, uid: bytes, ndef_message: bytes = b"",
max_ndef_size: int = 256,
atqa: bytes = b"\x04\x00", sak: int = 0x20,
ats: bytes = b"\x05\x78\x80\x70\x02"):
super().__init__(uid=uid, atqa=atqa, sak=sak, ats=ats)
self._max_ndef_size = max_ndef_size
self._ndef_message = ndef_message
self._selected_file: bytes | None = None
self._app_selected = False
self._build_files()
def _build_files(self) -> None:
"""Build CC and NDEF file contents."""
# CC file (15 bytes per NFC Forum Type 4 spec)
cc_len = 15
ndef_file_ctrl = bytes([
0x04, # NDEF file control TLV type
0x06, # length
0xE1, 0x04, # NDEF file ID
(self._max_ndef_size >> 8) & 0xFF,
self._max_ndef_size & 0xFF,
0x00, # read access: no security
0x00, # write access: no security
])
self._cc_file = struct.pack(">H", cc_len) + bytes([
0x20, # mapping version 2.0
0x00, 0x3B, # max R-APDU: 59 bytes
0x00, 0x34, # max C-APDU: 52 bytes
]) + ndef_file_ctrl
# NDEF file: 2-byte length prefix + message
self._update_ndef_file()
def _update_ndef_file(self) -> None:
nlen = len(self._ndef_message)
self._ndef_file = struct.pack(">H", nlen) + self._ndef_message
if len(self._ndef_file) < self._max_ndef_size:
self._ndef_file += b"\x00" * (self._max_ndef_size - len(self._ndef_file))
def set_ndef(self, message: bytes) -> None:
"""Write an NDEF message. For Type 4, takes effect immediately (no sync needed).
Args:
message: Raw NDEF message bytes. Use ndef_text(), ndef_uri() etc.
from pm3py.sim.type5 to build records.
Raises:
ValueError: If message exceeds max NDEF size.
"""
if len(message) > self._max_ndef_size - 2: # 2 bytes for length prefix
raise ValueError(
f"NDEF message too large: {len(message)} bytes, "
f"max is {self._max_ndef_size - 2} bytes")
self._ndef_message = message
self._update_ndef_file()
def clear_ndef(self) -> None:
"""Remove NDEF message."""
self._ndef_message = b""
self._update_ndef_file()
def _handle_apdu(self, apdu: bytes) -> bytes:
"""Process ISO 7816 APDU. Returns response data + SW."""
if len(apdu) < 4:
return SW_WRONG_LENGTH
ins = apdu[1]
p1 = apdu[2]
p2 = apdu[3]
if ins == INS_SELECT:
return self._handle_select(apdu)
elif ins == INS_READ_BINARY:
return self._handle_read_binary(apdu)
elif ins == INS_UPDATE_BINARY:
return self._handle_update_binary(apdu)
return SW_NOT_FOUND
def _handle_select(self, apdu: bytes) -> bytes:
p1 = apdu[2]
p2 = apdu[3]
lc = apdu[4] if len(apdu) > 4 else 0
data = apdu[5:5 + lc] if lc > 0 else b""
if p1 == 0x04:
# SELECT by AID
if data == NDEF_AID:
self._app_selected = True
self._selected_file = None
return SW_OK
return SW_NOT_FOUND
elif p1 == 0x00 and p2 == 0x0C:
# SELECT by file ID
if not self._app_selected:
return SW_NOT_FOUND
if data == CC_FILE_ID:
self._selected_file = CC_FILE_ID
return SW_OK
elif data == NDEF_FILE_ID:
self._selected_file = NDEF_FILE_ID
return SW_OK
return SW_NOT_FOUND
return SW_WRONG_P1P2
def _handle_read_binary(self, apdu: bytes) -> bytes:
if self._selected_file is None:
return SW_NOT_FOUND
offset = (apdu[2] << 8) | apdu[3]
le = apdu[4] if len(apdu) > 4 else 0
if self._selected_file == CC_FILE_ID:
data = self._cc_file[offset:offset + le]
elif self._selected_file == NDEF_FILE_ID:
data = self._ndef_file[offset:offset + le]
else:
return SW_NOT_FOUND
return data + SW_OK
def _handle_update_binary(self, apdu: bytes) -> bytes:
if self._selected_file != NDEF_FILE_ID:
return SW_NOT_FOUND
offset = (apdu[2] << 8) | apdu[3]
lc = apdu[4] if len(apdu) > 4 else 0
data = apdu[5:5 + lc]
ndef_file = bytearray(self._ndef_file)
ndef_file[offset:offset + len(data)] = data
self._ndef_file = bytes(ndef_file)
return SW_OK

23
pm3py/sim/ntag5_boost.py Normal file
View File

@@ -0,0 +1,23 @@
"""NTAG 5 Boost (NTA5332) — compact I2C bridge with ALM and AES.
NTAG 5 Link + Active Load Modulation + AES always enabled.
Same memory layout and I2C interface as Link NTP5332.
512 user blocks (2048 bytes) + 256-byte SRAM + config memory.
ALM (Active Load Modulation) is an RF modulation technique that
reduces antenna size without compromising read range. It has no
protocol-level impact — the sim model is identical to Link NTP5332.
"""
from __future__ import annotations
from .ntag5_link import Ntag5LinkTag
class Ntag5BoostTag(Ntag5LinkTag):
"""NTAG 5 Boost transponder (NTA5332).
= NTAG 5 Link NTP5332 + ALM. AES always enabled.
"""
def __init__(self, uid: bytes | str | None = None, **kwargs):
super().__init__(uid=uid, aes_capable=True, **kwargs)

261
pm3py/sim/ntag5_link.py Normal file
View File

@@ -0,0 +1,261 @@
"""NTAG 5 Link (NTP53x2) — NXP dual-interface NFC + I2C bridge.
Extends Ntag5PlatformTag with:
- I2C slave interface (configurable address, default 0x54)
- 256-byte SRAM (shared between NFC and I2C)
- Arbiter for NFC/I2C memory access coordination
- SRAM modes: mirror, pass-through, PHDC
- Two variants: NTP5312 (password only), NTP5332 (+ AES + I2C master)
TAM/MAM authentication uses the same protocol as ICODE DNA.
See docs/NTAG5_SECURITY.md for verified implementation details.
"""
from __future__ import annotations
from collections.abc import Callable
from .frame import RFFrame
from .iso15693 import FLAG_ADDRESS, FLAG_INVENTORY
from .ntag5_platform import Ntag5PlatformTag
from .nxp_icode import NXP_MFG
# SRAM arbiter modes
ARBITER_NORMAL = 0
ARBITER_MIRROR = 1
ARBITER_PASSTHROUGH = 2
ARBITER_PHDC = 3
class Ntag5LinkTag(Ntag5PlatformTag):
"""NTAG 5 Link transponder (NTP53x2).
Dual-interface NFC + I2C bridge with shared SRAM.
512 user blocks (2048 bytes) + config memory + 256-byte SRAM.
"""
def __init__(self, uid: bytes | str | None = None,
i2c_address: int = 0x54,
aes_capable: bool = False,
**kwargs):
kwargs.setdefault("num_blocks", 512)
super().__init__(uid=uid, aes_capable=aes_capable, **kwargs)
# I2C configuration
self._i2c_address = i2c_address
# SRAM: 256 bytes, accessible from both NFC and I2C
self._sram = bytearray(256)
self._sram_enabled = False
self._arbiter_mode = ARBITER_NORMAL
self._pt_transfer_dir = 0 # 0=I2C→NFC, 1=NFC→I2C
# Pass-through callback: called with (source, offset, data)
# where source is "nfc" or "i2c"
self.on_sram_write: Callable[[str, int, bytes], None] | None = None
# Synch data block: when set, access to this block triggers status flags
self._synch_data_block: int | None = None
@property
def sram(self) -> bytearray:
return self._sram
@property
def i2c_address(self) -> int:
return self._i2c_address
# ----- Arbitration -----
def acquire_nfc(self) -> bool:
"""Acquire NFC lock. Returns False if I2C holds the lock."""
if self.i2c_if_locked:
return False
self.set_nfc_locked(True)
return True
def release_nfc(self):
"""Release NFC lock."""
self.set_nfc_locked(False)
def acquire_i2c(self) -> bool:
"""Acquire I2C lock. Returns False if NFC holds the lock."""
if self.nfc_if_locked:
return False
self.set_i2c_locked(True)
return True
def release_i2c(self):
"""Release I2C lock."""
self.set_i2c_locked(False)
# ----- I2C interface stubs -----
def i2c_read(self, address: int, length: int) -> bytes | None:
"""Read from I2C address space. Returns None if arbitration denied.
I2C memory map:
0x0000-0x01FF: User EEPROM (mirrors NFC blocks)
0x1000-0x109F: Configuration memory
0x2000-0x203F: SRAM (64 blocks × 4 bytes = 256 bytes)
"""
if 0x2000 <= address < 0x2040:
if self.nfc_if_locked:
return None
offset = (address - 0x2000) * 4
result = bytes(self._sram[offset:offset + min(length, 256 - offset)])
# In pass-through NFC→I2C: I2C read clears sram_data_ready
if (self._sram_enabled and self._arbiter_mode == ARBITER_PASSTHROUGH
and self.config_pt_transfer_dir):
self.sram_data_ready = False
return result
elif 0x0000 <= address < 0x0200:
offset = address * 4
return bytes(self._memory[offset:offset + min(length, len(self._memory) - offset)])
elif 0x1000 <= address < 0x10A0:
cfg_block = address - 0x1000
offset = cfg_block * 4
return bytes(self._config_memory[offset:offset + min(length, len(self._config_memory) - offset)])
return bytes(length)
def i2c_write(self, address: int, data: bytes) -> bool:
"""Write to I2C address space. Returns False if arbitration denied."""
if 0x2000 <= address < 0x2040:
if self.nfc_if_locked:
return False
offset = (address - 0x2000) * 4
self._sram[offset:offset + len(data)] = data
# In pass-through I2C→NFC: I2C write sets sram_data_ready
if (self._sram_enabled and self._arbiter_mode == ARBITER_PASSTHROUGH
and not self.config_pt_transfer_dir):
self.sram_data_ready = True
if self.on_sram_write is not None:
self.on_sram_write("i2c", offset, bytes(data))
return True
elif 0x0000 <= address < 0x0200:
offset = address * 4
self._memory[offset:offset + len(data)] = data
return True
return False
# ----- SRAM mirror mode -----
def _handle_read_single(self, frame: RFFrame) -> RFFrame | None:
"""Override: SRAM mirror/pass-through redirects NFC reads to SRAM.
Returns error (0x01, 0x0F) if I2C holds the arbitration lock.
"""
if self._sram_enabled and self._arbiter_mode in (ARBITER_MIRROR, ARBITER_PASSTHROUGH):
block = self._extract_block_number(frame)
if block is not None and block < 64:
if self.i2c_if_locked:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
offset = block * 4
data = self._sram[offset:offset + self._block_size]
# In pass-through I2C→NFC: NFC read clears sram_data_ready
if (self._arbiter_mode == ARBITER_PASSTHROUGH
and not self.config_pt_transfer_dir):
self.sram_data_ready = False
return RFFrame.from_bytes(bytes([0x00]) + bytes(data))
resp = super()._handle_read_single(frame)
# Synch data block: set flag when NFC reads the configured block
if self._synch_data_block is not None and resp is not None and resp.data[0] == 0x00:
block = self._extract_block_number(frame)
if block == self._synch_data_block:
self.synch_block_read = True
self._check_ed_trigger("synch_read")
return resp
def _handle_write_single(self, frame: RFFrame) -> RFFrame | None:
"""Override: pass-through mode redirects NFC writes to SRAM."""
if self._sram_enabled and self._arbiter_mode == ARBITER_PASSTHROUGH:
flags = frame.data[0]
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
if is_addressed:
block_offset = 10 # flags + cmd + UID(8)
else:
block_offset = 2 # flags + cmd
if len(frame.data) <= block_offset:
return None
block = frame.data[block_offset]
if block < 64:
if self.i2c_if_locked:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
data_offset = block_offset + 1
data = frame.data[data_offset:data_offset + self._block_size]
if len(data) < self._block_size:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
offset = block * 4
self._sram[offset:offset + self._block_size] = data
# In pass-through NFC→I2C: NFC write sets sram_data_ready
if self.config_pt_transfer_dir:
self.sram_data_ready = True
if self.on_sram_write is not None:
self.on_sram_write("nfc", offset, bytes(data))
return RFFrame.from_bytes(bytes([0x00]))
resp = super()._handle_write_single(frame)
# Synch data block: set flag when NFC writes the configured block
if self._synch_data_block is not None and resp is not None and resp.data[0] == 0x00:
block = self._extract_block_number(frame)
if block == self._synch_data_block:
self.synch_block_write = True
self._check_ed_trigger("synch_write")
return resp
# ----- NXP custom commands -----
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
"""Override: add READ SRAM (0xD2) and WRITE SRAM (0xD3)."""
frame = self._normalize_custom_frame(frame)
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
if cmd == 0xD2:
return self._handle_read_sram(frame)
if cmd == 0xD3:
return self._handle_write_sram(frame)
return super()._handle_custom_command(cmd, frame)
def _handle_read_sram(self, frame: RFFrame) -> RFFrame | None:
"""READ SRAM (0xD2): direct SRAM read.
Frame (after normalization): flags(02) cmd(D2) mfg(04) block_addr(1) num_blocks(1)
Returns: flags(00) + block_data(num_blocks * 4 bytes)
"""
if len(frame.data) < 5:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if not self._sram_enabled:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if self.i2c_if_locked:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
block_addr = frame.data[3]
num_blocks = frame.data[4]
if block_addr + num_blocks > 64:
return RFFrame.from_bytes(bytes([0x01, 0x10]))
offset = block_addr * 4
length = num_blocks * 4
data = bytes(self._sram[offset:offset + length])
return RFFrame.from_bytes(bytes([0x00]) + data)
def _handle_write_sram(self, frame: RFFrame) -> RFFrame | None:
"""WRITE SRAM (0xD3): direct SRAM write.
Frame (after normalization): flags(02) cmd(D3) mfg(04) block_addr(1) num_blocks(1) data(N*4)
Returns: flags(00) on success.
"""
if len(frame.data) < 5:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if not self._sram_enabled:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if self.i2c_if_locked:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
block_addr = frame.data[3]
num_blocks = frame.data[4]
if block_addr + num_blocks > 64:
return RFFrame.from_bytes(bytes([0x01, 0x10]))
expected_data_len = num_blocks * 4
if len(frame.data) < 5 + expected_data_len:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
offset = block_addr * 4
self._sram[offset:offset + expected_data_len] = frame.data[5:5 + expected_data_len]
return RFFrame.from_bytes(bytes([0x00]))

446
pm3py/sim/ntag5_platform.py Normal file
View File

@@ -0,0 +1,446 @@
"""NTAG 5 platform base — shared across Switch, Link, and Boost.
All NTAG 5 variants share:
- Config memory with session registers (loaded from config on POR)
- Energy harvesting configuration
- Event detection pin (ED)
- PWM/GPIO configuration
- 16-bit counter on last user block
- READ/WRITE CONFIG commands
- Same NXP custom command set as SLIX2
The NTAG 5 platform extends SLIX2, not DNA. DNA adds AES crypto which
the Switch doesn't have. Link/Boost optionally add AES (and share the
same TAM/MAM protocol as DNA — see docs/NTAG5_SECURITY.md).
"""
from __future__ import annotations
import struct
from typing import Callable
from .auth_aes import NxpAesAuth
from .frame import RFFrame
from .icode_slix2 import IcodeSlix2Tag
from .nxp_icode import NXP_MFG
# NTAG 5 AREA_1 password identifiers (NTP5210/NTA5332 datasheets)
PWD_AREA1_READ = 0x40
PWD_AREA1_WRITE = 0x80
class Ntag5PlatformTag(NxpAesAuth, IcodeSlix2Tag):
"""NTAG 5 platform base — shared by Switch, Link, and Boost.
Features beyond IcodeSlix2Tag:
- Configuration memory accessible via READ/WRITE CONFIG
- Session registers (STATUS_REG, CONFIG_REG, etc.)
- Energy harvesting, event detection, PWM/GPIO
- 16-bit counter on last user block
AES auth (TAM/MAM) is added by subclasses that support it
(Link NTP5332, Boost NTA5332). The protocol is identical to
ICODE DNA — see docs/NTAG5_SECURITY.md for the verified
implementation including undocumented AuthMethod values.
"""
_uid_prefix = b"\xE0\x04\x01\x18" # NTAG 5 platform — same type indicator as DNA (bits 37:36 = 11)
def __init__(self, uid: bytes | str | None = None,
aes_capable: bool = False,
**kwargs):
super().__init__(uid=uid, **kwargs)
# AREA_1 password IDs (not present in SLIX2 parent)
self._passwords[PWD_AREA1_READ] = None
self._passwords[PWD_AREA1_WRITE] = None
# PICK RANDOM UID state
self._random_uid = None
# Configuration memory (same layout as ICODE 3 / NTAG 5 Link)
self._config_memory = bytearray(160 * 4) # 160 config blocks
self._config_password_protected = False
self._config_locked = False
# AES key management (from NxpAesAuth mixin)
self._init_aes_auth()
self._aes_capable = aes_capable
# Session registers
self._status_reg = bytearray(4)
self._config_reg = bytearray(4)
# Initialize session register defaults (all zeros = no field, no VCC, no locks)
# Energy harvesting
self._eh_enabled = False
# Event detection
self._ed_config = 0
self._ed_pin_state = False
self.ed_pin_callback: Callable[[bool], None] | None = None
# Initialize config defaults
self._init_platform_config()
def _init_platform_config(self):
"""Initialize config memory with platform defaults."""
# Block 0x37: CONFIG bytes (copied to session regs on POR)
# Block 0x3D: EH_CONFIG + ED_CONFIG
# Block 0x3E: I2C_SLAVE_CONFIG
# Block 0x3F: DEV_SEC_CONFIG + SRAM_CONF_PROT + PP_AREA_1
# Default DEV_SEC_CONFIG: 0xA5 (writable + plain password)
self._config_memory[0x3F * 4] = 0xA5
# ----- STATUS_REG bit-field properties (byte 0) -----
def _status_bit(self, byte_idx: int, bit: int) -> bool:
return bool(self._status_reg[byte_idx] & (1 << bit))
def _set_status_bit(self, byte_idx: int, bit: int, value: bool):
if value:
self._status_reg[byte_idx] |= (1 << bit)
else:
self._status_reg[byte_idx] &= ~(1 << bit)
# Byte 0 bits
@property
def nfc_field_ok(self) -> bool:
return self._status_bit(0, 0)
@property
def vcc_supply_ok(self) -> bool:
return self._status_bit(0, 1)
@property
def pt_transfer_dir(self) -> bool:
return self._status_bit(0, 2)
@pt_transfer_dir.setter
def pt_transfer_dir(self, value: bool):
self._set_status_bit(0, 2, value)
@property
def synch_block_read(self) -> bool:
return self._status_bit(0, 3)
@synch_block_read.setter
def synch_block_read(self, value: bool):
self._set_status_bit(0, 3, value)
@property
def synch_block_write(self) -> bool:
return self._status_bit(0, 4)
@synch_block_write.setter
def synch_block_write(self, value: bool):
self._set_status_bit(0, 4, value)
@property
def sram_data_ready(self) -> bool:
return self._status_bit(0, 5)
@sram_data_ready.setter
def sram_data_ready(self, value: bool):
self._set_status_bit(0, 5, value)
@property
def eeprom_wr_error(self) -> bool:
return self._status_bit(0, 6)
@eeprom_wr_error.setter
def eeprom_wr_error(self, value: bool):
self._set_status_bit(0, 6, value)
@property
def eeprom_wr_busy(self) -> bool:
return self._status_bit(0, 7)
# Byte 1 bits
@property
def nfc_if_locked(self) -> bool:
return self._status_bit(1, 0)
@property
def i2c_if_locked(self) -> bool:
return self._status_bit(1, 1)
@i2c_if_locked.setter
def i2c_if_locked(self, value: bool):
self._set_status_bit(1, 1, value)
@property
def nfc_boot_ok(self) -> bool:
return self._status_bit(1, 6)
@property
def vcc_boot_ok(self) -> bool:
return self._status_bit(1, 7)
# ----- CONFIG_REG bit-field properties (byte 0) -----
def _config_bit(self, byte_idx: int, bit: int) -> bool:
return bool(self._config_reg[byte_idx] & (1 << bit))
def _set_config_bit(self, byte_idx: int, bit: int, value: bool):
if value:
self._config_reg[byte_idx] |= (1 << bit)
else:
self._config_reg[byte_idx] &= ~(1 << bit)
@property
def disable_nfc(self) -> bool:
"""CONFIG_REG byte 0 bit 5: silences NFC interface (all except INVENTORY)."""
return self._config_bit(0, 5)
@disable_nfc.setter
def disable_nfc(self, value: bool):
self._set_config_bit(0, 5, value)
@property
def arbiter_mode(self) -> int:
"""CONFIG_REG byte 0 bits 3:2 — 0=normal, 1=mirror, 2=pass-through, 3=PHDC."""
return (self._config_reg[0] >> 2) & 0x03
@arbiter_mode.setter
def arbiter_mode(self, value: int):
self._config_reg[0] = (self._config_reg[0] & ~0x0C) | ((value & 0x03) << 2)
# Sync to subclass _arbiter_mode attribute if it exists
if hasattr(self, '_arbiter_mode'):
self._arbiter_mode = value & 0x03
@property
def sram_enable(self) -> bool:
"""CONFIG_REG byte 0 bit 1: gates all SRAM access."""
return self._config_bit(0, 1)
@sram_enable.setter
def sram_enable(self, value: bool):
self._set_config_bit(0, 1, value)
# Sync to subclass _sram_enabled attribute if it exists
if hasattr(self, '_sram_enabled'):
self._sram_enabled = value
@property
def config_pt_transfer_dir(self) -> bool:
"""CONFIG_REG byte 0 bit 0: 0=I2C→NFC, 1=NFC→I2C."""
return self._config_bit(0, 0)
@config_pt_transfer_dir.setter
def config_pt_transfer_dir(self, value: bool):
self._set_config_bit(0, 0, value)
# ----- Session register control methods -----
def set_nfc_field(self, active: bool):
"""Set NFC field state. Sets NFC_BOOT_OK on first activation."""
self._set_status_bit(0, 0, active)
if active:
self._set_status_bit(1, 6, True) # NFC_BOOT_OK
def set_vcc_supply(self, active: bool):
"""Set VCC supply state. Sets VCC_BOOT_OK on first activation."""
self._set_status_bit(0, 1, active)
if active:
self._set_status_bit(1, 7, True) # VCC_BOOT_OK
def set_i2c_locked(self, locked: bool):
"""Set I2C interface arbitration lock."""
self._set_status_bit(1, 1, locked)
def set_nfc_locked(self, locked: bool):
"""Set NFC interface arbitration lock."""
self._set_status_bit(1, 0, locked)
# ----- Event Detection (ED) pin -----
def assert_ed(self):
"""Assert ED pin (active-low open-drain output)."""
self._ed_pin_state = True
if self.ed_pin_callback is not None:
self.ed_pin_callback(True)
def release_ed(self):
"""Release ED pin."""
self._ed_pin_state = False
if self.ed_pin_callback is not None:
self.ed_pin_callback(False)
def _check_ed_trigger(self, event: str):
"""Check if *event* matches _ed_config and assert ED if so.
Events:
- "nfc_field" → bit 0
- "synch_read" → bit 4
- "synch_write" → bit 5
"""
bit_map = {"nfc_field": 0, "synch_read": 4, "synch_write": 5}
bit = bit_map.get(event)
if bit is not None and self._ed_config & (1 << bit):
self.assert_ed()
def _build_access_map(self) -> None:
"""Override: counter on last block."""
has_pp = self._protection_pointer is not None
last_block = self._num_blocks - 1
if not has_pp and last_block < 0:
self.regions["user"].access_map = None
return
from .memory import BlockAccess
from .icode_slix2 import PWD_READ
user = self.regions["user"]
access_map = []
for block in range(self._num_blocks):
ba = BlockAccess()
if has_pp and block >= self._protection_pointer:
ba = BlockAccess(read="password", read_key=PWD_READ)
if block == last_block:
ba = BlockAccess(read=ba.read, read_key=ba.read_key,
write_mode="counter")
access_map.append(ba)
user.access_map = access_map
# ----- Command dispatch -----
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
"""Override to intercept ISO 29167-10 CHALLENGE/READBUFFER/AUTHENTICATE.
Same pattern as IcodeDnaTag — commands 0x35, 0x39, 0x3A are standard
ISO commands that must be caught before the base class rejects them.
Only routed to AES mixin when aes_capable is True.
DISABLE_NFC enforcement: when CONFIG_REG bit 5 is set, the tag
silences all NFC responses except INVENTORY (cmd 0x01).
"""
# DISABLE_NFC: only INVENTORY (0x01) gets through
if self.disable_nfc and len(frame.data) >= 2:
cmd = frame.data[1]
if cmd != 0x01:
return None
if len(frame.data) >= 2:
cmd = frame.data[1]
if self._aes_capable:
if cmd == 0x35: # AUTHENTICATE (MAM1/MAM2)
return self._handle_authenticate(frame)
if cmd == 0x39: # CHALLENGE
self._handle_challenge(frame)
return None # no RF response
if cmd == 0x3A: # READBUFFER
return self._handle_readbuffer(frame)
# PICK RANDOM UID must be handled before SLIX2 privacy gate
# Only AES-capable variants support this (NTP5210 rev 3.2: "not supported by NTAG 5 switch")
if self._aes_capable and cmd == 0xC2 and len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
return self._handle_pick_random_uid(frame)
return await super().handle_frame(frame)
# ----- READ/WRITE CONFIG -----
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
frame = self._normalize_custom_frame(frame)
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
match cmd:
case 0xBC: # STAY QUIET PERSISTENT — not in NTP5210/NTA5332 command tables
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
case 0xC0: # READ CONFIG
return self._handle_read_config(frame)
case 0xC1: # WRITE CONFIG
return self._handle_write_config(frame)
case 0xC2: # PICK RANDOM UID — AES-capable only (not NTP5210)
if self._aes_capable:
return self._handle_pick_random_uid(frame)
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
return super()._handle_custom_command(cmd, frame)
# ----- PICK RANDOM UID (0xC2) -----
def _handle_pick_random_uid(self, frame: RFFrame) -> RFFrame | None:
"""PICK RANDOM UID (0xC2): generate random UID for privacy mode."""
if not self._privacy_mode:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
import os
self._random_uid = b"\xE0\x04" + os.urandom(6)
return RFFrame.from_bytes(bytes([0x00]))
def _get_session_register(self, block: int) -> bytes | None:
"""Return 4-byte session register for block addresses 0xA0-0xAF.
0xA0 = STATUS_REG, 0xA1 = CONFIG_REG, others return zeros.
"""
if block == 0xA0:
return bytes(self._status_reg)
elif block == 0xA1:
return bytes(self._config_reg)
elif 0xA2 <= block <= 0xAF:
return bytes(4) # reserved session register blocks
return None
def _handle_read_config(self, frame: RFFrame) -> RFFrame | None:
"""READ CONFIG (0xC0): read config memory or session register blocks."""
if len(frame.data) < 5:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
block_addr = frame.data[3]
num_blocks = frame.data[4] + 1
# Check if all requested blocks are valid (config or session range)
max_config = len(self._config_memory) // 4
for blk in range(block_addr, block_addr + num_blocks):
if blk < max_config:
continue # valid config block
if 0xA0 <= blk <= 0xAF:
continue # valid session register block
return RFFrame.from_bytes(bytes([0x01, 0x0F])) # out of range
resp = bytearray([0x00])
# Password blocks masked with 0x00 (blocks 0x20-0x2F for keys,
# 0x33-0x34 for I2C passwords)
pwd_blocks = set(range(0x20, 0x30)) | {0x33, 0x34, 0x35, 0x36}
for blk in range(block_addr, block_addr + num_blocks):
session_data = self._get_session_register(blk)
if session_data is not None:
resp.extend(session_data)
elif blk in pwd_blocks:
resp.extend(bytes(4))
else:
offset = blk * 4
resp.extend(self._config_memory[offset:offset + 4])
return RFFrame.from_bytes(bytes(resp))
def _handle_write_config(self, frame: RFFrame) -> RFFrame | None:
"""WRITE CONFIG (0xC1): write 4 bytes to config block or session register."""
if len(frame.data) < 8:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if self._config_locked:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
block_addr = frame.data[3]
data = frame.data[4:8]
# Session register block 0xA1 = CONFIG_REG (writable)
if block_addr == 0xA1:
self._apply_config_reg(data)
return RFFrame.from_bytes(bytes([0x00]))
# Session register block 0xAB = ED_INTR_CLEAR_REG
if block_addr == 0xAB:
self.release_ed()
return RFFrame.from_bytes(bytes([0x00]))
max_blocks = len(self._config_memory) // 4
if block_addr >= max_blocks:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
offset = block_addr * 4
self._config_memory[offset:offset + 4] = data
return RFFrame.from_bytes(bytes([0x00]))
def _apply_config_reg(self, data: bytes):
"""Apply CONFIG_REG data, triggering property setters for side effects."""
# Write raw bytes first
self._config_reg[:] = data
# Trigger side effects via property setters (reads from _config_reg)
self.sram_enable = self.sram_enable
self.arbiter_mode = self.arbiter_mode

21
pm3py/sim/ntag5_switch.py Normal file
View File

@@ -0,0 +1,21 @@
"""NTAG 5 Switch (NTP5210) — NFC PWM/GPIO bridge, no I2C.
Simplest NTAG 5 variant: RF + PWM/GPIO outputs, no I2C, no SRAM, no AES.
128 user blocks (512 bytes), block 0x7F = 16-bit counter.
"""
from __future__ import annotations
from .ntag5_platform import Ntag5PlatformTag
class Ntag5SwitchTag(Ntag5PlatformTag):
"""NTAG 5 Switch (NTP5210).
128 blocks (512 bytes) + config memory.
PWM/GPIO outputs, energy harvesting, event detection.
No I2C, no SRAM, no AES authentication.
"""
def __init__(self, uid: bytes | str | None = None, **kwargs):
kwargs.setdefault("num_blocks", 128)
super().__init__(uid=uid, aes_capable=False, **kwargs)

86
pm3py/sim/nxp_icode.py Normal file
View File

@@ -0,0 +1,86 @@
"""NXP ICODE/NTAG5 platform — shared NXP custom commands."""
from __future__ import annotations
import os
import struct
from .frame import RFFrame
from .trace_fmt import decode_15693_nxp
from .type5 import NfcType5Tag
# NXP manufacturer code
NXP_MFG = 0x04
# NXP custom commands per SL2S2602 datasheet
CMD_INVENTORY_READ = 0xA0
CMD_FAST_INVENTORY_READ = 0xA1
CMD_SET_EAS = 0xA2
CMD_RESET_EAS = 0xA3
CMD_LOCK_EAS = 0xA4
CMD_EAS_ALARM = 0xA5
CMD_PASSWORD_PROTECT_EAS_AFI = 0xA6
CMD_WRITE_EAS_ID = 0xA7
CMD_GET_NXP_SYSTEM_INFO = 0xAB
CMD_GET_RANDOM = 0xB2
CMD_SET_PASSWORD = 0xB3
CMD_WRITE_PASSWORD = 0xB4
CMD_LOCK_PASSWORD = 0xB5
CMD_PROTECT_PAGE = 0xB6
CMD_LOCK_PAGE_PROTECTION = 0xB7
CMD_DESTROY = 0xB9
CMD_ENABLE_PRIVACY = 0xBA
CMD_64BIT_PASSWORD_PROTECTION = 0xBB
CMD_STAY_QUIET_PERSISTENT = 0xBC
CMD_READ_SIGNATURE = 0xBD
CMD_READ_CONFIG = 0xC0
CMD_WRITE_CONFIG = 0xC1
CMD_READ_TT = 0xC4
class NxpIcodeTag(NfcType5Tag):
"""NXP ICODE/NTAG5 shared platform features.
Provides: originality signature, NXP system info, GET_RANDOM, SET_PASSWORD.
Base for ICODE SLIX, SLIX2, DNA, NTAG 5 Link, etc.
"""
_uid_prefix = b"\xE0\x04" # E0 + NXP manufacturer code
def __init__(self, uid: bytes, signature: bytes | None = None, **kwargs):
super().__init__(uid=uid, **kwargs)
# 32-byte ECC originality signature (from factory, read-only)
self._signature = signature or bytes(32)
def decode_trace(self, direction: int, payload: bytes) -> str | None:
return decode_15693_nxp(direction, payload) or super().decode_trace(direction, payload)
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
"""Handle NXP custom commands (0xA0-0xDF)."""
# Verify NXP manufacturer code
if len(frame.data) < 3 or frame.data[2] != NXP_MFG:
return None
match cmd:
case 0xB2: # GET RANDOM NUMBER
return self._handle_get_random(frame)
case 0xB3: # SET PASSWORD
return self._nxp_handle_set_password(frame)
return None
def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None:
"""GET NXP SYSTEM INFO (0xAB): returns PP byte, PP conditions, lock bits, feature flags."""
# Base implementation — subclasses override with actual PP/feature data
# Response: flags(1) + PP(1) + PP_cond(1) + lock_bits(1) + feature_flags(4)
return RFFrame.from_bytes(bytes([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
def _handle_get_random(self, frame: RFFrame) -> RFFrame | None:
random_bytes = os.urandom(2)
return RFFrame.from_bytes(bytes([0x00]) + random_bytes)
def _handle_read_signature(self, frame: RFFrame) -> RFFrame | None:
"""READ SIGNATURE (0xBD): returns 32-byte ECC originality signature."""
return RFFrame.from_bytes(bytes([0x00]) + self._signature)
def _nxp_handle_set_password(self, frame: RFFrame) -> RFFrame | None:
"""Base SET_PASSWORD handler. Override in subclasses for specific password types."""
return None

148
pm3py/sim/pm3medium.py Normal file
View File

@@ -0,0 +1,148 @@
"""PM3Medium — hardware-backed Medium using Proxmark3 as RF frontend.
Reader mode: PM3 acts as reader, talks to real tags on the antenna.
Uses existing pm3py command wrappers (hf.a14.raw(), HF_ISO15693_COMMAND, etc.)
"""
from __future__ import annotations
import struct
from .frame import RFFrame
from .medium import Medium
from .transponder import Transponder
from ..core.protocol import Cmd
from ..core.transport import PM3Transport
# ISO 14443-A flags (from pm3 firmware)
ISO14A_CONNECT = 0x0001
ISO14A_NO_DISCONNECT = 0x0002
ISO14A_RAW = 0x0008
ISO14A_NO_RATS = 0x0200
# ISO 15693 flags
ISO15_CONNECT = 0x01
ISO15_NO_DISCONNECT = 0x02
ISO15_RAW = 0x04
ISO15_APPEND_CRC = 0x08
ISO15_READ_RESPONSE = 0x10
class PM3ReaderMedium(Medium):
"""Proxmark3 as RF reader — sends commands to real tags.
Single-transponder (whatever physical tag is on the antenna).
Collision resolution is handled by PM3 firmware.
"""
def __init__(self, transport: PM3Transport, protocol: str = "14443a",
timeout: float = 5.0):
self._t = transport
self._protocol = protocol
self._timeout = timeout
self._last_tx: RFFrame | None = None
self._last_resp: RFFrame | None = None
self._connected = False
async def transmit_reader(self, frame: RFFrame) -> None:
"""Send frame to the real tag via PM3."""
self._last_tx = frame
self._last_resp = None
try:
if self._protocol == "14443a":
await self._transmit_14a(frame)
elif self._protocol == "15693":
await self._transmit_15693(frame)
except (TimeoutError, Exception):
self._last_resp = None
async def receive_reader(self, timeout_us: int = 5000) -> RFFrame | None:
"""Return the response from the last transmit."""
return self._last_resp
async def attach(self, transponder: Transponder) -> int:
raise NotImplementedError(
"PM3ReaderMedium doesn't support software transponders. "
"Place a physical tag on the PM3 antenna."
)
async def detach(self, transponder_id: int) -> None:
raise NotImplementedError(
"PM3ReaderMedium doesn't support software transponders."
)
async def dropfield(self) -> None:
"""Turn off the HF field."""
await self._t.send_ng_no_response(Cmd.HF_DROPFIELD)
self._connected = False
# ---- ISO 14443-A ----
async def _transmit_14a(self, frame: RFFrame) -> None:
data = frame.data
if not data:
return
cmd = data[0]
# REQA/WUPA → use scan (CONNECT)
if cmd in (0x26, 0x52) and not self._connected:
await self._14a_connect(frame)
return
# Everything else → raw exchange
await self._14a_raw(frame)
async def _14a_connect(self, frame: RFFrame) -> None:
"""Connect to tag (scan)."""
flags = ISO14A_CONNECT | ISO14A_NO_DISCONNECT
resp = await self._t.send_mix(
Cmd.HF_ISO14443A_READER,
arg0=flags,
timeout=self._timeout,
)
self._connected = True
if resp.data and len(resp.data) >= 14:
# Extract ATQA from iso14a_card_select_t
uid_len = resp.data[10]
atqa = resp.data[11:13]
self._last_resp = RFFrame.from_bytes(atqa)
else:
self._last_resp = None
async def _14a_raw(self, frame: RFFrame) -> None:
"""Send raw bytes to tag."""
flags = ISO14A_RAW | ISO14A_NO_DISCONNECT
resp = await self._t.send_mix(
Cmd.HF_ISO14443A_READER,
arg0=flags,
arg1=len(frame.data),
arg2=0,
payload=frame.data,
timeout=self._timeout,
)
data_len = resp.oldarg[0] if resp.oldarg else len(resp.data)
if data_len > 0 and resp.data:
self._last_resp = RFFrame.from_bytes(resp.data[:data_len])
else:
self._last_resp = None
# ---- ISO 15693 ----
async def _transmit_15693(self, frame: RFFrame) -> None:
"""Send ISO 15693 command via PM3."""
iso_cmd = frame.data
flags = ISO15_CONNECT | ISO15_READ_RESPONSE | ISO15_APPEND_CRC
payload = struct.pack("<BH", flags, len(iso_cmd)) + iso_cmd
resp = await self._t.send_ng(
Cmd.HF_ISO15693_COMMAND,
payload,
timeout=self._timeout,
)
if resp.data and len(resp.data) > 0:
self._last_resp = RFFrame.from_bytes(resp.data)
else:
self._last_resp = None

96
pm3py/sim/reader.py Normal file
View File

@@ -0,0 +1,96 @@
"""Reader — base class and modes for simulated RF readers."""
from __future__ import annotations
import abc
from dataclasses import dataclass, field
from typing import Callable
from .frame import RFFrame
from .medium import Medium
class Reader(abc.ABC):
"""Base class for simulated readers."""
def __init__(self, medium: Medium):
self._medium = medium
async def transceive(self, frame: RFFrame, timeout_us: int = 5000) -> RFFrame | None:
"""Send frame, wait for response."""
await self._medium.transmit_reader(frame)
return await self._medium.receive_reader(timeout_us)
@abc.abstractmethod
async def run(self) -> dict:
"""Execute the reader's full protocol sequence. Returns result dict."""
@dataclass
class ReaderStep:
"""One step in a scripted reader sequence."""
frame: RFFrame
description: str = ""
check: Callable[[RFFrame | None], bool] | None = None
stop_on_fail: bool = False
@dataclass
class StepResult:
"""Result of executing one ReaderStep."""
step: ReaderStep
response: RFFrame | None
passed: bool
class ScriptedReader:
"""Execute a reader protocol sequence programmatically."""
def __init__(self, medium: Medium):
self._medium = medium
async def run_script(self, steps: list[ReaderStep]) -> list[StepResult]:
"""Execute a sequence of steps with assertions."""
results = []
for step in steps:
await self._medium.transmit_reader(step.frame)
resp = await self._medium.receive_reader()
passed = step.check(resp) if step.check else True
result = StepResult(step=step, response=resp, passed=passed)
results.append(result)
if not result.passed and step.stop_on_fail:
break
return results
class InteractiveReader:
"""Step-through reader for debugging/education."""
def __init__(self, medium: Medium):
self._medium = medium
self._history: list[tuple[RFFrame, RFFrame | None]] = []
async def send(self, frame: RFFrame) -> RFFrame | None:
"""Send one frame, return response. Stores in history."""
await self._medium.transmit_reader(frame)
resp = await self._medium.receive_reader()
self._history.append((frame, resp))
return resp
async def send_hex(self, hex_str: str) -> RFFrame | None:
"""Convenience: send bytes from hex string."""
return await self.send(RFFrame.from_hex(hex_str))
@property
def history(self) -> list[tuple[RFFrame, RFFrame | None]]:
return list(self._history)
def dump_trace(self) -> str:
"""Pretty-print the entire conversation."""
lines = []
for i, (tx, rx) in enumerate(self._history):
lines.append(f"[{i}] TX: {tx!r}")
if rx is not None:
lines.append(f" RX: {rx!r}")
else:
lines.append(f" RX: (no response)")
return "\n".join(lines)

89
pm3py/sim/relay.py Normal file
View File

@@ -0,0 +1,89 @@
"""Relay and MitM — transparent relay and traffic interception."""
from __future__ import annotations
from typing import Callable
from .frame import RFFrame
from .medium import Medium
from .transponder import Transponder
from .reader import Reader
from .replay import TraceEntry
class RelayTransponder(Transponder):
"""Transponder that forwards all frames to an upstream reader.
Acts as a transparent relay: reader commands received on the local
medium are forwarded to the upstream reader (which talks to the
real card), and the real card's response is returned.
"""
def __init__(self, upstream_reader: Reader):
self._upstream = upstream_reader
self._powered = False
async def power_on(self) -> None:
self._powered = True
async def power_off(self) -> None:
self._powered = False
@property
def state(self) -> str:
return "RELAY" if self._powered else "OFF"
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
"""Forward frame to upstream reader, return response."""
if not self._powered:
return None
return await self._upstream.transceive(frame)
class MitMProxy:
"""Man-in-the-middle proxy between reader and tag mediums.
Intercepts traffic in both directions, optionally modifying frames.
Logs all traffic as a trace.
"""
def __init__(self, reader_medium: Medium, tag_medium: Medium,
intercept: Callable[[RFFrame, str], RFFrame | None] | None = None):
self._reader_medium = reader_medium
self._tag_medium = tag_medium
self._intercept = intercept
self._trace: list[TraceEntry] = []
@property
def trace(self) -> list[TraceEntry]:
return self._trace
async def forward_to_tag(self, frame: RFFrame) -> RFFrame | None:
"""Forward reader command to tag, return tag response."""
# Intercept reader→tag direction
if self._intercept:
frame = self._intercept(frame, "reader")
if frame is None:
return None
self._trace.append(TraceEntry(direction="reader", frame=frame))
# Forward to tag
await self._tag_medium.transmit_reader(frame)
resp = await self._tag_medium.receive_reader()
if resp is not None:
# Intercept tag→reader direction
if self._intercept:
resp = self._intercept(resp, "tag")
if resp is not None:
self._trace.append(TraceEntry(direction="tag", frame=resp))
return resp
async def forward_to_reader(self, frame: RFFrame) -> None:
"""Forward tag response back to reader medium."""
if self._intercept:
frame = self._intercept(frame, "tag")
if frame is None:
return
self._trace.append(TraceEntry(direction="tag", frame=frame))

83
pm3py/sim/replay.py Normal file
View File

@@ -0,0 +1,83 @@
"""Traffic recording and replay."""
from __future__ import annotations
from dataclasses import dataclass, field
from .frame import RFFrame
from .medium import Medium
@dataclass
class TraceEntry:
"""One entry in a protocol trace."""
direction: str # "reader" or "tag"
frame: RFFrame
timestamp_us: int = 0
annotation: str = ""
class TraceRecorder(Medium):
"""Wraps a Medium, recording all traffic as TraceEntry list."""
def __init__(self, inner: Medium):
self._inner = inner
self._trace: list[TraceEntry] = []
@property
def trace(self) -> list[TraceEntry]:
return self._trace
async def transmit_reader(self, frame: RFFrame) -> None:
self._trace.append(TraceEntry(direction="reader", frame=frame))
await self._inner.transmit_reader(frame)
async def receive_reader(self, timeout_us: int = 5000) -> RFFrame | None:
resp = await self._inner.receive_reader(timeout_us)
if resp is not None:
self._trace.append(TraceEntry(direction="tag", frame=resp))
return resp
async def attach(self, transponder) -> int:
return await self._inner.attach(transponder)
async def detach(self, transponder_id: int) -> None:
await self._inner.detach(transponder_id)
class TraceReplayer:
"""Replay a recorded trace against a live medium, comparing responses."""
def __init__(self, trace: list[TraceEntry], medium: Medium):
self._trace = trace
self._medium = medium
async def replay(self) -> list[dict]:
"""Replay reader frames from trace, compare tag responses."""
results = []
expected_responses = iter(
e for e in self._trace if e.direction == "tag"
)
for entry in self._trace:
if entry.direction != "reader":
continue
await self._medium.transmit_reader(entry.frame)
actual = await self._medium.receive_reader()
expected_entry = next(expected_responses, None)
expected = expected_entry.frame if expected_entry else None
matched = (
(actual is None and expected is None) or
(actual is not None and expected is not None and
actual.data == expected.data)
)
results.append({
"tx": entry.frame,
"expected": expected,
"actual": actual,
"matched": matched,
})
return results

387
pm3py/sim/sim_session.py Normal file
View File

@@ -0,0 +1,387 @@
"""SimSession — manages active card simulation with response table."""
from __future__ import annotations
import asyncio
import struct
import threading
import time
import serial
from .frame import RFFrame
from .table_compiler import ResponseTable, TableCompiler
from .trace_fmt import TraceFormatter
from .transponder import Transponder
from ..core.protocol import Cmd
from ..core.transport import encode_ng_frame, decode_response_frame, RESP_PREAMBLE_MAGIC, RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE
# Command IDs (must match firmware pm3_cmd.h)
CMD_SIM_TABLE_UPLOAD = 0x0950
CMD_SIM_TABLE_CLEAR = 0x0951
CMD_SIM_TABLE_UPDATE = 0x0952
CMD_HF_ISO15693_SIM_TRACE = 0x0336
# Max NG payload per frame
MAX_PAYLOAD = 512
# Default serial port for Proxmark3
DEFAULT_PORT = "/dev/ttyACM0"
class SimSession:
"""Manages an active card simulation.
For 15693 sims, use the synchronous API — no asyncio needed:
session = SimSession.open()
session.start_15693(tag)
# ... modify tag in REPL ...
tag.sync()
The async API (via PM3Transport) is available for 14443-A
WTX-based sims (future).
"""
def __init__(self, transport=None, port: serial.Serial | None = None):
self._t = transport
self._port = port # raw pyserial port for synchronous I/O
self._active = False
self._relay_task: asyncio.Task | None = None
self._tag_model: Transponder | None = None
self._formatter: TraceFormatter | None = None
self.on_field_strength: callable | None = None # callback(adc_mv: int)
@classmethod
def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SimSession":
"""Open a synchronous SimSession directly over serial.
No asyncio, no threads. For use in the REPL:
session = SimSession.open()
session.start_15693(tag)
tag._memory[0:4] = b"\\xDE\\xAD\\xBE\\xEF"
tag.sync()
"""
ser = serial.Serial()
ser.port = port
ser.baudrate = baudrate
ser.bytesize = 8
ser.parity = "N"
ser.stopbits = 1
ser.timeout = 1
ser.xonxoff = False
ser.rtscts = False
ser.dsrdtr = False
ser.open()
# Flush any stale data from previous session
ser.reset_input_buffer()
ser.reset_output_buffer()
# Give firmware a moment to settle after port open
time.sleep(0.5)
ser.reset_input_buffer()
return cls(port=ser)
async def upload_table(self, table: ResponseTable) -> None:
"""Upload response table to firmware BigBuf."""
data = table.serialize()
for i in range(0, len(data), MAX_PAYLOAD):
chunk = data[i:i + MAX_PAYLOAD]
cmd = CMD_SIM_TABLE_UPLOAD if i == 0 else CMD_SIM_TABLE_UPDATE
await self._t.send_ng(cmd, chunk)
async def clear_table(self) -> None:
"""Clear the firmware response table."""
await self._t.send_ng(CMD_SIM_TABLE_CLEAR, b"")
async def start_14a(self, tag, compile: bool = True) -> None:
"""Compile table, upload, start 14443-A sim with WTX relay loop."""
self._tag_model = tag
if compile:
if hasattr(tag, '_keys_a'):
table = TableCompiler.compile_mifare(tag)
else:
table = TableCompiler.compile_14a(tag)
await self.upload_table(table)
# Start sim (fire-and-forget — response comes when sim ends)
uid = tag._uid
flag_val = 0x0001 # FLAG_INTERACTIVE
if len(uid) == 4:
flag_val |= 0x0010
elif len(uid) == 7:
flag_val |= 0x0020
elif len(uid) == 10:
flag_val |= 0x0030
atqa = tag.atqa if hasattr(tag, 'atqa') else b"\x04\x00"
sak = tag.sak if hasattr(tag, 'sak') else 0x08
payload = atqa + bytes([sak]) + uid
await self._t.send_ng_no_response(Cmd.HF_ISO14443A_SIMULATE)
# TODO: use send_mix for 14a sim (MIX frame format)
self._active = True
self._relay_task = asyncio.create_task(self._relay_loop(tag))
def start_15693(self, tag, compile: bool = True, trace: bool = False) -> None:
"""Start 15693 sim (synchronous).
Firmware handles standard commands (inventory, read, write) autonomously.
After starting, modify the tag in the REPL and call tag.sync() to push
changes to firmware emulator memory.
Args:
trace: If True, print live reader↔tag communication to console.
"""
self._tag_model = tag
# Resolve the raw serial port
if self._port:
ser = self._port
elif self._t and self._t._writer:
ser = self._t._writer.transport.serial
else:
raise RuntimeError("No serial port available")
# Bind serial port to tag for tag.sync()
tag._serial = ser
# Start sim with UID and block_size — same format as PM3 client
# Client sends UID as-is (firmware reverses internally)
flags = 0x02 if trace else 0x00
block_size = tag._block_size if hasattr(tag, '_block_size') else 4
payload = tag._uid + bytes([block_size, flags])
frame = encode_ng_frame(Cmd.HF_ISO15693_SIMULATE, payload)
ser.write(frame)
# Give firmware time to start sim before any sync
import time
time.sleep(0.2)
# Push tag memory to firmware EML (always — firmware EML is fresh on sim start)
tag._uid_dirty = True
for r in tag.regions.values():
r.data._dirty = True
tag.sync()
self._active = True
if compile:
self._compile_and_upload_table(tag, ser)
if trace:
self._formatter = TraceFormatter(mode="sim", decoder=tag.decode_trace, crc_len=2)
self._trace_thread = threading.Thread(
target=self._trace_reader, args=(ser,), daemon=True
)
self._trace_thread.start()
print(f"[SimSession] 15693 sim started, UID={tag._uid.hex()}"
+ (" (trace ON)" if trace else ""))
print(f"[SimSession] Modify tag in REPL, then call tag.sync()")
def _compile_and_upload_table(self, tag, ser) -> None:
"""Compile response table from tag model and upload to firmware."""
table = ResponseTable(entries=[])
# Check most specific IC first, fall back to base
from .icode3 import Icode3Tag
from .icode_dna import IcodeDnaTag
from .icode_slix2 import IcodeSlix2Tag
from .ntag5_platform import Ntag5PlatformTag
if isinstance(tag, Ntag5PlatformTag):
# NTAG 5 Switch/Link/Boost — use DNA compiler (same platform)
table = TableCompiler.compile_icode_dna(tag)
elif isinstance(tag, Icode3Tag):
table = TableCompiler.compile_icode3(tag)
elif isinstance(tag, IcodeDnaTag):
table = TableCompiler.compile_icode_dna(tag)
elif isinstance(tag, IcodeSlix2Tag):
table = TableCompiler.compile_slix2(tag)
elif hasattr(tag, '_passwords'):
table = TableCompiler.compile_slix2(tag)
elif hasattr(tag, '_signature'):
table = TableCompiler.compile_nxp_icode(tag)
if not table.entries:
return
# Upload: first chunk has 4-byte initial_groups header
initial_groups = 0xFFFFFFFF
data = table.serialize()
entry_size = 120
# First frame
header = struct.pack("<I", initial_groups)
entries_per_chunk = (MAX_PAYLOAD - len(header)) // entry_size
first_data = data[:entries_per_chunk * entry_size]
payload = header + first_data
frame = encode_ng_frame(CMD_SIM_TABLE_UPLOAD, payload)
ser.write(frame)
time.sleep(0.05)
if ser.in_waiting:
ser.read(ser.in_waiting)
# Remaining chunks via UPDATE
remaining = data[entries_per_chunk * entry_size:]
chunk_size = (MAX_PAYLOAD // entry_size) * entry_size
for i in range(0, len(remaining), chunk_size):
chunk = remaining[i:i + chunk_size]
frame = encode_ng_frame(CMD_SIM_TABLE_UPDATE, chunk)
ser.write(frame)
time.sleep(0.05)
if ser.in_waiting:
ser.read(ser.in_waiting)
print(f"[SimSession] Uploaded {len(table.entries)} table entries")
def _trace_reader(self, ser: serial.Serial) -> None:
"""Background thread: read trace notifications from firmware and print them."""
buf = bytearray()
while self._active:
try:
chunk = ser.read(ser.in_waiting or 1)
if not chunk:
continue
buf.extend(chunk)
# Scan for response frames
while len(buf) >= RESP_PREAMBLE_SIZE + RESP_POSTAMBLE_SIZE:
# Find magic
idx = buf.find(struct.pack("<I", RESP_PREAMBLE_MAGIC))
if idx < 0:
buf = buf[-3:] # keep last 3 bytes (partial magic)
break
if idx > 0:
buf = buf[idx:]
if len(buf) < RESP_PREAMBLE_SIZE:
break
length_ng = struct.unpack_from("<H", buf, 4)[0]
payload_len = length_ng & 0x7FFF
frame_len = RESP_PREAMBLE_SIZE + payload_len + RESP_POSTAMBLE_SIZE
if len(buf) < frame_len:
break # wait for more data
try:
resp = decode_response_frame(bytes(buf[:frame_len]))
buf = buf[frame_len:]
if resp["cmd"] == CMD_HF_ISO15693_SIM_TRACE:
data = resp["data"]
if len(data) >= 2:
direction = data[0] & 0x0F
if direction == 0x03 and len(data) >= 3:
# ADC field strength report
adc_mv = (data[1] << 8) | data[2]
if self.on_field_strength:
self.on_field_strength(adc_mv)
else:
crc_fail = bool(data[0] & 0x80)
payload = data[1:]
self._formatter.print(direction, payload, crc_fail=crc_fail)
except Exception:
buf = buf[4:] # skip bad magic, try again
except serial.SerialException:
break
except Exception:
continue
async def _relay_loop(self, tag: Transponder) -> None:
"""Background: listen for WTX relay events (14443-A Layer 4 only).
15693 relay was removed — can't meet 311µs FDT over USB.
This loop is for future 14443-A ISO-DEP WTX relay where
S(WTX) gives seconds of response time.
"""
while self._active:
try:
resp = await self._t.read_response(timeout=0.5)
except Exception:
continue
if resp is None:
continue
elif resp.cmd == Cmd.HF_ISO15693_SIMULATE:
# Sim ended (firmware sent final reply)
print(f"[SimSession] Sim ended, status={resp.status}")
self._active = False
break
async def sync(self) -> None:
"""Push tag model state to firmware emulator memory.
Call this after modifying the tag in the REPL to update
what the firmware serves for standard commands.
"""
tag = self._tag_model
if tag is None:
return
# Build iso15_tag_t header (offsets match firmware struct)
uid_reversed = tag._uid[::-1] # firmware stores UID reversed
block_size = tag._block_size if hasattr(tag, '_block_size') else 4
num_blocks = tag._num_blocks if hasattr(tag, '_num_blocks') else 64
dsfid = tag._dsfid if hasattr(tag, '_dsfid') else 0
afi = tag._afi if hasattr(tag, '_afi') else 0
ic_ref = tag._ic_reference if hasattr(tag, '_ic_reference') else 0
header = bytearray(15)
header[0:8] = uid_reversed # uid[8]
header[8] = dsfid # dsfid
header[9] = 0 # dsfidLock
header[10] = afi # afi
header[11] = 0 # afiLock
header[12] = block_size # bytesPerPage
header[13] = num_blocks # pagesCount
header[14] = ic_ref # ic
# Write header to EML offset 0
await self._eml_set(0, header)
# Write tag memory data at offset 175 (after uid+config+locks)
# locks[160] at offset 15, data[2048] at offset 175
memory = tag._memory if hasattr(tag, '_memory') else b""
if memory:
# Chunk memory writes (max ~500 bytes per EML_SETMEM)
offset = 175
chunk_size = 480
for i in range(0, len(memory), chunk_size):
chunk = bytes(memory[i:i + chunk_size])
await self._eml_set(offset + i, chunk)
print(f"[SimSession] Synced tag state to firmware (uid={tag._uid.hex()}, "
f"{num_blocks} blocks of {block_size} bytes)")
async def _eml_set(self, offset: int, data: bytes) -> None:
"""Write data to firmware emulator memory at offset."""
payload = struct.pack("<IH", offset, len(data)) + data
await self._t.send_ng_no_response(Cmd.HF_ISO15693_EML_SETMEM, payload)
def stop(self) -> None:
"""Stop simulation."""
self._active = False
if self._port:
frame = encode_ng_frame(Cmd.BREAK_LOOP)
self._port.write(frame)
print("[SimSession] Sim stopped")
elif self._t:
# Fall back to async transport
asyncio.get_event_loop().run_until_complete(
self._t.send_ng_no_response(Cmd.BREAK_LOOP)
)
if self._relay_task:
self._relay_task.cancel()
self._relay_task = None
def close(self) -> None:
"""Stop sim and close serial port."""
self.stop()
if self._port:
self._port.close()
self._port = None
if self._tag_model:
self._tag_model._serial = None

163
pm3py/sim/t5577.py Normal file
View File

@@ -0,0 +1,163 @@
"""T5577/ATA5577 — programmable LF emulator transponder."""
from __future__ import annotations
import struct
from .frame import RFFrame
from .lf_base import TagLF, ReaderLF, Modulation
from .em4100 import EM4100Tag
# T5577 commands (first byte of frame)
CMD_READ = 0x01
CMD_WRITE = 0x02
CMD_PWD_READ = 0x03
CMD_PWD_WRITE = 0x04
# T5577 block 0 config bits for common modes
CONFIG_EM4100 = 0x00148040 # ASK/Manchester, RF/64, 64 bits, maxblk=2
class T5577Tag(TagLF):
"""T5577 programmable LF transponder.
8 blocks of 32 bits each. Block 0 is configuration.
Can emulate EM4100, HID, Indala, AWID via block 0 config.
Optional password protection.
"""
def __init__(self, password: int | None = None):
super().__init__(modulation=Modulation.ASK)
self._blocks = [0] * 8
self._password = password
self._pwd_mode = password is not None
@property
def blocks(self) -> list[int]:
return self._blocks
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
if not self._powered or not frame.data:
return None
cmd = frame.data[0]
if cmd == CMD_READ:
return self._handle_read(frame)
elif cmd == CMD_WRITE:
return self._handle_write(frame)
elif cmd == CMD_PWD_READ:
return self._handle_pwd_read(frame)
elif cmd == CMD_PWD_WRITE:
return self._handle_pwd_write(frame)
else:
# Default: return block 1+2 data (like EM4100 emulation)
return self._get_response()
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 2:
return None
block = frame.data[1]
if block >= 8:
return None
if self._pwd_mode:
return None # password required
return RFFrame.from_bytes(struct.pack(">I", self._blocks[block]))
def _handle_write(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 6:
return None
block = frame.data[1]
if block >= 8:
return None
if self._pwd_mode:
return None
data = struct.unpack(">I", frame.data[2:6])[0]
self._blocks[block] = data
return RFFrame.from_bytes(b"\x00") # ACK
def _handle_pwd_read(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 6:
return None
block = frame.data[1]
if block >= 8:
return None
pwd = struct.unpack(">I", frame.data[2:6])[0]
if pwd != self._password:
return None
return RFFrame.from_bytes(struct.pack(">I", self._blocks[block]))
def _handle_pwd_write(self, frame: RFFrame) -> RFFrame | None:
if len(frame.data) < 10:
return None
block = frame.data[1]
if block >= 8:
return None
pwd = struct.unpack(">I", frame.data[2:6])[0]
if pwd != self._password:
return None
data = struct.unpack(">I", frame.data[6:10])[0]
self._blocks[block] = data
return RFFrame.from_bytes(b"\x00")
def _get_response(self) -> RFFrame | None:
"""Return data blocks as continuous stream (for emulation mode)."""
# In emulation mode, T5577 outputs blocks 1+ based on block 0 config
data = b""
for i in range(1, 3): # blocks 1-2 for EM4100
data += struct.pack(">I", self._blocks[i])
return RFFrame.from_bytes(data)
@classmethod
def preset(cls, mode: str, **kwargs) -> T5577Tag:
"""Create a T5577 pre-configured for a specific emulation mode."""
tag = cls()
if mode == "em4100":
tag_id = kwargs.get("tag_id", 0)
tag._blocks[0] = CONFIG_EM4100
# Encode EM4100 data into blocks 1-2
encoded = EM4100Tag._encode(tag_id)
# Pack 64 bits into two 32-bit blocks
val1 = 0
for i in range(32):
val1 = (val1 << 1) | encoded[i]
val2 = 0
for i in range(32, 64):
val2 = (val2 << 1) | encoded[i]
tag._blocks[1] = val1
tag._blocks[2] = val2
return tag
class T5577Reader(ReaderLF):
"""T5577 reader — read/write blocks."""
async def read_block(self, block: int, password: int | None = None) -> dict:
if password is not None:
cmd = bytes([CMD_PWD_READ, block]) + struct.pack(">I", password)
else:
cmd = bytes([CMD_READ, block])
await self._medium.transmit_reader(RFFrame.from_bytes(cmd))
resp = await self._medium.receive_reader()
if resp is None or len(resp.data) < 4:
return {"data": None}
return {"data": struct.unpack(">I", resp.data[:4])[0]}
async def write_block(self, block: int, data: int,
password: int | None = None) -> dict:
if password is not None:
cmd = bytes([CMD_PWD_WRITE, block]) + struct.pack(">I", password) + struct.pack(">I", data)
else:
cmd = bytes([CMD_WRITE, block]) + struct.pack(">I", data)
await self._medium.transmit_reader(RFFrame.from_bytes(cmd))
resp = await self._medium.receive_reader()
return {"success": resp is not None}
async def read_id(self) -> dict | None:
"""Read tag in emulation mode (default response)."""
await self._medium.transmit_reader(RFFrame.from_bytes(b"\x00"))
resp = await self._medium.receive_reader()
if resp is None:
return None
return {"raw": resp.data}
def _decode_response(self, resp: RFFrame) -> dict | None:
return {"raw": resp.data}

563
pm3py/sim/table_compiler.py Normal file
View File

@@ -0,0 +1,563 @@
"""Response table compiler — walks transponder models to build firmware lookup tables."""
from __future__ import annotations
import struct
from dataclasses import dataclass, field
from .frame import RFFrame
from .replay import TraceEntry
# Match modes
MATCH_EXACT = 0
MATCH_PREFIX = 1
# Entry size in firmware format
ENTRY_SIZE = 120
SIM_TABLE_MAX_MATCH = 32
SIM_TABLE_MAX_RESPONSE = 64
# Response flags
RESP_FLAG_CRC = 0x01
# Entry flags
ENTRY_FLAG_CONSUME = 0x02
# EML actions
EML_NONE = 0
EML_READ = 1
EML_WRITE = 2
EML_AES_CMAC = 3
@dataclass
class TableEntry:
"""One entry in the firmware response table."""
match: bytes
match_mode: int = MATCH_EXACT # 0=exact, 1=prefix
response: bytes = b""
response_flags: int = 0 # bit 0: append CRC
eml_action: int = 0
eml_offset: int = 0
eml_len: int = 0
eml_resp_insert: int = 0
cmd_data_offset: int = 0
cmd_data_len: int = 0
group: int = 0
activate_groups: int = 0
deactivate_groups: int = 0
set_auth: int = 0
clear_auth: int = 0
flags: int = 0 # bit 1: consume after use
def serialize(self) -> bytes:
"""Pack into 120-byte firmware format."""
buf = bytearray(ENTRY_SIZE)
# match[32] + match_len[1] + match_mode[1] = 34 bytes
m = self.match[:SIM_TABLE_MAX_MATCH]
buf[0:len(m)] = m
buf[32] = len(m)
buf[33] = self.match_mode
# response[64] + response_len[1] + response_flags[1] = 66 bytes
r = self.response[:SIM_TABLE_MAX_RESPONSE]
buf[34:34 + len(r)] = r
buf[98] = len(r)
buf[99] = self.response_flags
# eml_action[1] + eml_offset[2 LE] + eml_len[1] + eml_resp_insert[1] + cmd_data_offset[1] + cmd_data_len[1] = 7 bytes
buf[100] = self.eml_action
struct.pack_into('<H', buf, 101, self.eml_offset)
buf[103] = self.eml_len
buf[104] = self.eml_resp_insert
buf[105] = self.cmd_data_offset
buf[106] = self.cmd_data_len
# group[1] + activate_groups[4 LE] + deactivate_groups[4 LE] + set_auth[1] + clear_auth[1] = 11 bytes
buf[107] = self.group
struct.pack_into('<I', buf, 108, self.activate_groups)
struct.pack_into('<I', buf, 112, self.deactivate_groups)
buf[116] = self.set_auth
buf[117] = self.clear_auth
# flags[1] + _pad[1] = 2 bytes
buf[118] = self.flags
# buf[119] = 0 # pad, already zero
return bytes(buf)
@classmethod
def deserialize(cls, data: bytes) -> TableEntry:
"""Unpack from 120-byte firmware format."""
match_len = data[32]
match = bytes(data[0:match_len])
match_mode = data[33]
resp_len = data[98]
response = bytes(data[34:34 + resp_len])
response_flags = data[99]
eml_action = data[100]
eml_offset = struct.unpack_from('<H', data, 101)[0]
eml_len = data[103]
eml_resp_insert = data[104]
cmd_data_offset = data[105]
cmd_data_len = data[106]
group = data[107]
activate_groups = struct.unpack_from('<I', data, 108)[0]
deactivate_groups = struct.unpack_from('<I', data, 112)[0]
set_auth = data[116]
clear_auth = data[117]
flags = data[118]
return cls(
match=match, match_mode=match_mode,
response=response, response_flags=response_flags,
eml_action=eml_action, eml_offset=eml_offset,
eml_len=eml_len, eml_resp_insert=eml_resp_insert,
cmd_data_offset=cmd_data_offset, cmd_data_len=cmd_data_len,
group=group, activate_groups=activate_groups,
deactivate_groups=deactivate_groups,
set_auth=set_auth, clear_auth=clear_auth, flags=flags,
)
def matches(self, cmd: bytes) -> bool:
"""Check if a command matches this entry."""
if self.match_mode == MATCH_EXACT:
return cmd == self.match
elif self.match_mode == MATCH_PREFIX:
return cmd[:len(self.match)] == self.match
return False
@dataclass
class ResponseTable:
"""Collection of response table entries."""
entries: list[TableEntry] = field(default_factory=list)
def serialize(self) -> bytes:
"""Concatenate all entries for CMD_SIM_TABLE_UPLOAD."""
return b"".join(e.serialize() for e in self.entries)
def lookup(self, cmd: bytes) -> TableEntry | None:
"""Find first matching entry for a command."""
for entry in self.entries:
if entry.matches(cmd):
return entry
return None
def overlay(self, other: ResponseTable) -> None:
"""Merge another table — other's entries win on match conflict."""
for new_entry in other.entries:
replaced = False
for i, existing in enumerate(self.entries):
if existing.match == new_entry.match and existing.match_mode == new_entry.match_mode:
self.entries[i] = new_entry
replaced = True
break
if not replaced:
self.entries.append(new_entry)
@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=MATCH_EXACT,
response=resp.frame.data,
))
return cls(entries=entries)
def _compute_bcc(data: bytes) -> int:
r = 0
for b in data:
r ^= b
return r
class TableCompiler:
"""Compile transponder models into firmware response tables."""
@staticmethod
def compile_14a(tag) -> ResponseTable:
"""Walk 14443-A tag state machine, enumerate cmd→resp pairs."""
entries = []
# REQA → ATQA
entries.append(TableEntry(match=b"\x26", response=tag.atqa))
# WUPA → ATQA
entries.append(TableEntry(match=b"\x52", response=tag.atqa))
# Anticollision + SELECT for each cascade level
cascade_entries = tag._cascade_entries()
for i, (sel, uid_chunk) in enumerate(cascade_entries):
bcc = _compute_bcc(uid_chunk)
is_last = (i == len(cascade_entries) - 1)
# ANTICOL → UID + BCC (prefix match on SEL + NVB=0x20)
entries.append(TableEntry(
match=bytes([sel, 0x20]),
match_mode=MATCH_PREFIX,
response=uid_chunk + bytes([bcc]),
))
# SELECT → SAK (prefix match on SEL + NVB=0x70)
if is_last:
sak_byte = tag.sak
else:
sak_byte = 0x04 # cascade not complete
entries.append(TableEntry(
match=bytes([sel, 0x70]),
match_mode=MATCH_PREFIX,
response=bytes([sak_byte]),
response_flags=0x01, # append CRC
))
# RATS → ATS (if Part 4)
if hasattr(tag, '_ats') and tag._ats:
entries.append(TableEntry(
match=b"\xE0",
match_mode=MATCH_PREFIX,
response=tag._ats,
))
# Application-specific responses
if hasattr(tag, 'enumerate_responses'):
for cmd, resp, flags in tag.enumerate_responses():
entries.append(TableEntry(match=cmd, response=resp, response_flags=flags))
return ResponseTable(entries=entries)
@staticmethod
def compile_15693(tag) -> ResponseTable:
"""Compile ISO 15693 tag responses."""
entries = []
uid_lsb = tag._uid[::-1]
# Inventory (unaddressed, 1-slot)
inv_resp = bytes([0x00, tag._dsfid]) + uid_lsb
entries.append(TableEntry(
match=bytes([0x26, 0x01]),
match_mode=MATCH_PREFIX,
response=inv_resp,
response_flags=0x01, # append CRC
))
# Inventory (unaddressed, 16-slot)
entries.append(TableEntry(
match=bytes([0x06, 0x01]),
match_mode=MATCH_PREFIX,
response=inv_resp,
response_flags=0x01,
))
# Get System Info (unaddressed)
sysinfo = bytes([0x00, 0x0F]) + uid_lsb
sysinfo += bytes([tag._dsfid, tag._afi])
sysinfo += bytes([tag._num_blocks - 1, tag._block_size - 1])
sysinfo += bytes([tag._ic_reference])
entries.append(TableEntry(
match=bytes([0x02, 0x2B]),
match_mode=MATCH_EXACT,
response=sysinfo,
response_flags=0x01,
))
# Read Single Block (unaddressed) for each block
for block in range(min(tag._num_blocks, 32)): # limit to 32 for table size
offset = block * tag._block_size
block_data = bytes(tag._memory[offset:offset + tag._block_size])
entries.append(TableEntry(
match=bytes([0x02, 0x20, block]),
match_mode=MATCH_EXACT,
response=bytes([0x00]) + block_data,
response_flags=0x01,
))
return ResponseTable(entries=entries)
@staticmethod
def compile_mifare(tag) -> ResponseTable:
"""Compile MIFARE Classic with pre-computed auth sequences."""
# Start with standard 14443-A anticollision
table = TableCompiler.compile_14a(tag)
# Pre-compute auth for each sector with key A
for sector in range(tag._num_sectors):
key = tag._keys_a[sector]
# AUTH_A command for blocks in this sector
if sector < 32:
first_block = sector * 4
else:
first_block = 128 + (sector - 32) * 16
# Pre-pick a tag nonce
from .crypto1 import Crypto1
crypto = Crypto1(key)
nt = crypto.generate_nonce()
# Auth nonce response (stateful — consumed after use since
# the reader's nr is unpredictable, so the follow-up
# encrypted exchange goes through WTX/retry relay)
entries = table.entries
entries.append(TableEntry(
match=bytes([0x60, first_block]),
match_mode=MATCH_EXACT,
response=nt,
flags=0x02, # stateful: consume after use
))
return table
@staticmethod
def compile_desfire(tag) -> ResponseTable:
"""Compile DESFire static APDUs. Auth goes through WTX relay."""
table = TableCompiler.compile_14a(tag)
return table
@staticmethod
def compile_nxp_icode(tag) -> ResponseTable:
"""Compile NXP ICODE base entries: GET_RANDOM, GET_NXP_SYSTEM_INFO, READ_SIGNATURE."""
import os
entries = []
# GET_RANDOM (0xB2) — firmware handles natively for addressed,
# but table entry needed for unaddressed custom path
random_bytes = os.urandom(2)
entries.append(TableEntry(
match=bytes([0x02, 0xB2]),
match_mode=MATCH_PREFIX,
response=bytes([0x00]) + random_bytes,
))
# GET NXP SYSTEM INFO (0xAB) — static response
# Match without mfg code: firmware normalization consumes mfg byte
# when processing addressed commands (UID+mfg are stripped together).
# PREFIX match on [flags, cmd] works for both addressed and unaddressed.
pp = getattr(tag, '_protection_pointer', None) or 0
pp_cond = 0x01 if getattr(tag, '_protection_pointer', None) is not None else 0x00
nxp_sysinfo_resp = bytes([0x00, pp, pp_cond, 0x00, 0x00, 0x00, 0x00, 0x00])
entries.append(TableEntry(
match=bytes([0x02, 0xAB]),
match_mode=MATCH_PREFIX,
response=nxp_sysinfo_resp,
))
# READ SIGNATURE (0xBD) — static 32-byte ECC signature
signature = getattr(tag, '_signature', bytes(32))
entries.append(TableEntry(
match=bytes([0x02, 0xBD]),
match_mode=MATCH_PREFIX,
response=bytes([0x00]) + signature,
))
return ResponseTable(entries=entries)
@staticmethod
def compile_slix2(tag) -> ResponseTable:
"""Compile SLIX2 with passwords, privacy, and ENABLE_PRIVACY on top of NXP base.
Groups:
0 = always active
1 = normal operation (active unless privacy mode)
"""
# Start with NXP base entries
table = TableCompiler.compile_nxp_icode(tag)
# Password auth bits per SLIX2 spec
PWD_AUTH_BITS = {0x01: 0x01, 0x02: 0x02, 0x04: 0x04, 0x08: 0x08, 0x10: 0x10, 0x20: 0x20}
# Get random bytes from GET_RANDOM entry for XOR
get_rnd = [e for e in table.entries if len(e.match) >= 2 and e.match[1] == 0xB2]
random_bytes = get_rnd[0].response[1:3] if get_rnd else b"\x00\x00"
def _xor_password(password: int) -> bytes:
pwd_bytes = struct.pack("<I", password)
return bytes([
pwd_bytes[0] ^ random_bytes[0],
pwd_bytes[1] ^ random_bytes[1],
pwd_bytes[2] ^ random_bytes[0],
pwd_bytes[3] ^ random_bytes[1],
])
for pwd_id, password in tag._passwords.items():
if password is None:
continue
auth_bit = PWD_AUTH_BITS.get(pwd_id, 0)
xored = _xor_password(password)
entry_kwargs = dict(
match=bytes([0x02, 0xB3, pwd_id]) + xored,
match_mode=MATCH_EXACT,
response=bytes([0x00]),
set_auth=auth_bit,
)
# Privacy password via SET_PASSWORD disables privacy mode
if pwd_id == 0x04: # PWD_PRIVACY
entry_kwargs['deactivate_groups'] = (1 << 1)
table.entries.append(TableEntry(**entry_kwargs))
# SET_PASSWORD fallback (wrong password → error)
table.entries.append(TableEntry(
match=bytes([0x02, 0xB3]),
match_mode=MATCH_PREFIX,
response=bytes([0x01, 0x0F]),
))
# EAS ALARM (0xA5) — return EAS sequence if EAS enabled, else no entry
if getattr(tag, '_eas_enabled', False):
eas_seq = getattr(tag, '_eas_sequence', bytes(32))
table.entries.append(TableEntry(
match=bytes([0x02, 0xA5]),
match_mode=MATCH_PREFIX,
response=bytes([0x00]) + eas_seq,
))
# ENABLE_PRIVACY (0xBA) — correct password activates privacy group
privacy_pwd = tag._passwords.get(0x04)
if privacy_pwd is not None:
xored = _xor_password(privacy_pwd)
# Correct password → enter privacy mode (activate group 1 to suppress inventory)
table.entries.append(TableEntry(
match=bytes([0x02, 0xBA]) + xored,
match_mode=MATCH_EXACT,
response=bytes([0x00]),
activate_groups=(1 << 1),
))
# Wrong password fallback
table.entries.append(TableEntry(
match=bytes([0x02, 0xBA]),
match_mode=MATCH_PREFIX,
response=bytes([0x01, 0x0F]),
))
return table
@staticmethod
def compile_icode3(tag) -> ResponseTable:
"""Compile ICODE 3 table: SLIX2 base + ICODE 3-specific feature flags.
ICODE 3 adds:
- Config password (pwd_id 0x20) — handled by SLIX2 password loop
- GET NXP SYSTEM INFO with ICODE 3 feature flags (overrides base)
"""
# Start with SLIX2 entries (passwords, EAS, privacy)
table = TableCompiler.compile_slix2(tag)
# Override GET NXP SYSTEM INFO with ICODE 3 feature flags
# Remove the base entry and add our own
table.entries = [e for e in table.entries if not (
len(e.match) >= 2 and e.match[1] == 0xAB
)]
pp = getattr(tag, '_protection_pointer', None) or 0
pp_cond = 0x01 if getattr(tag, '_protection_pointer', None) is not None else 0x00
# ICODE 3 feature flags (Table 113 in SL2S3003 datasheet)
feature_flags = (
(1 << 0) | # UM_PP: user memory password protection
(1 << 1) | # COUNTER
(1 << 2) | # EAS_ID
(1 << 8) | # ORIGINALITY_SIG
(1 << 10) | # P_QUIET
(1 << 12) | # PRIVACY
(1 << 13) | # DESTROY
(1 << 15) # HIGH_DATA_RATES
)
if getattr(tag, '_tag_tamper_supported', False):
feature_flags |= (1 << 9) # TAGTAMPER
nxp_sysinfo_resp = bytes([0x00, pp, pp_cond, 0x00]) + struct.pack("<I", feature_flags)
table.entries.append(TableEntry(
match=bytes([0x02, 0xAB]),
match_mode=MATCH_PREFIX,
response=nxp_sysinfo_resp,
))
return table
@staticmethod
def compile_icode_dna(tag) -> ResponseTable:
"""Compile ICODE DNA table: SLIX2 base + DNA feature flags.
DNA uses AES auth (TAM/MAM) instead of passwords for most
operations, but still supports the SLIX2 password scheme for
backward compatibility. The table entries are the same, just
GET NXP SYSTEM INFO gets DNA-specific feature flags.
"""
table = TableCompiler.compile_slix2(tag)
# Override GET NXP SYSTEM INFO with DNA feature flags
table.entries = [e for e in table.entries if not (
len(e.match) >= 2 and e.match[1] == 0xAB
)]
pp = getattr(tag, '_protection_pointer', None) or 0
pp_cond = 0x01 if getattr(tag, '_protection_pointer', None) is not None else 0x00
feature_flags = (
(1 << 0) | # UM_PP
(1 << 1) | # COUNTER
(1 << 2) | # EAS_ID
(1 << 3) | # EAS_PP
(1 << 4) | # AFI_PP
(1 << 8) | # ORIGINALITY_SIG
(1 << 10) | # P_QUIET
(1 << 12) | # PRIVACY
(1 << 13) | # DESTROY
(1 << 15) # HIGH_DATA_RATES
)
nxp_sysinfo_resp = bytes([0x00, pp, pp_cond, 0x00]) + struct.pack("<I", feature_flags)
table.entries.append(TableEntry(
match=bytes([0x02, 0xAB]),
match_mode=MATCH_PREFIX,
response=nxp_sysinfo_resp,
))
return table
@staticmethod
def compile_from_regions(tag) -> ResponseTable:
"""Generic: walk tag.regions, generate entries based on access rules.
For each block-addressed region, generates READ_BLOCK entries.
Access rules determine whether a block gets a data entry, error entry,
or no entry (falls through to relay).
"""
entries = []
authenticated = tag._authenticated if hasattr(tag, '_authenticated') else set()
for region in tag.regions.values():
if region.block_size <= 0 or not region.rf_readable:
continue
for block in range(region.num_blocks):
access = region.access_for_block(block)
offset = block * region.block_size
block_data = bytes(region.data[offset:offset + region.block_size])
# Determine if this block is readable
readable = (
access.read == "open" or
(access.read_key is not None and access.read_key in authenticated)
)
if readable:
# Generate READ → data response
resp = bytes([0x00]) + block_data
entries.append(TableEntry(
match=bytes([0x02, 0x20, block]),
match_mode=MATCH_EXACT,
response=resp,
response_flags=0x01, # append CRC
))
elif access.read == "deny":
# Generate READ → error response
entries.append(TableEntry(
match=bytes([0x02, 0x20, block]),
match_mode=MATCH_EXACT,
response=bytes([0x01, 0x0F]), # error flag + block unavailable
response_flags=0x01,
))
# else: "password"/"key" without auth → no entry → relay
return ResponseTable(entries=entries)

560
pm3py/sim/trace_fmt.py Normal file
View File

@@ -0,0 +1,560 @@
"""Trace formatter — colored, decoded, column-wrapped trace output."""
from __future__ import annotations
import os
import signal
import sys
# ---- ISO 15693 flags ----
_15693_FLAG_INVENTORY = 0x04
_15693_FLAG_ADDRESS = 0x20
# ---- ISO 15693 command names ----
_15693_CMDS = {
0x01: "INVENTORY",
0x02: "STAY QUIET",
0x20: "READ SINGLE BLOCK",
0x21: "WRITE SINGLE BLOCK",
0x23: "READ MULTIPLE BLOCK",
0x26: "RESET TO READY",
0x2B: "GET SYSTEM INFO",
0x2C: "GET MULTIPLE BLOCK SECURITY",
}
# NXP custom commands (manufacturer code 0x04, per SL2S2602 datasheet)
_15693_NXP_CMDS = {
0xA0: "NXP INVENTORY READ",
0xA1: "NXP FAST INVENTORY READ",
0xA2: "NXP SET EAS",
0xA3: "NXP RESET EAS",
0xA4: "NXP LOCK EAS",
0xA5: "NXP EAS ALARM",
0xA6: "NXP PASSWORD PROTECT EAS/AFI",
0xA7: "NXP WRITE EAS ID",
0xAB: "NXP GET SYSTEM INFO",
0xB2: "NXP GET RANDOM",
0xB3: "NXP SET PASSWORD",
0xB4: "NXP WRITE PASSWORD",
0xB5: "NXP LOCK PASSWORD",
0xB6: "NXP PROTECT PAGE",
0xB7: "NXP LOCK PAGE PROTECTION",
0xB9: "NXP DESTROY",
0xBA: "NXP ENABLE PRIVACY",
0xBB: "NXP 64-BIT PASSWORD PROTECTION",
0xBD: "NXP READ SIGNATURE",
}
def _15693_block_offset(flags: int) -> int:
"""Return the byte offset of the block number field after flags+cmd."""
is_inventory = bool(flags & _15693_FLAG_INVENTORY)
if not is_inventory and (flags & _15693_FLAG_ADDRESS):
return 10 # flags(1) + cmd(1) + uid(8)
return 2 # flags(1) + cmd(1)
def decode_15693(direction: int, payload: bytes) -> str | None:
"""Decode an ISO 15693 frame into a human-readable annotation.
Args:
direction: 0 = reader->tag, 1 = tag->reader
payload: raw frame bytes
Returns:
Annotation string or None if unrecognized.
"""
if direction == 0:
return _decode_15693_request(payload)
else:
return _decode_15693_response(payload)
def decode_15693_nxp(direction: int, payload: bytes) -> str | None:
"""Decode an NXP custom ISO 15693 command.
Args:
direction: 0 = reader->tag, 1 = tag->reader
payload: raw frame bytes
Returns:
Annotation string or None if not an NXP custom command.
"""
if direction != 0:
return None
if len(payload) < 2:
return None
cmd = payload[1]
name = _15693_NXP_CMDS.get(cmd)
if name is not None:
return _annotate_nxp_request(name, cmd, payload)
return None
def _decode_15693_request(payload: bytes) -> str | None:
if len(payload) < 2:
return None
flags = payload[0]
cmd = payload[1]
# Standard commands
name = _15693_CMDS.get(cmd)
if name is not None:
return _annotate_15693_request(name, cmd, flags, payload)
# NXP custom commands
nxp_name = _15693_NXP_CMDS.get(cmd)
if nxp_name is not None:
return _annotate_nxp_request(nxp_name, cmd, payload)
return f"UNKNOWN CMD 0x{cmd:02X}"
def _annotate_15693_request(name: str, cmd: int, flags: int, payload: bytes) -> str:
blk_off = _15693_block_offset(flags)
if cmd == 0x01: # INVENTORY
if len(payload) > 2 and payload[2] > 0:
return f"{name} mask={payload[2]}"
return name
if cmd in (0x20, 0x21): # READ/WRITE SINGLE BLOCK
if len(payload) > blk_off:
block = payload[blk_off]
if cmd == 0x21:
data_len = len(payload) - blk_off - 1
return f"{name} #{block} [{data_len}B]"
return f"{name} #{block}"
return name
if cmd in (0x23, 0x2C): # READ MULTIPLE / GET MULTIPLE BLOCK SECURITY
if len(payload) > blk_off + 1:
start = payload[blk_off]
count = payload[blk_off + 1]
return f"{name} #{start}+{count}"
return name
return name
def _annotate_nxp_request(name: str, cmd: int, payload: bytes) -> str:
if cmd == 0xB3 and len(payload) >= 4: # SET PASSWORD
return f"{name} id={payload[3]}"
if cmd == 0xB6 and len(payload) >= 4: # PROTECT PAGE
pp = payload[3]
cond = payload[4] if len(payload) >= 5 else 0
return f"{name} pp={pp} cond=0x{cond:02X}"
if cmd == 0xBA and len(payload) >= 7: # ENABLE PRIVACY
return f"{name} [XOR pwd]"
return name
_15693_ERRORS = {
0x01: "not supported",
0x02: "not recognized",
0x10: "block not available",
0x11: "block already locked",
0x12: "block locked",
0x13: "block not written",
0x14: "block not locked",
}
def _decode_15693_response(payload: bytes) -> str | None:
if len(payload) < 1:
return None
flags = payload[0]
if flags & 0x01: # Error
if len(payload) >= 2:
code = payload[1]
desc = _15693_ERRORS.get(code, "")
if desc:
return f"ERROR 0x{code:02X} {desc}"
return f"ERROR 0x{code:02X}"
return "ERROR"
# Success — try to identify the response type
data = payload[1:]
if len(data) == 0:
return "OK"
# Inventory response: dsfid(1) + uid(8) = 9 bytes
if len(data) == 9:
uid_msb = bytes(reversed(data[1:9]))
return f"OK INVENTORY UID={uid_msb.hex().upper()}"
# Try NDEF decode on response data
from pm3py.trace.ndef import decode_ndef_annotation
ndef_ann = decode_ndef_annotation(data)
if ndef_ann:
return f"OK {ndef_ann}"
# GET SYSTEM INFO response: info_flags(1) + UID(8) + DSFID(1) + AFI(1) + memsize(2) + ic_ref(1)
if len(data) >= 14 and data[0] & 0x0F == 0x0F: # all info flags set
uid_msb = bytes(reversed(data[1:9]))
ic_ref = data[13]
ic_name = _identify_nxp_ic(uid_msb, ic_ref)
blocks = data[11] + 1
blk_size = data[12] + 1
ann = f"OK SYS blocks={blocks}×{blk_size}"
if ic_name:
ann += f" [{ic_name}]"
return ann
# Generic data response
return f"OK [{len(data)}B]"
def _identify_nxp_ic(uid_msb: bytes, ic_ref: int) -> str | None:
"""Identify NXP IC from UID type indicator bits + ic_reference.
uid_msb: 8-byte UID in MSB-first order (uid[0]=E0, uid[1]=mfg, uid[2]=tag_type)
ic_ref: ic_reference byte from GET SYSTEM INFO
"""
if len(uid_msb) < 4 or uid_msb[0] != 0xE0 or uid_msb[1] != 0x04:
return None # not NXP
tag_type = uid_msb[2]
type_ind = uid_msb[3]
if tag_type == 0x02:
return "ICODE SLIX2"
if tag_type == 0x01:
# Type indicator bits 37:36 at byte[3] bits 4:3
bits_37_36 = (type_ind >> 3) & 0x03
# Extended bits 39:36 at byte[3] bits 6:3
bits_39_36 = (type_ind >> 3) & 0x0F
if bits_39_36 == 0x04: # 0100
return "ICODE 3"
if bits_37_36 == 0x03: # 11
# DNA and NTAG 5 share this — differentiate by memory size
# DNA = 64 blocks, NTAG 5 Switch = 128, Link/Boost = 512
return "ICODE DNA / NTAG 5"
if bits_37_36 == 0x02: # 10
return "ICODE SLIX"
if bits_37_36 == 0x00:
return "ICODE SLI"
return None
# ---- ISO 14443-A constants ----
_14A_CL_MAP = {0x93: "CL1", 0x95: "CL2", 0x97: "CL3"}
def decode_14443a(direction: int, payload: bytes) -> str | None:
"""Decode an ISO 14443-A frame into a human-readable annotation."""
if not payload:
return None
if direction == 0:
return _decode_14443a_request(payload)
else:
return _decode_14443a_response(payload)
def _decode_14443a_request(payload: bytes) -> str | None:
b0 = payload[0]
# Short frames (single byte)
if b0 == 0x26:
return "REQA"
if b0 == 0x52:
return "WUPA"
# HLTA
if b0 == 0x50 and len(payload) >= 2:
return "HLTA"
# Anticollision / Select
if b0 in _14A_CL_MAP and len(payload) >= 2:
cl = _14A_CL_MAP[b0]
nvb = payload[1]
if nvb == 0x20:
return f"ANTICOL {cl}"
if nvb == 0x70:
return f"SELECT {cl}"
return f"ANTICOL {cl} nvb={nvb:02X}"
# RATS
if b0 == 0xE0:
return "RATS"
# ISO-DEP I-block
if b0 & 0xE2 == 0x02:
bn = b0 & 0x01
return f"I-BLOCK({bn})"
# R-ACK
if b0 & 0xF6 == 0xA2:
bn = b0 & 0x01
return f"R-ACK({bn})"
# R-NAK
if b0 & 0xF6 == 0xB2:
bn = b0 & 0x01
return f"R-NAK({bn})"
# S(DESELECT)
if b0 == 0xC2:
return "S(DESELECT)"
# S(WTX)
if b0 == 0xF2:
return "S(WTX)"
return None
def _decode_14443a_response(payload: bytes) -> str | None:
if not payload:
return None
b0 = payload[0]
# I-block response
if b0 & 0xE2 == 0x02:
bn = b0 & 0x01
return f"I-BLOCK({bn})"
# ATQA (2 bytes)
if len(payload) == 2 and b0 & 0xF0 == 0x00:
return f"ATQA {payload[0]:02X} {payload[1]:02X}"
# SAK (1 byte)
if len(payload) == 1:
return f"SAK {b0:02X}"
# ATS (first byte = length, length >= 2)
if len(payload) >= 2 and payload[0] == len(payload):
return f"ATS [{len(payload)}]"
return None
# ---- ANSI colors ----
_C_CYAN = "\033[36m"
_C_YELLOW = "\033[33m"
_C_MAGENTA = "\033[35m"
_C_GREEN = "\033[32m"
_C_DIM = "\033[2m"
_C_CRC = "\033[1;37m" # bold white — CRC bytes
_C_RESET = "\033[0m"
_MODE_TAGS = {
"sim": ("[Sim]", _C_MAGENTA),
"reader": ("[Rdr]", _C_CYAN),
"sniff": ("[Snf]", ""),
}
class TraceFormatter:
"""Colored, decoded, column-wrapped trace output.
Args:
mode: "sim", "reader", or "sniff"
decoder: Callable[[int, bytes], str | None] for annotation, or None
width: override terminal width (None = auto-detect)
is_tty: override TTY detection (None = auto-detect)
"""
def __init__(self, mode: str = "sim", decoder=None,
width: int | None = None, is_tty: bool | None = None,
crc_len: int = 0):
self._mode = mode
self._decoder = decoder
self._crc_len = crc_len
self._width = width or self._detect_width()
self._is_tty = is_tty if is_tty is not None else sys.stdout.isatty()
self._line_count = 0
# Try to register SIGWINCH for dynamic resize
if width is None:
try:
signal.signal(signal.SIGWINCH, self._on_resize)
except (OSError, ValueError):
pass # not main thread or not Unix
def _detect_width(self) -> int:
try:
return os.get_terminal_size().columns
except (OSError, ValueError):
return 80
def _on_resize(self, signum, frame):
self._width = self._detect_width()
def _color(self, code: str, text: str) -> str:
if not self._is_tty or not code:
return text
return f"{code}{text}{_C_RESET}"
def format(self, direction: int, payload: bytes, crc_fail: bool = False) -> str:
"""Format a trace line with colors, decoding, and wrapping."""
# Re-check width periodically if no SIGWINCH
self._line_count += 1
if self._line_count % 10 == 0:
try:
self._width = self._detect_width()
except Exception:
pass
# Mode tag
mode_tag, mode_color = _MODE_TAGS.get(self._mode, ("[???]", ""))
# Direction + determine if this is "our" side
# sim mode: we are the tag (direction=1 is ours)
# reader mode: we are the reader (direction=0 is ours)
# sniff mode: neither side is ours
if direction == 0:
arrow = "Reader \u2192 Tag:"
dir_color = _C_CYAN
is_ours = (self._mode == "reader")
else:
arrow = "Tag \u2192 Reader:"
dir_color = _C_YELLOW
is_ours = (self._mode == "sim")
# Build prefix (uncolored for width calc)
prefix_plain = f"{mode_tag} {arrow} "
prefix_len = len(prefix_plain)
# Colored prefix — dim our side
if is_ours:
dir_color = _C_DIM + dir_color
prefix_colored = self._color(mode_color, mode_tag) + " " + self._color(dir_color, arrow) + " "
# Split payload into data and CRC (both directions include CRC)
if self._crc_len > 0 and len(payload) > self._crc_len:
data = payload[:-self._crc_len]
crc = payload[-self._crc_len:]
else:
data = payload
crc = b""
# Hex bytes (space-separated, uppercase)
data_hex = " ".join(f"{b:02X}" for b in data)
crc_hex = " ".join(f"{b:02X}" for b in crc)
full_hex = f"{data_hex} {crc_hex}" if crc_hex else data_hex
# Decode annotation via callable (on data only, no CRC)
annotation = None
if self._decoder is not None:
annotation = self._decoder(direction, data)
if crc_fail:
crc_note = "BAD CRC"
annotation = f"{annotation} {crc_note}" if annotation else crc_note
# Annotations use direction color (cyan=reader, yellow=tag)
ann_color = (_C_DIM + dir_color) if is_ours else dir_color
if crc_fail:
ann_color = "\033[31m" # red for CRC failures
# Layout: determine if everything fits on one line
avail = self._width - prefix_len
if annotation:
one_line = f"{full_hex} {annotation}"
else:
one_line = full_hex
if len(one_line) <= avail:
# Single line — annotation right-justified
hex_colored = self._color(_C_DIM, data_hex)
if crc_hex:
hex_colored += " " + self._color(_C_CRC, crc_hex)
if annotation:
ann_colored = self._color(ann_color, annotation)
gap = avail - len(full_hex) - len(annotation)
gap = max(gap, 2)
line = f"{prefix_colored}{hex_colored}{' ' * gap}{ann_colored}"
else:
line = f"{prefix_colored}{hex_colored}"
return f"\n{line}"
# Multi-line: wrap hex, annotation right-justified on its own line
pad = " " * prefix_len
hex_lines = self._wrap_hex(full_hex, avail)
parts = []
for i, hl in enumerate(hex_lines):
colored_hl = self._color_hex_line(hl, data_hex, crc_hex)
if i == 0:
parts.append(f"{prefix_colored}{colored_hl}")
else:
parts.append(f"{pad}{colored_hl}")
if annotation:
ann_lines = self._wrap_annotation(annotation, avail)
for al in ann_lines:
ann_pad = max(avail - len(al), 0)
parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, al)}")
return "\n" + "\n".join(parts)
def _color_hex_line(self, line: str, data_hex: str, crc_hex: str) -> str:
"""Color a hex line, using dim for data bytes and dark for CRC bytes."""
if not crc_hex:
return self._color(_C_DIM, line)
# Check if this line contains CRC bytes (they appear at the end)
crc_tokens = crc_hex.split(" ")
line_tokens = line.split(" ")
# Find where CRC starts in this line by matching trailing tokens
crc_start = None
for i in range(len(line_tokens)):
if line_tokens[i:] == crc_tokens[-len(line_tokens) + i:]:
crc_start = i
break
# Check if the tail of this line matches the start/all of CRC
remaining = line_tokens[i:]
if crc_hex.startswith(" ".join(remaining)) or " ".join(remaining) == crc_hex:
crc_start = i
break
if crc_start is not None and crc_start < len(line_tokens):
data_part = " ".join(line_tokens[:crc_start])
crc_part = " ".join(line_tokens[crc_start:])
result = ""
if data_part:
result += self._color(_C_DIM, data_part) + " "
result += self._color(_C_CRC, crc_part)
return result
return self._color(_C_DIM, line)
def _wrap_annotation(self, annotation: str, avail: int) -> list[str]:
if len(annotation) <= avail:
return [annotation]
if " | " in annotation:
parts = annotation.split(" | ")
lines = []
current = parts[0]
for part in parts[1:]:
candidate = f"{current} | {part}"
if len(candidate) <= avail:
current = candidate
else:
lines.append(current)
current = part
lines.append(current)
return lines
return [annotation[:avail - 3] + "..."]
def _wrap_hex(self, hex_str: str, avail: int) -> list[str]:
"""Wrap space-separated hex string into lines of at most `avail` chars."""
tokens = hex_str.split(" ")
lines: list[str] = []
current = ""
for tok in tokens:
candidate = f"{current} {tok}" if current else tok
if len(candidate) <= avail:
current = candidate
else:
if current:
lines.append(current)
current = tok
if current:
lines.append(current)
return lines or [""]
def print(self, direction: int, payload: bytes, crc_fail: bool = False) -> None:
"""Format and print a trace line to stdout."""
sys.stdout.write(self.format(direction, payload, crc_fail=crc_fail))
sys.stdout.flush()

130
pm3py/sim/transponder.py Normal file
View File

@@ -0,0 +1,130 @@
"""Transponder ABC — base class for simulated RF transponders."""
from __future__ import annotations
import abc
import os
import struct
import warnings
from .frame import RFFrame
from .memory import MemoryRegion
class Transponder(abc.ABC):
"""Base class for simulated transponders (tags/cards).
Subclasses declare memory regions in __init__() via self.regions.
sync() pushes dirty regions to firmware EML memory, or recompiles
the response table if access/auth state changed.
"""
_serial = None # set by SimSession.start_*() for sync() support
def __init__(self):
self.regions: dict[str, MemoryRegion] = {}
self._authenticated: set[int] = set()
self._access_dirty: bool = False
self._uid_dirty: bool = False
@abc.abstractmethod
async def power_on(self) -> None:
"""Called when RF field energizes the transponder."""
@abc.abstractmethod
async def power_off(self) -> None:
"""Called when RF field is removed."""
@abc.abstractmethod
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
"""Process incoming reader frame, return response or None (stay quiet)."""
@property
@abc.abstractmethod
def state(self) -> str:
"""Current protocol state name (for debugging/inspection)."""
@staticmethod
def _parse_uid(uid: bytes | str) -> bytes:
"""Parse UID from bytes or hex string.
Accepts bytes or hex string (spaces, colons, dashes ignored):
b"\\xE0\\x04\\x01\\x02\\x03\\x04\\x05\\x06"
"E0 04 01 02 03 04 05 06"
"E0:04:01:02:03:04:05:06"
"E004010203040506"
"""
if isinstance(uid, str):
return bytes.fromhex(uid.replace(" ", "").replace(":", "").replace("-", ""))
return uid
def set_uid(self, uid: bytes | str) -> None:
"""Change the tag UID. Call sync() to push to firmware.
Accepts bytes or hex string (spaces, colons, dashes ignored).
Subclasses may override to validate UID format or recompute
checksums (e.g., 14443-A BCC).
"""
self._uid = self._parse_uid(uid)
self._uid_dirty = True
def authenticate(self, key_id: int) -> None:
"""Mark a key/password as authenticated."""
self._authenticated.add(key_id)
self._access_dirty = True
def deauthenticate(self, key_id: int | None = None) -> None:
"""Revoke authentication. None = revoke all."""
if key_id is None:
self._authenticated.clear()
else:
self._authenticated.discard(key_id)
self._access_dirty = True
def sync(self) -> None:
"""Smart sync: EML push for data changes, table recompile for access changes."""
if self._serial is None:
raise RuntimeError("Not bound to a live session")
if self._uid_dirty:
self._sync_uid()
self._uid_dirty = False
if self._access_dirty:
self._recompile_and_upload()
self._access_dirty = False
for r in self.regions.values():
r.data.clear_dirty()
else:
for r in self.regions.values():
if r.data.dirty and r.eml_offset >= 0:
self._eml_push_region(r)
r.data.clear_dirty()
def _sync_uid(self) -> None:
"""Push UID to firmware. Override in subclasses for wire format."""
pass
def _eml_push_region(self, region: MemoryRegion) -> None:
"""Push a region's data to firmware EML memory via raw serial."""
from ..core.transport import encode_ng_frame
from ..core.protocol import Cmd
offset = region.eml_offset
data = bytes(region.data)
chunk_size = 480
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
payload = struct.pack("<IH", offset + i, len(chunk)) + chunk
frame = encode_ng_frame(Cmd.HF_ISO15693_EML_SETMEM, payload)
self._serial.write(frame)
def decode_trace(self, direction: int, payload: bytes) -> str | None:
"""Decode a trace frame for display. Override in subclasses."""
return None
def _recompile_and_upload(self) -> None:
"""Recompile response table from regions and upload.
TODO: integrate with TableCompiler + firmware sim_table.
For now, fall back to EML push for all syncable regions.
"""
for r in self.regions.values():
if r.eml_offset >= 0:
self._eml_push_region(r)

150
pm3py/sim/type5.py Normal file
View File

@@ -0,0 +1,150 @@
"""NFC Forum Type 5 Tag — extends ISO 15693 with NDEF support."""
from __future__ import annotations
from .iso15693 import Tag15693
NDEF_MAGIC_T5 = 0xE1
NDEF_TLV_TYPE = 0x03
TERMINATOR_TLV = 0xFE
# NDEF record type name format flags
TNF_WELL_KNOWN = 0x01
TNF_MEDIA = 0x02
TNF_URI = 0x01 # well-known + RTD_URI
# Well-known RTD types
RTD_TEXT = b"T"
RTD_URI = b"U"
# URI identifier codes (prefix abbreviations)
URI_PREFIXES = {
0x00: "",
0x01: "http://www.",
0x02: "https://www.",
0x03: "http://",
0x04: "https://",
0x05: "tel:",
0x06: "mailto:",
}
def ndef_text(text: str, lang: str = "en") -> bytes:
"""Build an NDEF Text record (well-known RTD_TEXT).
ndef_text("Hello") → raw NDEF message bytes
"""
lang_bytes = lang.encode("ascii")
payload = bytes([len(lang_bytes)]) + lang_bytes + text.encode("utf-8")
return _ndef_record(TNF_WELL_KNOWN, RTD_TEXT, payload)
def ndef_uri(uri: str) -> bytes:
"""Build an NDEF URI record (well-known RTD_URI).
ndef_uri("https://example.com") → raw NDEF message bytes
"""
# Find longest matching prefix
code = 0x00
for c, prefix in URI_PREFIXES.items():
if prefix and uri.startswith(prefix) and len(prefix) > len(URI_PREFIXES.get(code, "")):
code = c
shortened = uri[len(URI_PREFIXES.get(code, "")):]
payload = bytes([code]) + shortened.encode("utf-8")
return _ndef_record(TNF_WELL_KNOWN, RTD_URI, payload)
def ndef_mime(mime_type: str, data: bytes) -> bytes:
"""Build an NDEF Media record.
ndef_mime("text/plain", b"hello") → raw NDEF message bytes
"""
return _ndef_record(TNF_MEDIA, mime_type.encode("ascii"), data)
def _ndef_record(tnf: int, record_type: bytes, payload: bytes,
id_bytes: bytes = b"") -> bytes:
"""Build a single NDEF record (MB+ME set = standalone message)."""
flags = 0x80 | 0x40 | tnf # MB=1, ME=1, TNF
if len(payload) < 256:
flags |= 0x10 # SR (short record)
header = bytes([flags, len(record_type)]) + bytes([len(payload)])
else:
header = bytes([flags, len(record_type)]) + len(payload).to_bytes(4, "big")
if id_bytes:
header = bytes([flags | 0x08, len(record_type)]) + header[2:] + bytes([len(id_bytes)])
return header + record_type + id_bytes + payload
class NfcType5Tag(Tag15693):
"""NFC Forum Type 5 Tag.
Block 0: Capability Container (CC)
Block 1+: NDEF data area (TLV format)
"""
def __init__(self, uid: bytes, ndef_message: bytes = b"",
dsfid: int = 0, afi: int = 0,
block_size: int = 4, num_blocks: int = 28,
**kwargs):
memory = bytearray(block_size * num_blocks)
super().__init__(uid=uid, dsfid=dsfid, afi=afi,
memory=memory, block_size=block_size,
num_blocks=num_blocks, **kwargs)
self._ndef_data_offset = self._block_size # block 1
self._ndef_capacity = (self._num_blocks - 1) * self._block_size - 3 # minus TLV overhead + terminator
self._write_cc()
# Always write NDEF TLV — real blank tags ship with empty NDEF (03 00 FE).
# Without this, NFC readers can't parse the data area.
self.set_ndef(ndef_message)
def _write_cc(self) -> None:
"""Write Capability Container to block 0.
CC byte 3 feature flags (NFC Forum T5T 1.0):
bit 0: MBREAD — tag supports READ MULTIPLE BLOCKS
bit 1: Lock block supported
bit 2: Special frame format required
Firmware sim handles READ MULTIPLE BLOCKS natively, so MBREAD=1.
"""
data_bytes = (self._num_blocks - 1) * self._block_size
cc = bytes([
NDEF_MAGIC_T5,
0x40, # version 1.0, read/write, single-byte CC length
data_bytes // 8, # size in 8-byte units
0x01, # MBREAD supported
])
self._memory[0:self._block_size] = cc[:self._block_size]
def set_ndef(self, message: bytes) -> None:
"""Write an NDEF message to the tag. Call sync() to push to firmware.
Args:
message: Raw NDEF message bytes (e.g. b"\\xD1\\x01\\x04\\x54\\x02enHi")
Raises:
ValueError: If message is too large for available space.
"""
if len(message) > self._ndef_capacity:
raise ValueError(
f"NDEF message too large: {len(message)} bytes, "
f"capacity is {self._ndef_capacity} bytes")
# Clear data area
self._memory[self._ndef_data_offset:] = bytes(
len(self._memory) - self._ndef_data_offset)
# Write TLV: type(1) + length(1 or 3) + message + terminator
offset = self._ndef_data_offset
if len(message) < 0xFF:
tlv = bytes([NDEF_TLV_TYPE, len(message)]) + message
else:
tlv = bytes([NDEF_TLV_TYPE, 0xFF,
(len(message) >> 8) & 0xFF,
len(message) & 0xFF]) + message
tlv += bytes([TERMINATOR_TLV])
self._memory[offset:offset + len(tlv)] = tlv
def clear_ndef(self) -> None:
"""Remove NDEF message. Call sync() to push."""
self._memory[self._ndef_data_offset:] = bytes(
len(self._memory) - self._ndef_data_offset)

419
tests/test_sim_14443a.py Normal file
View File

@@ -0,0 +1,419 @@
"""Tests for pm3py.sim.iso14443a — ISO 14443-A transponder and reader state machines."""
import asyncio
import pytest
from bitarray import bitarray
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import SoftwareMedium
from pm3py.sim.iso14443a import (
Tag14443A, Tag14443A_3, Tag14443A_4, Reader14443A,
State14443A, REQA, WUPA, HLTA, CL1, CL2, CL3,
)
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# Tag14443A — anticollision base (Part 2+3)
# ---------------------------------------------------------------------------
class TestTag14443AStates:
"""Test basic state transitions."""
def test_initial_state_is_idle(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04")
run(tag.power_on())
assert tag.state == "IDLE"
def test_reqa_transitions_to_ready(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04")
run(tag.power_on())
resp = run(tag.handle_frame(RFFrame.from_hex("26"))) # REQA
assert resp is not None
assert tag.state == "READY"
def test_reqa_returns_atqa(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04", atqa=b"\x44\x00")
run(tag.power_on())
resp = run(tag.handle_frame(RFFrame.from_hex("26")))
assert resp.data == b"\x44\x00"
def test_wupa_transitions_to_ready(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04")
run(tag.power_on())
resp = run(tag.handle_frame(RFFrame.from_hex("52"))) # WUPA
assert resp is not None
assert tag.state == "READY"
def test_idle_ignores_non_reqa_wupa(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04")
run(tag.power_on())
resp = run(tag.handle_frame(RFFrame.from_hex("9320")))
assert resp is None
assert tag.state == "IDLE"
class TestTag14443AAnticollision4Byte:
"""Test anticollision with 4-byte UID (single cascade level)."""
def test_anticol_cl1_returns_uid_and_bcc(self):
uid = b"\x01\x02\x03\x04"
tag = Tag14443A_3(uid=uid)
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26"))) # → READY
# ANTICOL CL1: SEL=0x93, NVB=0x20 (2 bytes known = SEL+NVB only)
resp = run(tag.handle_frame(RFFrame.from_hex("9320")))
assert resp is not None
# Response: 4 UID bytes + BCC
bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
assert resp.data == uid + bytes([bcc])
def test_select_cl1_returns_sak_and_transitions_to_active(self):
uid = b"\x01\x02\x03\x04"
bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
tag = Tag14443A_3(uid=uid, sak=0x08)
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26"))) # → READY
# SELECT CL1: SEL=0x93, NVB=0x70, UID(4), BCC
select = b"\x93\x70" + uid + bytes([bcc])
resp = run(tag.handle_frame(RFFrame.from_bytes(select)))
assert resp is not None
# SAK response: 1 byte
assert resp.data[0] == 0x08
assert tag.state == "ACTIVE"
def test_select_wrong_uid_no_response(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04")
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
wrong_uid = b"\xFF\xFF\xFF\xFF"
bcc = wrong_uid[0] ^ wrong_uid[1] ^ wrong_uid[2] ^ wrong_uid[3]
select = b"\x93\x70" + wrong_uid + bytes([bcc])
resp = run(tag.handle_frame(RFFrame.from_bytes(select)))
assert resp is None
class TestTag14443AAnticollision7Byte:
"""Test anticollision with 7-byte UID (two cascade levels)."""
def test_cl1_returns_ct_plus_first3_with_cascade_sak(self):
uid = b"\x01\x02\x03\x04\x05\x06\x07"
tag = Tag14443A_3(uid=uid, sak=0x08)
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
# ANTICOL CL1
resp = run(tag.handle_frame(RFFrame.from_hex("9320")))
assert resp is not None
# CL1: CT(0x88) + uid[0:3] + BCC
ct_uid = b"\x88" + uid[0:3]
bcc = ct_uid[0] ^ ct_uid[1] ^ ct_uid[2] ^ ct_uid[3]
assert resp.data == ct_uid + bytes([bcc])
def test_select_cl1_sak_indicates_cascade(self):
uid = b"\x01\x02\x03\x04\x05\x06\x07"
tag = Tag14443A_3(uid=uid, sak=0x08)
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
ct_uid = b"\x88" + uid[0:3]
bcc = ct_uid[0] ^ ct_uid[1] ^ ct_uid[2] ^ ct_uid[3]
select = b"\x93\x70" + ct_uid + bytes([bcc])
resp = run(tag.handle_frame(RFFrame.from_bytes(select)))
# SAK bit 2 set = cascade not complete
assert resp.data[0] & 0x04 != 0
assert tag.state == "READY" # still in anticollision, not ACTIVE
def test_cl2_returns_remaining_uid(self):
uid = b"\x01\x02\x03\x04\x05\x06\x07"
tag = Tag14443A_3(uid=uid, sak=0x08)
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
# Complete CL1
ct_uid = b"\x88" + uid[0:3]
bcc1 = ct_uid[0] ^ ct_uid[1] ^ ct_uid[2] ^ ct_uid[3]
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1]))))
# ANTICOL CL2
resp = run(tag.handle_frame(RFFrame.from_hex("9520")))
assert resp is not None
cl2_uid = uid[3:7]
bcc2 = cl2_uid[0] ^ cl2_uid[1] ^ cl2_uid[2] ^ cl2_uid[3]
assert resp.data == cl2_uid + bytes([bcc2])
def test_select_cl2_completes_with_final_sak(self):
uid = b"\x01\x02\x03\x04\x05\x06\x07"
tag = Tag14443A_3(uid=uid, sak=0x08)
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
# CL1
ct_uid = b"\x88" + uid[0:3]
bcc1 = ct_uid[0] ^ ct_uid[1] ^ ct_uid[2] ^ ct_uid[3]
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1]))))
# CL2 SELECT
cl2_uid = uid[3:7]
bcc2 = cl2_uid[0] ^ cl2_uid[1] ^ cl2_uid[2] ^ cl2_uid[3]
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2_uid + bytes([bcc2]))))
assert resp.data[0] == 0x08 # final SAK, no cascade bit
assert resp.data[0] & 0x04 == 0
assert tag.state == "ACTIVE"
class TestTag14443AAnticollision10Byte:
"""Test anticollision with 10-byte UID (three cascade levels)."""
def test_full_10byte_anticollision(self):
uid = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A"
tag = Tag14443A_3(uid=uid, sak=0x20)
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
# CL1: CT + uid[0:3]
ct1 = b"\x88" + uid[0:3]
bcc1 = ct1[0] ^ ct1[1] ^ ct1[2] ^ ct1[3]
resp = run(tag.handle_frame(RFFrame.from_hex("9320")))
assert resp.data == ct1 + bytes([bcc1])
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct1 + bytes([bcc1]))))
assert resp.data[0] & 0x04 != 0 # cascade
# CL2: CT + uid[3:6]
ct2 = b"\x88" + uid[3:6]
bcc2 = ct2[0] ^ ct2[1] ^ ct2[2] ^ ct2[3]
resp = run(tag.handle_frame(RFFrame.from_hex("9520")))
assert resp.data == ct2 + bytes([bcc2])
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + ct2 + bytes([bcc2]))))
assert resp.data[0] & 0x04 != 0 # cascade
# CL3: uid[6:10]
cl3 = uid[6:10]
bcc3 = cl3[0] ^ cl3[1] ^ cl3[2] ^ cl3[3]
resp = run(tag.handle_frame(RFFrame.from_hex("9720")))
assert resp.data == cl3 + bytes([bcc3])
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x97\x70" + cl3 + bytes([bcc3]))))
# Tag14443A_3 clears SAK bit 5, so 0x20 & ~0x20 = 0x00
assert resp.data[0] == 0x00 # final SAK (Part 3, bit 5 cleared)
assert tag.state == "ACTIVE"
class TestTag14443AHalt:
"""Test HALT behavior."""
def test_hlta_transitions_to_halt(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04")
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26"))) # → READY
# Select to ACTIVE
uid = b"\x01\x02\x03\x04"
bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
# HLTA
resp = run(tag.handle_frame(RFFrame.from_hex("5000")))
assert resp is None # no response to HLTA
assert tag.state == "HALT"
def test_halted_tag_ignores_reqa(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04")
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
uid = b"\x01\x02\x03\x04"
bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
run(tag.handle_frame(RFFrame.from_hex("5000"))) # HALT
resp = run(tag.handle_frame(RFFrame.from_hex("26"))) # REQA
assert resp is None
assert tag.state == "HALT"
def test_halted_tag_wakes_on_wupa(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04")
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
uid = b"\x01\x02\x03\x04"
bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
run(tag.handle_frame(RFFrame.from_hex("5000"))) # HALT
resp = run(tag.handle_frame(RFFrame.from_hex("52"))) # WUPA
assert resp is not None
assert tag.state == "READY"
# ---------------------------------------------------------------------------
# Tag14443A_3 — Part 3 only (rejects RATS)
# ---------------------------------------------------------------------------
class TestTag14443A_3:
"""Tag14443A_3 rejects RATS (no ISO-DEP)."""
def test_rejects_rats(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04", sak=0x08)
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
uid = b"\x01\x02\x03\x04"
bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
# RATS (0xE0, CID=0)
resp = run(tag.handle_frame(RFFrame.from_hex("E050")))
assert resp is None # Part 3 only, no RATS
def test_sak_bit5_is_zero(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04", sak=0x08)
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
uid = b"\x01\x02\x03\x04"
bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
# SAK bit 5 = 0 (no ISO-DEP)
assert resp.data[0] & 0x20 == 0
# ---------------------------------------------------------------------------
# Tag14443A_4 — Part 3 + Part 4 (ISO-DEP capable)
# ---------------------------------------------------------------------------
class TestTag14443A_4:
"""Tag14443A_4 supports RATS and I-block exchange."""
def test_sak_bit5_is_set(self):
tag = Tag14443A_4(uid=b"\x01\x02\x03\x04", sak=0x20, ats=b"\x05\x78\x80\x70\x02")
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
uid = b"\x01\x02\x03\x04"
bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
assert resp.data[0] & 0x20 != 0
def test_rats_returns_ats(self):
ats = b"\x05\x78\x80\x70\x02"
tag = Tag14443A_4(uid=b"\x01\x02\x03\x04", sak=0x20, ats=ats)
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
uid = b"\x01\x02\x03\x04"
bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
# RATS: 0xE0, FSD/CID byte
resp = run(tag.handle_frame(RFFrame.from_hex("E050")))
assert resp is not None
assert resp.data == ats
assert tag.state == "PROTOCOL"
def test_iblock_exchange(self):
"""After RATS, I-blocks carry APDUs."""
tag = Tag14443A_4(uid=b"\x01\x02\x03\x04", sak=0x20,
ats=b"\x05\x78\x80\x70\x02")
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
uid = b"\x01\x02\x03\x04"
bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
run(tag.handle_frame(RFFrame.from_hex("E050"))) # RATS → PROTOCOL
# I-block: PCB=0x02 (block 0), payload = SELECT APDU
apdu = b"\x00\xA4\x04\x00\x07\xD2\x76\x00\x00\x85\x01\x01"
iblock = b"\x02" + apdu
resp = run(tag.handle_frame(RFFrame.from_bytes(iblock)))
# Default handler should return something (at minimum an I-block response)
assert resp is not None
# Response PCB should be I-block with toggled block number
assert resp.data[0] & 0xC0 == 0x00 # I-block: bits 7-6 = 00
# ---------------------------------------------------------------------------
# Reader14443A — anticollision tree walk
# ---------------------------------------------------------------------------
class TestReader14443AInventory:
"""Test reader anticollision with single and multiple tags."""
def test_inventory_single_4byte_tag(self):
medium = SoftwareMedium()
uid = b"\x01\x02\x03\x04"
run(medium.attach(Tag14443A_3(uid=uid, sak=0x08)))
reader = Reader14443A(medium)
uids = run(reader.inventory())
assert len(uids) == 1
assert uids[0] == uid
def test_inventory_single_7byte_tag(self):
medium = SoftwareMedium()
uid = b"\x01\x02\x03\x04\x05\x06\x07"
run(medium.attach(Tag14443A_3(uid=uid, sak=0x08)))
reader = Reader14443A(medium)
uids = run(reader.inventory())
assert len(uids) == 1
assert uids[0] == uid
def test_inventory_two_tags_different_uids(self):
medium = SoftwareMedium()
uid1 = b"\x01\x02\x03\x04"
uid2 = b"\x05\x06\x07\x08"
run(medium.attach(Tag14443A_3(uid=uid1, sak=0x08)))
run(medium.attach(Tag14443A_3(uid=uid2, sak=0x08)))
reader = Reader14443A(medium)
uids = run(reader.inventory())
assert len(uids) == 2
assert set(uids) == {uid1, uid2}
def test_inventory_four_tags(self):
medium = SoftwareMedium()
tag_uids = [bytes([i, i+1, i+2, i+3]) for i in range(0, 16, 4)]
for uid in tag_uids:
run(medium.attach(Tag14443A_3(uid=uid, sak=0x08)))
reader = Reader14443A(medium)
uids = run(reader.inventory())
assert len(uids) == 4
assert set(uids) == set(tag_uids)
class TestReader14443ASelect:
"""Test reader SELECT sequence."""
def test_select_known_4byte_uid(self):
medium = SoftwareMedium()
uid = b"\x01\x02\x03\x04"
run(medium.attach(Tag14443A_3(uid=uid, sak=0x08)))
reader = Reader14443A(medium)
result = run(reader.select_tag(uid))
assert result["uid"] == uid
assert result["sak"] == 0x08
def test_select_known_7byte_uid(self):
medium = SoftwareMedium()
uid = b"\x01\x02\x03\x04\x05\x06\x07"
run(medium.attach(Tag14443A_3(uid=uid, sak=0x08)))
reader = Reader14443A(medium)
result = run(reader.select_tag(uid))
assert result["uid"] == uid
assert result["sak"] == 0x08
class TestReader14443ARATS:
"""Test reader RATS for Layer 4."""
def test_rats_returns_ats(self):
medium = SoftwareMedium()
ats = b"\x05\x78\x80\x70\x02"
uid = b"\x01\x02\x03\x04"
run(medium.attach(Tag14443A_4(uid=uid, sak=0x20, ats=ats)))
reader = Reader14443A(medium)
run(reader.select_tag(uid))
result = run(reader.rats())
assert result["ats"] == ats

285
tests/test_sim_15693.py Normal file
View File

@@ -0,0 +1,285 @@
"""Tests for pm3py.sim.iso15693 — ISO 15693 transponder and reader."""
import asyncio
import pytest
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import SoftwareMedium
from pm3py.sim.iso15693 import Tag15693, Reader15693, State15693
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# Tag15693 — basic state machine
# ---------------------------------------------------------------------------
class TestTag15693States:
def test_initial_state_is_ready(self):
tag = Tag15693(uid=bytes(8))
run(tag.power_on())
assert tag.state == "READY"
def test_stay_quiet_transitions_to_quiet(self):
tag = Tag15693(uid=b"\x01" * 8)
run(tag.power_on())
# Stay Quiet (cmd=0x02) with addressed flag and UID
frame_data = bytes([0x22, 0x02]) + tag._uid[::-1] # flags + cmd + UID (LSB first)
resp = run(tag.handle_frame(RFFrame.from_bytes(frame_data)))
assert tag.state == "QUIET"
def test_quiet_tag_ignores_inventory(self):
tag = Tag15693(uid=b"\x01" * 8)
run(tag.power_on())
# Stay Quiet
frame_data = bytes([0x22, 0x02]) + tag._uid[::-1]
run(tag.handle_frame(RFFrame.from_bytes(frame_data)))
assert tag.state == "QUIET"
# Inventory should be ignored
inv = bytes([0x26, 0x01, 0x00]) # flags=0x26, cmd=0x01, mask_len=0
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
assert resp is None
def test_reset_to_ready(self):
tag = Tag15693(uid=b"\x01" * 8)
run(tag.power_on())
# Stay Quiet
frame_data = bytes([0x22, 0x02]) + tag._uid[::-1]
run(tag.handle_frame(RFFrame.from_bytes(frame_data)))
assert tag.state == "QUIET"
# Reset to Ready (cmd=0x26)
reset = bytes([0x22, 0x26]) + tag._uid[::-1]
run(tag.handle_frame(RFFrame.from_bytes(reset)))
assert tag.state == "READY"
class TestTag15693Inventory:
def test_1slot_inventory_returns_uid(self):
uid = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x03"
tag = Tag15693(uid=uid, dsfid=0x00)
run(tag.power_on())
# Inventory: flags=0x26 (high data rate + inventory), cmd=0x01, mask_len=0
inv = bytes([0x26, 0x01, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
assert resp is not None
# Response: flags(1) + DSFID(1) + UID(8, LSB first)
assert len(resp.data) == 10
assert resp.data[0] == 0x00 # no error
assert resp.data[1] == 0x00 # DSFID
# UID is LSB first in response
assert resp.data[2:10] == uid[::-1]
def test_16slot_inventory_responds_in_correct_slot(self):
"""With 16-slot flag, tag should only respond in its hashed slot."""
uid = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x03"
tag = Tag15693(uid=uid)
run(tag.power_on())
# 16-slot inventory: flags=0x06 (no inventory flag bit 5 clear = 16 slots)
# Actually: flag bit 5 = 0 means 16 slots, bit 5 = 1 means 1 slot
inv = bytes([0x06, 0x01, 0x00]) # flags=0x06, cmd=0x01, mask_len=0
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
# Tag responds (slot assignment handled by medium, not tag directly)
assert resp is not None
class TestTag15693ReadWrite:
def test_read_single_block(self):
memory = bytearray(112) # 28 blocks * 4 bytes
memory[0:4] = b"\xDE\xAD\xBE\xEF"
tag = Tag15693(uid=bytes(8), memory=memory, block_size=4, num_blocks=28)
run(tag.power_on())
# Read Single Block (cmd=0x20), unaddressed, block 0
read_cmd = bytes([0x02, 0x20, 0x00]) # flags=0x02, cmd=0x20, block=0
resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd)))
assert resp is not None
assert resp.data[0] == 0x00 # flags: no error
assert resp.data[1:5] == b"\xDE\xAD\xBE\xEF"
def test_read_single_block_addressed(self):
uid = b"\x01\x02\x03\x04\x05\x06\x07\x08"
memory = bytearray(112)
memory[4:8] = b"\xCA\xFE\xBA\xBE"
tag = Tag15693(uid=uid, memory=memory, block_size=4, num_blocks=28)
run(tag.power_on())
# Addressed read: flags=0x22 (addressed), cmd=0x20, UID(8 LSB), block=1
read_cmd = bytes([0x22, 0x20]) + uid[::-1] + bytes([0x01])
resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd)))
assert resp is not None
assert resp.data[1:5] == b"\xCA\xFE\xBA\xBE"
def test_read_wrong_uid_no_response(self):
tag = Tag15693(uid=b"\x01" * 8)
run(tag.power_on())
wrong_uid = b"\xFF" * 8
read_cmd = bytes([0x22, 0x20]) + wrong_uid[::-1] + bytes([0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd)))
assert resp is None
def test_write_single_block(self):
tag = Tag15693(uid=bytes(8), block_size=4, num_blocks=28)
run(tag.power_on())
# Write Single Block (cmd=0x21), unaddressed, block 0, data
write_cmd = bytes([0x02, 0x21, 0x00]) + b"\xAA\xBB\xCC\xDD"
resp = run(tag.handle_frame(RFFrame.from_bytes(write_cmd)))
assert resp is not None
assert resp.data[0] == 0x00 # no error
# Verify
read_cmd = bytes([0x02, 0x20, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd)))
assert resp.data[1:5] == b"\xAA\xBB\xCC\xDD"
def test_read_out_of_range_block(self):
tag = Tag15693(uid=bytes(8), block_size=4, num_blocks=4)
run(tag.power_on())
read_cmd = bytes([0x02, 0x20, 0x10]) # block 16, out of range
resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd)))
assert resp is not None
assert resp.data[0] & 0x01 != 0 # error flag set
class TestTag15693SystemInfo:
def test_get_system_info(self):
uid = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x03"
tag = Tag15693(uid=uid, dsfid=0x42, block_size=4, num_blocks=28)
run(tag.power_on())
# Get System Information (cmd=0x2B), unaddressed
sysinfo = bytes([0x02, 0x2B])
resp = run(tag.handle_frame(RFFrame.from_bytes(sysinfo)))
assert resp is not None
assert resp.data[0] == 0x00 # no error
# Info flags(1) + UID(8) + DSFID(1) + AFI(1) + mem_size(2) + IC_ref(1)
assert len(resp.data) >= 14
def test_custom_command_extension_point(self):
"""_handle_custom_command returns error for unrecognized commands."""
tag = Tag15693(uid=bytes(8))
run(tag.power_on())
# Custom command 0xA0 (NXP range)
custom = bytes([0x02, 0xA0, 0x04]) # flags, cmd, mfg code
resp = run(tag.handle_frame(RFFrame.from_bytes(custom)))
assert resp is not None
assert resp.data[0] & 0x01 # error flag set
assert resp.data[1] == 0x01 # not supported
# ---------------------------------------------------------------------------
# Reader15693 — inventory and block operations
# ---------------------------------------------------------------------------
class TestReader15693Inventory:
def test_inventory_single_tag(self):
medium = SoftwareMedium()
uid = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x03"
run(medium.attach(Tag15693(uid=uid, dsfid=0x00)))
reader = Reader15693(medium)
tags = run(reader.inventory())
assert len(tags) == 1
assert tags[0]["uid"] == uid
def test_inventory_two_tags_collision(self):
medium = SoftwareMedium()
uid1 = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x03"
uid2 = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x04"
run(medium.attach(Tag15693(uid=uid1)))
run(medium.attach(Tag15693(uid=uid2)))
reader = Reader15693(medium)
tags = run(reader.inventory())
assert len(tags) == 2
found_uids = {t["uid"] for t in tags}
assert found_uids == {uid1, uid2}
def test_inventory_no_tags(self):
medium = SoftwareMedium()
reader = Reader15693(medium)
tags = run(reader.inventory())
assert len(tags) == 0
class TestReader15693ReadWrite:
def test_read_block(self):
medium = SoftwareMedium()
memory = bytearray(112)
memory[0:4] = b"\xDE\xAD\xBE\xEF"
uid = b"\x01" * 8
run(medium.attach(Tag15693(uid=uid, memory=memory, block_size=4, num_blocks=28)))
reader = Reader15693(medium)
result = run(reader.read_block(uid=uid, block=0))
assert result["success"]
assert result["data"] == b"\xDE\xAD\xBE\xEF"
def test_write_block(self):
medium = SoftwareMedium()
uid = b"\x01" * 8
run(medium.attach(Tag15693(uid=uid, block_size=4, num_blocks=28)))
reader = Reader15693(medium)
result = run(reader.write_block(uid=uid, block=0, data=b"\x11\x22\x33\x44"))
assert result["success"]
result = run(reader.read_block(uid=uid, block=0))
assert result["data"] == b"\x11\x22\x33\x44"
def test_system_info(self):
medium = SoftwareMedium()
uid = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x03"
run(medium.attach(Tag15693(uid=uid, dsfid=0x42, block_size=4, num_blocks=28)))
reader = Reader15693(medium)
result = run(reader.system_info(uid=uid))
assert result["uid"] == uid
assert result["dsfid"] == 0x42
assert result["num_blocks"] == 28
assert result["block_size"] == 4
class TestReader15693DynamicInjection:
"""Test ISO 15693 anti-collision tricks via dynamic attach/detach."""
def test_detach_mid_inventory(self):
medium = SoftwareMedium()
uid1 = b"\x01" * 8
uid2 = b"\x02" * 8
run(medium.attach(Tag15693(uid=uid1)))
tid2 = run(medium.attach(Tag15693(uid=uid2)))
reader = Reader15693(medium)
# Both present
tags = run(reader.inventory())
assert len(tags) == 2
# Remove one
run(medium.detach(tid2))
tags = run(reader.inventory())
assert len(tags) == 1
assert tags[0]["uid"] == uid1
def test_attach_new_tag(self):
medium = SoftwareMedium()
uid1 = b"\x01" * 8
run(medium.attach(Tag15693(uid=uid1)))
reader = Reader15693(medium)
tags = run(reader.inventory())
assert len(tags) == 1
# Add new tag
uid2 = b"\x02" * 8
run(medium.attach(Tag15693(uid=uid2)))
tags = run(reader.inventory())
assert len(tags) == 2

View File

@@ -0,0 +1,160 @@
"""Tests for access control I/O: credentials, Wiegand, OSDP."""
import asyncio
import pytest
from pm3py.sim.access_control.credential import (
Credential, encode_wiegand, decode_wiegand, from_uid,
)
from pm3py.sim.access_control.wiegand import (
WiegandOutput, WiegandInput,
)
from pm3py.sim.access_control.osdp import (
OSDPFrame, OSDPChannel, osdp_crc16,
)
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# Credential model
# ---------------------------------------------------------------------------
class TestCredential:
def test_construction(self):
c = Credential(facility_code=42, card_number=12345, format="H10301")
assert c.facility_code == 42
assert c.card_number == 12345
assert c.format == "H10301"
def test_equality(self):
c1 = Credential(42, 12345, "H10301")
c2 = Credential(42, 12345, "H10301")
assert c1 == c2
def test_from_uid_derives_credential(self):
"""Derive a credential from a tag UID."""
cred = from_uid(b"\x01\x02\x03\x04", format="H10301")
assert cred.facility_code >= 0
assert cred.card_number >= 0
assert cred.format == "H10301"
class TestWiegandEncoding:
def test_encode_26bit(self):
bits = encode_wiegand(Credential(42, 12345, "H10301"))
assert len(bits) == 26
def test_decode_26bit(self):
cred = Credential(42, 12345, "H10301")
bits = encode_wiegand(cred)
decoded = decode_wiegand(bits, "H10301")
assert decoded.facility_code == 42
assert decoded.card_number == 12345
def test_encode_37bit(self):
bits = encode_wiegand(Credential(1000, 50000, "H10304"))
assert len(bits) == 37
def test_decode_37bit_roundtrip(self):
cred = Credential(1000, 50000, "H10304")
bits = encode_wiegand(cred)
decoded = decode_wiegand(bits, "H10304")
assert decoded == cred
def test_26bit_parity(self):
bits = encode_wiegand(Credential(42, 12345, "H10301"))
assert sum(bits[:13]) % 2 == 0 # even parity first half
assert sum(bits[13:]) % 2 == 1 # odd parity second half
def test_encode_35bit_corp1000(self):
bits = encode_wiegand(Credential(100, 5000, "C1000_35"))
assert len(bits) == 35
# ---------------------------------------------------------------------------
# Wiegand I/O (software mode)
# ---------------------------------------------------------------------------
class TestWiegandOutput:
def test_software_mode_encode(self):
"""WiegandOutput in software mode stores bit stream."""
out = WiegandOutput()
cred = Credential(42, 12345, "H10301")
out.send(cred)
assert len(out.last_bits) == 26
def test_software_mode_timing(self):
"""Verify pulse timing parameters exist."""
out = WiegandOutput(pulse_width_us=50, interval_us=2000)
assert out.pulse_width_us == 50
assert out.interval_us == 2000
class TestWiegandInput:
def test_software_mode_decode(self):
"""WiegandInput in software mode decodes bit stream."""
inp = WiegandInput()
cred = Credential(42, 12345, "H10301")
bits = encode_wiegand(cred)
decoded = inp.decode(bits, "H10301")
assert decoded.facility_code == 42
assert decoded.card_number == 12345
# ---------------------------------------------------------------------------
# OSDP protocol
# ---------------------------------------------------------------------------
class TestOSDPFrame:
def test_frame_construction(self):
frame = OSDPFrame(address=0, command=0x60, data=b"")
raw = frame.encode()
assert raw[0] == 0x53 # SOM
assert len(raw) >= 7 # SOM + ADDR + LEN(2) + CMD + CRC(2)
def test_frame_parse_roundtrip(self):
frame = OSDPFrame(address=1, command=0x61, data=b"\x01\x02")
raw = frame.encode()
parsed = OSDPFrame.decode(raw)
assert parsed.address == 1
assert parsed.command == 0x61
assert parsed.data == b"\x01\x02"
def test_crc16(self):
"""OSDP uses CRC-16/AUG-CCITT."""
crc = osdp_crc16(b"\x53\x00\x08\x00\x60")
assert isinstance(crc, int)
assert 0 <= crc <= 0xFFFF
class TestOSDPChannel:
def test_software_loopback(self):
"""Channel in software mode: controller polls, reader replies."""
channel = OSDPChannel() # software loopback
# Controller sends POLL
poll = OSDPFrame(address=0, command=0x60, data=b"")
channel.send(poll)
# Reader responds with ACK
ack = OSDPFrame(address=0, command=0x40, data=b"")
channel.send(ack)
# Both frames in buffer
assert len(channel.buffer) == 2
def test_card_present_notification(self):
"""Simulate card present event via OSDP."""
channel = OSDPChannel()
cred = Credential(42, 12345, "H10301")
bits = encode_wiegand(cred)
# Reader sends RAW (card data) — cmd 0x73
raw_data = bytes(bits) # simplified
frame = OSDPFrame(address=0, command=0x73, data=raw_data)
channel.send(frame)
last = channel.buffer[-1]
assert last.command == 0x73

189
tests/test_sim_advanced.py Normal file
View File

@@ -0,0 +1,189 @@
"""Tests for advanced applications: fuzzer, relay, replay."""
import asyncio
import pytest
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import SoftwareMedium
from pm3py.sim.iso14443a import Tag14443A_3, Reader14443A
from pm3py.sim.fuzzer import MutationFuzzer, GrammarFuzzer
from pm3py.sim.relay import RelayTransponder, MitMProxy
from pm3py.sim.replay import TraceRecorder, TraceReplayer, TraceEntry
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# MutationFuzzer
# ---------------------------------------------------------------------------
class TestMutationFuzzer:
def test_mutate_produces_different_frame(self):
seed = RFFrame.from_hex("26")
fuzzer = MutationFuzzer([seed], seed=42)
mutated = fuzzer.mutate(seed)
# At least sometimes should differ (with enough attempts)
found_different = False
for _ in range(20):
m = fuzzer.mutate(seed)
if m.data != seed.data or m.bit_count != seed.bit_count:
found_different = True
break
assert found_different
def test_generate_yields_frames(self):
seeds = [RFFrame.from_hex("26"), RFFrame.from_hex("9320")]
fuzzer = MutationFuzzer(seeds, seed=42)
frames = list(fuzzer.generate(count=10))
assert len(frames) == 10
assert all(isinstance(f, RFFrame) for f in frames)
def test_deterministic_with_seed(self):
seeds = [RFFrame.from_hex("A5B6")]
f1 = list(MutationFuzzer(seeds, seed=123).generate(5))
f2 = list(MutationFuzzer(seeds, seed=123).generate(5))
assert [f.data for f in f1] == [f.data for f in f2]
def test_strategies_include_flip_truncate_extend(self):
fuzzer = MutationFuzzer([RFFrame.from_hex("DEADBEEF")], seed=0)
strategies_seen = set()
for _ in range(100):
frame = RFFrame.from_hex("DEADBEEF")
m = fuzzer.mutate(frame)
if m.bit_count < 32:
strategies_seen.add("truncate")
elif m.bit_count > 32:
strategies_seen.add("extend")
elif m.data != b"\xDE\xAD\xBE\xEF":
strategies_seen.add("flip")
assert len(strategies_seen) >= 2 # at least 2 strategies observed
# ---------------------------------------------------------------------------
# GrammarFuzzer
# ---------------------------------------------------------------------------
class TestGrammarFuzzer:
def test_generate_14443a_reqa(self):
fuzzer = GrammarFuzzer("14443a")
frame = fuzzer.generate("REQA")
assert frame is not None
assert frame.bit_count > 0
def test_generate_with_overrides(self):
fuzzer = GrammarFuzzer("14443a")
frame = fuzzer.generate("SELECT", uid=b"\xFF\xFF\xFF\xFF")
assert frame is not None
def test_unknown_command_returns_none(self):
fuzzer = GrammarFuzzer("14443a")
frame = fuzzer.generate("NONEXISTENT")
assert frame is None
# ---------------------------------------------------------------------------
# RelayTransponder
# ---------------------------------------------------------------------------
class TestRelayTransponder:
def test_relay_forwards_frames(self):
"""RelayTransponder forwards reader commands to upstream reader."""
# Set up "real card" on upstream medium
upstream_medium = SoftwareMedium()
uid = b"\x01\x02\x03\x04"
run(upstream_medium.attach(Tag14443A_3(uid=uid, sak=0x08)))
upstream_reader = Reader14443A(upstream_medium)
# Set up relay on local medium
relay = RelayTransponder(upstream_reader)
local_medium = SoftwareMedium()
run(local_medium.attach(relay))
# Send REQA through local medium
run(local_medium.transmit_reader(RFFrame.from_hex("26")))
resp = run(local_medium.receive_reader())
# Should get ATQA back from the real card
assert resp is not None
assert len(resp.data) == 2 # ATQA
# ---------------------------------------------------------------------------
# MitMProxy
# ---------------------------------------------------------------------------
class TestMitMProxy:
def test_proxy_logs_traffic(self):
reader_medium = SoftwareMedium()
tag_medium = SoftwareMedium()
run(tag_medium.attach(Tag14443A_3(uid=b"\x01\x02\x03\x04")))
proxy = MitMProxy(reader_medium, tag_medium)
# Simulate reader → proxy → tag
run(proxy.forward_to_tag(RFFrame.from_hex("26")))
assert len(proxy.trace) == 2 # request + response
def test_proxy_with_intercept(self):
reader_medium = SoftwareMedium()
tag_medium = SoftwareMedium()
run(tag_medium.attach(Tag14443A_3(uid=b"\x01\x02\x03\x04")))
modified = []
def intercept(frame, direction):
modified.append(direction)
return frame # pass through
proxy = MitMProxy(reader_medium, tag_medium, intercept=intercept)
run(proxy.forward_to_tag(RFFrame.from_hex("26")))
assert "reader" in modified
assert "tag" in modified
# ---------------------------------------------------------------------------
# TraceRecorder / TraceReplayer
# ---------------------------------------------------------------------------
class TestTraceRecorder:
def test_records_traffic(self):
medium = SoftwareMedium()
run(medium.attach(Tag14443A_3(uid=b"\x01\x02\x03\x04")))
recorder = TraceRecorder(medium)
# Exchange through recorder
run(recorder.transmit_reader(RFFrame.from_hex("26")))
run(recorder.receive_reader())
assert len(recorder.trace) >= 1
def test_trace_entries_have_direction(self):
medium = SoftwareMedium()
run(medium.attach(Tag14443A_3(uid=b"\x01\x02\x03\x04")))
recorder = TraceRecorder(medium)
run(recorder.transmit_reader(RFFrame.from_hex("26")))
resp = run(recorder.receive_reader())
directions = [e.direction for e in recorder.trace]
assert "reader" in directions
if resp is not None:
assert "tag" in directions
class TestTraceReplayer:
def test_replay_against_tag(self):
"""Record a trace, then replay it against a fresh tag."""
# Record
medium1 = SoftwareMedium()
run(medium1.attach(Tag14443A_3(uid=b"\x01\x02\x03\x04")))
recorder = TraceRecorder(medium1)
run(recorder.transmit_reader(RFFrame.from_hex("26")))
run(recorder.receive_reader())
# Replay against fresh tag
medium2 = SoftwareMedium()
run(medium2.attach(Tag14443A_3(uid=b"\x01\x02\x03\x04")))
replayer = TraceReplayer(recorder.trace, medium2)
results = run(replayer.replay())
assert len(results) > 0
assert all(r["matched"] for r in results)

420
tests/test_sim_aes_auth.py Normal file
View File

@@ -0,0 +1,420 @@
"""Tests for AES TAM1/MAM authentication in NxpAesAuth mixin."""
import asyncio
import os
import pytest
from Crypto.Cipher import AES
from pm3py.sim.frame import RFFrame
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
class TestTam1:
def test_challenge_readbuffer(self):
"""Full TAM1 flow: CHALLENGE computes TResponse, READBUFFER returns it."""
from pm3py.sim.icode_dna import IcodeDnaTag
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
key = b"\xAA" * 16
tag = IcodeDnaTag(aes_keys=[key, None, None, None])
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
run(tag.power_on())
ichallenge = bytes(range(10))
# CHALLENGE: flags(02) cmd(39) CSI(00) AuthMethod(00) KeyID(00) IChallenge(10)
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + ichallenge
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None # CHALLENGE has no RF response
# READBUFFER: flags(02) cmd(3A)
cmd = bytes([0x02, 0x3A])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert len(resp.data) >= 17 # flags + 16 bytes TResponse
tresponse_wire = resp.data[1:17]
tresponse = tresponse_wire[::-1] # reverse for decryption
cipher = AES.new(key, AES.MODE_ECB)
decrypted = cipher.decrypt(tresponse)
# Verify C_TAM1 constant
assert decrypted[0:2] == bytes([0x96, 0xC5])
# Verify echoed IChallenge (bytes 6-15, reversed back)
echoed = decrypted[6:16]
assert echoed == ichallenge
def test_challenge_key1(self):
"""TAM1 with key slot 1."""
from pm3py.sim.icode_dna import IcodeDnaTag
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
key = b"\xCC" * 16
tag = IcodeDnaTag(aes_keys=[None, key, None, None])
tag._key_headers[1] = KEY_HEADER_ACTIVE_LOCKED
run(tag.power_on())
ichallenge = b"\xFF" * 10
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x01]) + ichallenge
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None
cmd = bytes([0x02, 0x3A])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
tresponse = resp.data[1:17][::-1]
cipher = AES.new(key, AES.MODE_ECB)
decrypted = cipher.decrypt(tresponse)
assert decrypted[0:2] == bytes([0x96, 0xC5])
assert decrypted[6:16] == ichallenge
def test_challenge_inactive_key_silent(self):
"""CHALLENGE with inactive key: tag stays silent, READBUFFER returns nothing."""
from pm3py.sim.icode_dna import IcodeDnaTag
tag = IcodeDnaTag(aes_keys=[b"\xBB" * 16, None, None, None])
# Key header NOT active (default 0x81)
run(tag.power_on())
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + bytes(10)
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None
# READBUFFER should have nothing
cmd = bytes([0x02, 0x3A])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None
def test_readbuffer_without_challenge(self):
"""READBUFFER without prior CHALLENGE returns nothing."""
from pm3py.sim.icode_dna import IcodeDnaTag
tag = IcodeDnaTag()
run(tag.power_on())
cmd = bytes([0x02, 0x3A])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None
def test_readbuffer_one_shot(self):
"""TResponse is cleared after first READBUFFER (one-shot)."""
from pm3py.sim.icode_dna import IcodeDnaTag
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
key = b"\xDD" * 16
tag = IcodeDnaTag(aes_keys=[key, None, None, None])
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
run(tag.power_on())
# CHALLENGE
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + bytes(10)
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
# First READBUFFER — should succeed
cmd = bytes([0x02, 0x3A])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
# Second READBUFFER — should return nothing (consumed)
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None
def test_challenge_no_key_in_slot(self):
"""CHALLENGE with None key slot stays silent."""
from pm3py.sim.icode_dna import IcodeDnaTag
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
tag = IcodeDnaTag(aes_keys=[None, None, None, None])
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
run(tag.power_on())
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + bytes(10)
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None
cmd = bytes([0x02, 0x3A])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None
def test_challenge_invalid_key_id(self):
"""CHALLENGE with key_id > 3 stays silent."""
from pm3py.sim.icode_dna import IcodeDnaTag
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
tag = IcodeDnaTag(aes_keys=[b"\xAA" * 16, None, None, None])
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
run(tag.power_on())
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x04]) + bytes(10)
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None
def test_challenge_wrong_auth_method(self):
"""CHALLENGE with AuthMethod != 0x00 (not TAM1) stays silent."""
from pm3py.sim.icode_dna import IcodeDnaTag
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
tag = IcodeDnaTag(aes_keys=[b"\xAA" * 16, None, None, None])
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
run(tag.power_on())
# AuthMethod = 0x02 (MAM1, not TAM1)
cmd = bytes([0x02, 0x39, 0x00, 0x02, 0x00]) + bytes(10)
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None
rb_cmd = bytes([0x02, 0x3A])
resp = run(tag.handle_frame(RFFrame.from_bytes(rb_cmd)))
assert resp is None
class TestMam:
"""Tests for MAM1/MAM2 mutual authentication via AUTHENTICATE (0x35)."""
def _make_tag(self, key=b"\xAA" * 16, key_slot=0):
from pm3py.sim.icode_dna import IcodeDnaTag
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
keys = [None] * 4
keys[key_slot] = key
tag = IcodeDnaTag(aes_keys=keys)
tag._key_headers[key_slot] = KEY_HEADER_ACTIVE_LOCKED
run(tag.power_on())
return tag
def _do_mam1(self, tag, key, ichallenge, key_id=0):
"""Send MAM1 and return (resp, tchallenge)."""
cmd = bytes([0x02, 0x35, 0x00, 0x02, key_id]) + ichallenge[::-1]
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert len(resp.data) == 24
assert resp.data[0] == 0x04
assert resp.data[1] == 0xA7
tc_high_reversed = resp.data[2:8]
encrypted_reversed = resp.data[8:24]
cipher = AES.new(key, AES.MODE_ECB)
decrypted = cipher.decrypt(encrypted_reversed[::-1])
assert decrypted[0:2] == bytes([0xDA, 0x83])
tc_low = decrypted[2:6]
echoed_ichallenge = decrypted[6:16]
assert echoed_ichallenge == ichallenge
tc_high = tc_high_reversed[::-1]
tchallenge = tc_high + tc_low
return resp, tchallenge
def _compute_iresponse(self, key, ichallenge, tchallenge):
"""Compute IResponse for MAM2."""
C_MAM2_PURPOSE = bytes([0xDA, 0x80])
ich_31_0 = ichallenge[6:10]
plaintext = C_MAM2_PURPOSE + ich_31_0 + tchallenge
cipher = AES.new(key, AES.MODE_ECB)
iresponse = cipher.decrypt(plaintext)
return iresponse[::-1]
def test_mam_complete_flow(self):
"""Complete MAM1 -> MAM2 flow results in authenticated key."""
key = b"\xAA" * 16
tag = self._make_tag(key)
ichallenge = os.urandom(10)
resp, tchallenge = self._do_mam1(tag, key, ichallenge)
iresponse_wire = self._compute_iresponse(key, ichallenge, tchallenge)
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + iresponse_wire
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
assert 0 in tag._aes_authenticated
def test_mam1_response_structure(self):
"""MAM1 response has correct flags, header, and 24-byte length."""
key = b"\xBB" * 16
tag = self._make_tag(key)
ichallenge = bytes(range(10))
cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x00]) + ichallenge[::-1]
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert len(resp.data) == 24
assert resp.data[0] == 0x04
assert resp.data[1] == 0xA7
def test_mam2_wrong_iresponse(self):
"""MAM2 with incorrect IResponse returns error."""
key = b"\xCC" * 16
tag = self._make_tag(key)
ichallenge = os.urandom(10)
self._do_mam1(tag, key, ichallenge)
bad_iresponse = os.urandom(16)
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + bad_iresponse
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x01
assert 0 not in tag._aes_authenticated
def test_mam2_without_prior_mam1(self):
"""MAM2 without prior MAM1 returns error."""
key = b"\xDD" * 16
tag = self._make_tag(key)
iresponse = os.urandom(16)
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + iresponse
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x01
assert 0 not in tag._aes_authenticated
def test_mam_key1(self):
"""MAM flow with key slot 1."""
key = b"\xEE" * 16
tag = self._make_tag(key, key_slot=1)
ichallenge = os.urandom(10)
resp, tchallenge = self._do_mam1(tag, key, ichallenge, key_id=1)
iresponse_wire = self._compute_iresponse(key, ichallenge, tchallenge)
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + iresponse_wire
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
assert 1 in tag._aes_authenticated
def test_mam1_inactive_key_error(self):
"""MAM1 with inactive key returns error."""
from pm3py.sim.icode_dna import IcodeDnaTag
tag = IcodeDnaTag(aes_keys=[b"\xAA" * 16, None, None, None])
run(tag.power_on())
cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x00]) + bytes(10)
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x01
def test_mam1_invalid_key_id(self):
"""MAM1 with key_id > 3 returns error."""
tag = self._make_tag()
cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x05]) + bytes(10)
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x01
def test_unsupported_auth_method(self):
"""AUTHENTICATE with AuthMethod 0x80 returns error (not supported)."""
tag = self._make_tag()
cmd = bytes([0x02, 0x35, 0x00, 0x80]) + bytes(16)
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x01
def test_mam_state_cleared_after_mam2_failure(self):
"""After MAM2 failure, state is cleared -- second MAM2 also fails."""
key = b"\xAA" * 16
tag = self._make_tag(key)
ichallenge = os.urandom(10)
self._do_mam1(tag, key, ichallenge)
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + os.urandom(16)
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x01
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + os.urandom(16)
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x01
def test_mam1_ichallenge_byte_reversal(self):
"""Verify IChallenge is de-reversed correctly from wire format."""
key = b"\xAA" * 16
tag = self._make_tag(key)
ichallenge = bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A])
cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x00]) + ichallenge[::-1]
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
encrypted_reversed = resp.data[8:24]
cipher = AES.new(key, AES.MODE_ECB)
decrypted = cipher.decrypt(encrypted_reversed[::-1])
assert decrypted[6:16] == ichallenge
def _do_mam_full(self, tag, key, ichallenge, key_id=0, purpose_nibble=0x00):
"""Complete MAM1+MAM2 flow with a specific purpose nibble. Returns MAM2 response."""
_, tchallenge = self._do_mam1(tag, key, ichallenge, key_id=key_id)
purpose_byte = 0x80 | purpose_nibble
ich_31_0 = ichallenge[6:10]
plaintext = bytes([0xDA, purpose_byte]) + ich_31_0 + tchallenge
cipher = AES.new(key, AES.MODE_ECB)
iresponse = cipher.decrypt(plaintext)
iresponse_reversed = iresponse[::-1]
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + iresponse_reversed
return run(tag.handle_frame(RFFrame.from_bytes(cmd)))
def test_mam2_purpose_enable_privacy(self):
"""MAM2 with purpose=0x09 sets privacy mode."""
from pm3py.sim.auth_aes import PRIV_PRIVACY
key = b"\xAA" * 16
tag = self._make_tag(key)
tag._key_privileges[0] = PRIV_PRIVACY
ichallenge = os.urandom(10)
resp = self._do_mam_full(tag, key, ichallenge, purpose_nibble=0x09)
assert resp.data[0] == 0x00
assert 0 in tag._aes_authenticated
assert tag._privacy_mode is True
def test_mam2_purpose_disable_privacy(self):
"""MAM2 with purpose=0x0A clears privacy mode."""
from pm3py.sim.auth_aes import PRIV_PRIVACY
key = b"\xAA" * 16
tag = self._make_tag(key)
tag._key_privileges[0] = PRIV_PRIVACY
tag._privacy_mode = True
ichallenge = os.urandom(10)
resp = self._do_mam_full(tag, key, ichallenge, purpose_nibble=0x0A)
assert resp.data[0] == 0x00
assert 0 in tag._aes_authenticated
assert tag._privacy_mode is False
def test_mam2_purpose_destroy(self):
"""MAM2 with purpose=0x0B sets destroyed."""
from pm3py.sim.auth_aes import PRIV_DESTROY
key = b"\xAA" * 16
tag = self._make_tag(key)
tag._key_privileges[0] = PRIV_DESTROY
ichallenge = os.urandom(10)
resp = self._do_mam_full(tag, key, ichallenge, purpose_nibble=0x0B)
assert resp.data[0] == 0x00
assert 0 in tag._aes_authenticated
assert tag._destroyed is True
def test_mam2_purpose_requires_privilege(self):
"""MAM2 with privacy purpose but no PRIV_PRIVACY returns error."""
key = b"\xAA" * 16
tag = self._make_tag(key)
# No privileges set (default 0x00)
ichallenge = os.urandom(10)
resp = self._do_mam_full(tag, key, ichallenge, purpose_nibble=0x09)
assert resp.data[0] == 0x01
assert 0 not in tag._aes_authenticated
assert tag._privacy_mode is False

181
tests/test_sim_desfire.py Normal file
View File

@@ -0,0 +1,181 @@
"""Tests for pm3py.sim.desfire — DESFire EV1/EV2 transponder and reader."""
import asyncio
import pytest
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import SoftwareMedium
from pm3py.sim.iso14443a import _compute_bcc
from pm3py.sim.desfire import DesfireTag, DesfireReader
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# DesfireTag — basics
# ---------------------------------------------------------------------------
class TestDesfireTagBasics:
def test_sak_indicates_isodep(self):
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
run(tag.power_on())
resp = run(tag.handle_frame(RFFrame.from_hex("26")))
assert resp.data == b"\x44\x03" # DESFire ATQA
# SELECT (7-byte UID)
ct_uid = b"\x88" + tag._uid[0:3]
bcc1 = _compute_bcc(ct_uid)
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1]))))
cl2_uid = tag._uid[3:7]
bcc2 = _compute_bcc(cl2_uid)
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2_uid + bytes([bcc2]))))
assert resp.data[0] & 0x20 != 0 # SAK bit 5 = ISO-DEP
def test_rats_returns_ats(self):
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
run(tag.power_on())
self._select(tag)
resp = run(tag.handle_frame(RFFrame.from_hex("E050")))
assert resp is not None
assert tag.state == "PROTOCOL"
def _select(self, tag):
run(tag.handle_frame(RFFrame.from_hex("26")))
ct_uid = b"\x88" + tag._uid[0:3]
bcc1 = _compute_bcc(ct_uid)
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1]))))
cl2_uid = tag._uid[3:7]
bcc2 = _compute_bcc(cl2_uid)
run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2_uid + bytes([bcc2]))))
class TestDesfireTagApplications:
"""Test DESFire application/file structure."""
def test_get_version(self):
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
self._activate(tag)
resp = self._send_desfire(tag, b"\x60") # GetVersion
assert resp is not None
assert resp[0] == 0xAF # AF = additional frames follow
def test_get_application_ids_default(self):
"""Default tag has only PICC application (0x000000)."""
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
self._activate(tag)
resp = self._send_desfire(tag, b"\x6A") # GetApplicationIDs
assert resp is not None
assert resp[0] == 0x00 # OK
def test_create_application(self):
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
self._activate(tag)
# CreateApplication: AID=0x010203, key_settings=0x0F, num_keys=1
resp = self._send_desfire(tag, b"\xCA\x01\x02\x03\x0F\x01")
assert resp is not None
assert resp[0] == 0x00 # OK
def test_select_application(self):
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
self._activate(tag)
# Create app
self._send_desfire(tag, b"\xCA\x01\x02\x03\x0F\x01")
# Select app
resp = self._send_desfire(tag, b"\x5A\x01\x02\x03")
assert resp is not None
assert resp[0] == 0x00
def test_create_and_read_standard_file(self):
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
self._activate(tag)
self._send_desfire(tag, b"\xCA\x01\x02\x03\x0F\x01") # CreateApp
self._send_desfire(tag, b"\x5A\x01\x02\x03") # SelectApp
# CreateStdDataFile: file_no=0, comm_settings=0, access_rights=0xEEEE, size=16
resp = self._send_desfire(tag, b"\xCD\x00\x00\xEE\xEE\x10\x00\x00")
assert resp[0] == 0x00
# WriteData: file_no=0, offset=0, length=4, data
resp = self._send_desfire(tag, b"\x3D\x00\x00\x00\x00\x04\x00\x00\xDE\xAD\xBE\xEF")
assert resp[0] == 0x00
# ReadData: file_no=0, offset=0, length=4
resp = self._send_desfire(tag, b"\xBD\x00\x00\x00\x00\x04\x00\x00")
assert resp[0] == 0x00
assert resp[1:5] == b"\xDE\xAD\xBE\xEF"
def test_delete_application(self):
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
self._activate(tag)
self._send_desfire(tag, b"\xCA\x01\x02\x03\x0F\x01")
resp = self._send_desfire(tag, b"\xDA\x01\x02\x03") # DeleteApplication
assert resp[0] == 0x00
def test_authenticate_default_key(self):
"""Authenticate with default all-zero AES key."""
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
self._activate(tag)
self._send_desfire(tag, b"\xCA\x01\x02\x03\x0F\x81") # CreateApp, AES keys
self._send_desfire(tag, b"\x5A\x01\x02\x03")
# AuthenticateAES: key_no=0
resp = self._send_desfire(tag, b"\xAA\x00")
assert resp is not None
# Response: status + encrypted challenge (16 bytes for AES)
assert resp[0] == 0xAF # AF = additional frame expected
def _activate(self, tag):
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
ct_uid = b"\x88" + tag._uid[0:3]
bcc1 = _compute_bcc(ct_uid)
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1]))))
cl2_uid = tag._uid[3:7]
bcc2 = _compute_bcc(cl2_uid)
run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2_uid + bytes([bcc2]))))
run(tag.handle_frame(RFFrame.from_hex("E050"))) # RATS
def _send_desfire(self, tag, cmd: bytes) -> bytes | None:
"""Send DESFire command via I-block, return response (status + data)."""
pcb = 0x02
resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([pcb]) + cmd)))
if resp is None:
return None
return resp.data[1:] # strip PCB
# ---------------------------------------------------------------------------
# DesfireReader
# ---------------------------------------------------------------------------
class TestDesfireReader:
def test_get_version(self):
medium = SoftwareMedium()
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
run(medium.attach(tag))
reader = DesfireReader(medium)
result = run(reader.get_version(uid=b"\x04\x01\x02\x03\x04\x05\x06"))
assert result is not None
assert "hw_vendor" in result
def test_create_and_select_app(self):
medium = SoftwareMedium()
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
run(medium.attach(tag))
reader = DesfireReader(medium)
uid = b"\x04\x01\x02\x03\x04\x05\x06"
result = run(reader.create_application(uid, aid=b"\x01\x02\x03", num_keys=1))
assert result["success"]
result = run(reader.select_application(uid, aid=b"\x01\x02\x03"))
assert result["success"]

View File

@@ -0,0 +1,211 @@
"""Tests for DualInterfaceSession coordinator."""
import time
import pytest
from pm3py.sim.dual_session import DualInterfaceSession
from pm3py.sim.mcu_bridge import McuBridge
from pm3py.sim.mcu_protocol import MsgType, build_frame, cobs_decode, parse_frame
from pm3py.sim.ntag5_link import Ntag5LinkTag
class MockSimSession:
"""Mock SimSession for testing — no real PM3 hardware."""
def __init__(self):
self.on_field_strength = None
self._started = False
self._stopped = False
self._tag = None
self._trace = False
def start_15693(self, tag, trace=False):
self._started = True
self._tag = tag
self._trace = trace
def stop(self):
self._stopped = True
class MockSerial:
"""Mock serial port for McuBridge."""
def __init__(self):
self._rx_buf = bytearray()
self._tx_buf = bytearray()
self.is_open = True
self.timeout = 0.1
def read(self, size=1):
if not self._rx_buf:
time.sleep(0.01)
return b""
data = bytes(self._rx_buf[:size])
self._rx_buf = self._rx_buf[size:]
return data
def write(self, data):
self._tx_buf.extend(data)
return len(data)
def inject(self, data: bytes):
self._rx_buf.extend(data)
def get_sent(self) -> bytes:
data = bytes(self._tx_buf)
self._tx_buf.clear()
return data
def close(self):
self.is_open = False
def _decode_sent(mock_serial) -> list[tuple[int, bytes]]:
"""Decode all COBS-framed messages sent to mock serial."""
raw = mock_serial.get_sent()
messages = []
while raw:
zero_idx = raw.find(b"\x00")
if zero_idx == -1:
break
frame = raw[:zero_idx]
raw = raw[zero_idx + 1:]
if frame:
decoded = cobs_decode(frame)
messages.append(parse_frame(decoded))
return messages
class TestDualInterfaceSession:
def _make_session(self):
"""Create a DualInterfaceSession with mocks."""
mock_serial = MockSerial()
sim = MockSimSession()
mcu = McuBridge(port=mock_serial)
tag = Ntag5LinkTag()
tag.sram_enable = True
tag.arbiter_mode = 2 # PASSTHROUGH
session = DualInterfaceSession(sim, mcu, tag)
return session, sim, mcu, mock_serial, tag
def test_start_wires_callbacks(self):
session, sim, mcu, mock_serial, tag = self._make_session()
session.start()
time.sleep(0.05)
assert sim._started
assert mcu.on_i2c_write is not None
assert mcu.on_i2c_read_req is not None
assert tag.ed_pin_callback is not None
assert sim.on_field_strength is not None
session.stop()
def test_start_pushes_initial_sram(self):
session, sim, mcu, mock_serial, tag = self._make_session()
tag._sram[0:4] = b"\xDE\xAD\xBE\xEF"
session.start()
time.sleep(0.05)
session.stop()
msgs = _decode_sent(mock_serial)
# Should have WRITE_SRAM with full 256-byte SRAM
sram_msgs = [m for m in msgs if m[0] == MsgType.WRITE_SRAM]
assert len(sram_msgs) == 1
assert sram_msgs[0][1][0] == 0x00 # offset 0
assert sram_msgs[0][1][1:5] == b"\xDE\xAD\xBE\xEF"
def test_i2c_write_updates_tag_sram(self):
session, sim, mcu, mock_serial, tag = self._make_session()
tag.config_pt_transfer_dir = False # I2C→NFC
session.start()
time.sleep(0.05)
# Simulate MCU pushing an I2C write event
mock_serial.inject(build_frame(MsgType.I2C_WRITE,
bytes([0x20, 0x00, 0xCA, 0xFE])))
time.sleep(0.15)
session.stop()
# Tag SRAM should have the data
assert tag._sram[0:2] == b"\xCA\xFE"
def test_i2c_read_req_responds(self):
session, sim, mcu, mock_serial, tag = self._make_session()
# Preload SRAM
tag._sram[0:4] = b"\x01\x02\x03\x04"
session.start()
time.sleep(0.05)
# Clear initial WRITE_SRAM from get_sent
mock_serial.get_sent()
# Simulate MCU requesting I2C read
mock_serial.inject(build_frame(MsgType.I2C_READ_REQ,
bytes([0x20, 0x00, 0x04])))
time.sleep(0.15)
session.stop()
msgs = _decode_sent(mock_serial)
resp_msgs = [m for m in msgs if m[0] == MsgType.I2C_READ_RESPONSE]
assert len(resp_msgs) == 1
assert resp_msgs[0][1] == b"\x01\x02\x03\x04"
def test_ed_pin_relayed_to_mcu(self):
session, sim, mcu, mock_serial, tag = self._make_session()
session.start()
time.sleep(0.05)
mock_serial.get_sent() # clear initial WRITE_SRAM
# Trigger ED on tag
tag.assert_ed()
time.sleep(0.05)
session.stop()
msgs = _decode_sent(mock_serial)
ed_msgs = [m for m in msgs if m[0] == MsgType.SET_ED]
assert len(ed_msgs) == 1
assert ed_msgs[0][1] == bytes([0x01])
def test_field_strength_above_threshold_activates_eh(self):
session, sim, mcu, mock_serial, tag = self._make_session()
session.eh_threshold = 500
session.start()
time.sleep(0.05)
mock_serial.get_sent() # clear initial
# Simulate field strength above threshold
sim.on_field_strength(600)
time.sleep(0.05)
session.stop()
msgs = _decode_sent(mock_serial)
eh_msgs = [m for m in msgs if m[0] == MsgType.SET_EH]
assert len(eh_msgs) == 1
assert eh_msgs[0][1] == bytes([0x01])
assert tag.nfc_field_ok
def test_field_strength_below_threshold_deactivates_eh(self):
session, sim, mcu, mock_serial, tag = self._make_session()
session.eh_threshold = 500
session._eh_active = True # pretend already active
session.start()
time.sleep(0.05)
mock_serial.get_sent()
sim.on_field_strength(300)
time.sleep(0.05)
session.stop()
msgs = _decode_sent(mock_serial)
eh_msgs = [m for m in msgs if m[0] == MsgType.SET_EH]
assert len(eh_msgs) == 1
assert eh_msgs[0][1] == bytes([0x00])
def test_stop_unhooks_callbacks(self):
session, sim, mcu, mock_serial, tag = self._make_session()
session.start()
time.sleep(0.05)
session.stop()
assert mcu.on_i2c_write is None
assert mcu.on_i2c_read_req is None
assert tag.ed_pin_callback is None
assert sim.on_field_strength is None

167
tests/test_sim_frame.py Normal file
View File

@@ -0,0 +1,167 @@
"""Tests for pm3py.sim.frame — RFFrame with bit-level granularity."""
import pytest
from bitarray import bitarray
from pm3py.sim.frame import RFFrame
class TestRFFrameConstruction:
"""Test RFFrame creation from various inputs."""
def test_from_bytes_creates_frame_with_correct_bits(self):
frame = RFFrame.from_bytes(b"\xA5")
assert frame.bit_count == 8
assert frame.data == b"\xA5"
def test_from_bytes_multi_byte(self):
frame = RFFrame.from_bytes(b"\x01\x02\x03")
assert frame.bit_count == 24
assert frame.data == b"\x01\x02\x03"
def test_from_bytes_empty(self):
frame = RFFrame.from_bytes(b"")
assert frame.bit_count == 0
assert frame.data == b""
def test_from_hex_creates_frame(self):
frame = RFFrame.from_hex("A5B6")
assert frame.bit_count == 16
assert frame.data == b"\xA5\xB6"
def test_from_hex_case_insensitive(self):
frame = RFFrame.from_hex("a5b6")
assert frame.data == b"\xA5\xB6"
def test_from_bits_with_partial_byte(self):
"""7-bit NVB response during anticollision."""
bits = bitarray("1010101")
frame = RFFrame(bits=bits, bit_count=7)
assert frame.bit_count == 7
# data property truncates to full bytes
assert frame.data == b""
def test_from_bits_with_full_and_partial(self):
"""12 bits: 8 full + 4 partial."""
bits = bitarray("101010110011")
frame = RFFrame(bits=bits, bit_count=12)
assert frame.bit_count == 12
assert frame.data == b"\xAB" # first 8 bits = 10101011
class TestRFFrameImmutability:
"""RFFrame should be frozen/immutable."""
def test_cannot_set_attributes(self):
frame = RFFrame.from_bytes(b"\x00")
with pytest.raises(AttributeError):
frame.bit_count = 99
def test_cannot_set_bits(self):
frame = RFFrame.from_bytes(b"\x00")
with pytest.raises(AttributeError):
frame.bits = bitarray("11111111")
class TestRFFrameOptionalFields:
"""Test optional fields: parity, crc, collision_positions, timestamp."""
def test_defaults_are_none_and_zero(self):
frame = RFFrame.from_bytes(b"\x00")
assert frame.parity is None
assert frame.crc is None
assert frame.collision_positions is None
assert frame.timestamp_us == 0
def test_with_parity(self):
parity = bitarray("1")
frame = RFFrame.from_bytes(b"\xA5", parity=parity)
assert frame.parity == parity
def test_with_crc(self):
frame = RFFrame.from_bytes(b"\xA5", crc=b"\x12\x34")
assert frame.crc == b"\x12\x34"
def test_with_collision_positions(self):
frame = RFFrame.from_bytes(b"\xA5", collision_positions=[2, 5])
assert frame.collision_positions == [2, 5]
def test_with_timestamp(self):
frame = RFFrame.from_bytes(b"\xA5", timestamp_us=12345)
assert frame.timestamp_us == 12345
def test_has_collision_property(self):
clean = RFFrame.from_bytes(b"\xA5")
assert not clean.has_collision
collided = RFFrame.from_bytes(b"\xA5", collision_positions=[3])
assert collided.has_collision
class TestRFFrameCollisionMerge:
"""Test bit-level collision merging of multiple frames."""
def test_merge_identical_frames_no_collision(self):
f1 = RFFrame.from_bytes(b"\xA5")
f2 = RFFrame.from_bytes(b"\xA5")
merged = RFFrame.merge([f1, f2])
assert merged.data == b"\xA5"
assert merged.collision_positions == []
def test_merge_different_frames_detects_collision(self):
f1 = RFFrame.from_bytes(b"\xFF") # 11111111
f2 = RFFrame.from_bytes(b"\x00") # 00000000
merged = RFFrame.merge([f1, f2])
# All 8 bit positions should have collisions
assert len(merged.collision_positions) == 8
def test_merge_partial_difference(self):
f1 = RFFrame.from_hex("A0") # 10100000
f2 = RFFrame.from_hex("A5") # 10100101
merged = RFFrame.merge([f1, f2])
# Bits 5 and 7 differ (0-indexed from MSB: positions 5 and 7)
assert merged.collision_positions is not None
assert len(merged.collision_positions) == 2
def test_merge_single_frame_returns_copy(self):
f1 = RFFrame.from_bytes(b"\xAB")
merged = RFFrame.merge([f1])
assert merged.data == b"\xAB"
assert merged.collision_positions == []
def test_merge_empty_list_returns_none(self):
result = RFFrame.merge([])
assert result is None
def test_merge_three_frames(self):
f1 = RFFrame.from_hex("FF") # 11111111
f2 = RFFrame.from_hex("FE") # 11111110
f3 = RFFrame.from_hex("FF") # 11111111
merged = RFFrame.merge([f1, f2, f3])
# Only bit 7 (LSB) has collision (1 vs 0 vs 1)
assert len(merged.collision_positions) == 1
def test_merge_different_lengths(self):
"""Shorter frame treated as not responding for extra bits."""
short = RFFrame(bits=bitarray("1010"), bit_count=4)
long = RFFrame.from_bytes(b"\xAB") # 10101011, 8 bits
merged = RFFrame.merge([short, long])
# First 4 bits: both respond, check for collisions
# Bits 4-7: only long responds, no collision
assert merged.bit_count == 8
def test_collision_bit_defaults_to_one(self):
"""Per ISO 14443-A: collision defaults to 1 (Manchester)."""
f1 = RFFrame.from_bytes(b"\x00") # 00000000
f2 = RFFrame.from_bytes(b"\xFF") # 11111111
merged = RFFrame.merge([f1, f2])
# All collision bits should be 1
assert all(merged.bits[i] == 1 for i in merged.collision_positions)
class TestRFFrameRepr:
"""Test string representation."""
def test_repr_shows_hex(self):
frame = RFFrame.from_hex("A5B6")
r = repr(frame)
assert "a5b6" in r.lower() or "A5B6" in r

638
tests/test_sim_icode3.py Normal file
View File

@@ -0,0 +1,638 @@
"""Tests for ICODE 3 (SL2S3003) transponder model."""
import asyncio
import struct
import pytest
from pm3py.sim.frame import RFFrame
from pm3py.sim.nxp_icode import (
CMD_SET_EAS, CMD_READ_CONFIG, CMD_WRITE_CONFIG, CMD_READ_TT,
)
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
def _xor_password(tag, pwd_value: int) -> bytes:
resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0xB2, 0x04]))))
r = resp.data[1:3]
pwd = struct.pack("<I", pwd_value)
return bytes([pwd[0] ^ r[0], pwd[1] ^ r[1], pwd[2] ^ r[0], pwd[3] ^ r[1]])
def _authenticate(tag, pwd_id: int, pwd_value: int):
xored = _xor_password(tag, pwd_value)
cmd = bytes([0x02, 0xB3, 0x04, pwd_id]) + xored
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00, f"auth failed for pwd_id {pwd_id:#x}"
PWD_READ = 0x01
PWD_WRITE = 0x02
PWD_PRIVACY = 0x04
PWD_DESTROY = 0x08
PWD_EAS_AFI = 0x10
PWD_CONFIG = 0x20
# ---------------------------------------------------------------------------
# Basics
# ---------------------------------------------------------------------------
class TestIcode3Basics:
def test_import(self):
from pm3py.sim.icode3 import Icode3Tag
def test_inherits_slix2(self):
from pm3py.sim.icode3 import Icode3Tag
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
assert issubclass(Icode3Tag, IcodeSlix2Tag)
def test_default_76_blocks(self):
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
assert tag._num_blocks == 76
def test_responds_to_inventory(self):
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
inv = bytes([0x26, 0x01, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
assert resp is not None
# ---------------------------------------------------------------------------
# 6th password (config, pwd_id 0x20)
# ---------------------------------------------------------------------------
class TestIcode3ConfigPassword:
def test_config_password_auth(self):
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4),
config_password=0xDEADBEEF)
run(tag.power_on())
_authenticate(tag, PWD_CONFIG, 0xDEADBEEF)
def test_config_password_wrong(self):
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4),
config_password=0xDEADBEEF)
run(tag.power_on())
xored = _xor_password(tag, 0x00000000)
cmd = bytes([0x02, 0xB3, 0x04, PWD_CONFIG]) + xored
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] & 0x01
# ---------------------------------------------------------------------------
# Inherited SLIX2 features still work
# ---------------------------------------------------------------------------
class TestIcode3InheritsSlix2:
def test_eas(self):
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
cmd = bytes([0x02, CMD_SET_EAS, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
assert tag.eas_enabled
def test_privacy(self):
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4),
privacy_password=0xAAAAAAAA)
run(tag.power_on())
tag.enter_privacy_mode()
assert tag.privacy_mode
def test_destroy(self):
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4),
destroy_password=0xBBBBBBBB)
run(tag.power_on())
xored = _xor_password(tag, 0xBBBBBBBB)
cmd = bytes([0x02, 0xB9, 0x04]) + xored
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
assert run(tag.handle_frame(RFFrame.from_bytes(bytes([0x26, 0x01, 0x00])))) is None
# ---------------------------------------------------------------------------
# 24-bit counter (block 75)
# ---------------------------------------------------------------------------
class TestIcode3Counter:
def test_counter_increments_24bit(self):
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
# Write increment of 1 to block 75
cmd = bytes([0x02, 0x21, 75, 0x01, 0x00, 0x00, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
# Read back
cmd = bytes([0x02, 0x20, 75])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
counter = int.from_bytes(resp.data[1:4], 'little')
assert counter == 1
def test_counter_accumulates(self):
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
for _ in range(10):
cmd = bytes([0x02, 0x21, 75, 0x01, 0x00, 0x00, 0x00])
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
cmd = bytes([0x02, 0x20, 75])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
counter = int.from_bytes(resp.data[1:4], 'little')
assert counter == 10
def test_counter_saturates_at_24bit(self):
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
# Set counter near max
offset = 75 * 4
tag._memory[offset:offset + 3] = (0xFFFFFE).to_bytes(3, 'little')
cmd = bytes([0x02, 0x21, 75, 0x01, 0x00, 0x00, 0x00])
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
# Now at 0xFFFFFF
cmd = bytes([0x02, 0x21, 75, 0x01, 0x00, 0x00, 0x00])
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
# Should saturate
cmd = bytes([0x02, 0x20, 75])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
counter = int.from_bytes(resp.data[1:4], 'little')
assert counter == 0xFFFFFF
def test_normal_blocks_still_overwrite(self):
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
cmd = bytes([0x02, 0x21, 0x00, 0xAA, 0xBB, 0xCC, 0xDD])
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
cmd = bytes([0x02, 0x21, 0x00, 0x11, 0x22, 0x33, 0x44])
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
cmd = bytes([0x02, 0x20, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[1:5] == bytes([0x11, 0x22, 0x33, 0x44])
# ---------------------------------------------------------------------------
# READ CONFIG (0xC0) / WRITE CONFIG (0xC1)
# ---------------------------------------------------------------------------
class TestIcode3Config:
def test_read_config(self):
"""READ CONFIG returns config memory blocks."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
# Read config block 16 (DSFID), 1 block
cmd = bytes([0x02, CMD_READ_CONFIG, 0x04, 16, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
assert len(resp.data) >= 5 # flags + 4 bytes
def test_write_config(self):
"""WRITE CONFIG writes to config memory."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
# Write to config block 18 (EAS ID)
cmd = bytes([0x02, CMD_WRITE_CONFIG, 0x04, 18, 0x34, 0x12, 0x00, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
# Read it back
cmd = bytes([0x02, CMD_READ_CONFIG, 0x04, 18, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[1:3] == bytes([0x34, 0x12])
def test_read_config_passwords_masked(self):
"""READ CONFIG masks password blocks with 0x00."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4),
read_password=0xDEADBEEF)
run(tag.power_on())
# Read config block 42 (READ_PWD) — should be masked
cmd = bytes([0x02, CMD_READ_CONFIG, 0x04, 42, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
assert resp.data[1:5] == bytes(4) # masked with 0x00
def test_write_config_requires_auth_when_protected(self):
"""WRITE CONFIG fails without config password when protected."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4),
config_password=0xAABBCCDD)
run(tag.power_on())
tag._config_password_protected = True
cmd = bytes([0x02, CMD_WRITE_CONFIG, 0x04, 18, 0x34, 0x12, 0x00, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] & 0x01
def test_read_config_multiple_blocks(self):
"""READ CONFIG can read multiple blocks."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
# Read 3 blocks starting at block 16
cmd = bytes([0x02, CMD_READ_CONFIG, 0x04, 16, 0x02])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
assert len(resp.data) >= 13 # flags + 3 * 4 bytes
# ---------------------------------------------------------------------------
# READ TT (0xC4) — TagTamper
# ---------------------------------------------------------------------------
class TestIcode3TagTamper:
def test_read_tt_closed(self):
"""READ TT returns closed status by default."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4), tag_tamper=True)
run(tag.power_on())
cmd = bytes([0x02, CMD_READ_TT, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
# actual_status=0x43('C'), stored_status=0x43('C')
assert resp.data[1] == 0x43 # 'C' = Closed
assert resp.data[2] == 0x43
def test_read_tt_open(self):
"""READ TT returns open status when tampered."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4), tag_tamper=True)
run(tag.power_on())
tag._tt_status_actual = 0x4F # 'O' = Open
cmd = bytes([0x02, CMD_READ_TT, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[1] == 0x4F # 'O'
def test_read_tt_not_supported_without_flag(self):
"""READ TT returns error when tag_tamper=False."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4), tag_tamper=False)
run(tag.power_on())
cmd = bytes([0x02, CMD_READ_TT, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None or resp.data[0] & 0x01
# ---------------------------------------------------------------------------
# GET NXP SYSTEM INFO feature flags
# ---------------------------------------------------------------------------
class TestIcode3SystemInfo:
def test_feature_flags(self):
"""GET NXP SYSTEM INFO returns ICODE 3 feature flags."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
cmd = bytes([0x02, 0xAB, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
# Feature flags should indicate counter, privacy, destroy, etc.
feature_flags = struct.unpack_from("<I", resp.data, 4)[0]
assert feature_flags & (1 << 1) # COUNTER
assert feature_flags & (1 << 8) # ORIGINALITY SIG
assert feature_flags & (1 << 10) # P QUIET
assert feature_flags & (1 << 12) # PRIVACY
assert feature_flags & (1 << 13) # DESTROY
# ---------------------------------------------------------------------------
# NFC ASCII mirror
# ---------------------------------------------------------------------------
class TestIcode3NfcMirror:
def test_uid_mirror(self):
"""UID mirror overlays 16 ASCII hex bytes into user memory on read."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06")
run(tag.power_on())
# Enable UID mirror at block 10, byte 0
tag.set_nfc_mirror(sel=1, block=10, byte_offset=0)
# Read block 10 — should contain ASCII hex of UID
cmd = bytes([0x02, 0x20, 10])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
# UID E0040102... MSB first → "E0040102" as ASCII = 0x45 0x30 0x30 0x34
data = resp.data[1:5]
assert data == b"E004"
def test_uid_counter_mirror(self):
"""UID + counter mirror: 16 bytes UID + 'x' + 6 bytes counter."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06")
run(tag.power_on())
# Set counter to 0x000042 (66 decimal)
offset = 75 * 4
tag._memory[offset:offset + 3] = (0x42).to_bytes(3, 'little')
tag.set_nfc_mirror(sel=2, block=10, byte_offset=0)
# Read blocks 10-15 to get full mirror
mirror_data = bytearray()
for blk in range(10, 16):
cmd = bytes([0x02, 0x20, blk])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
mirror_data.extend(resp.data[1:5])
# First 16 bytes = UID hex: "E004012003040506"
assert mirror_data[:16] == b"E004012003040506"
# Separator
assert mirror_data[16:17] == b"x"
# Counter 6 bytes hex MSB first: 0x000042 → "000042"
assert mirror_data[17:23] == b"000042"
def test_mirror_disabled(self):
"""With mirror disabled (sel=0), reads return physical memory."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06")
run(tag.power_on())
tag.set_nfc_mirror(sel=0, block=10, byte_offset=0)
# Write data to block 10
cmd = bytes([0x02, 0x21, 10, 0xAA, 0xBB, 0xCC, 0xDD])
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
# Read back — should be physical data
cmd = bytes([0x02, 0x20, 10])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[1:5] == bytes([0xAA, 0xBB, 0xCC, 0xDD])
def test_mirror_with_byte_offset(self):
"""Mirror starting at byte 2 within a block."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06")
run(tag.power_on())
# Write known data first
cmd = bytes([0x02, 0x21, 10, 0xFF, 0xFF, 0xFF, 0xFF])
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
# UID mirror at block 10, byte 2
tag.set_nfc_mirror(sel=1, block=10, byte_offset=2)
cmd = bytes([0x02, 0x20, 10])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
data = resp.data[1:5]
# Bytes 0-1 = physical (0xFF, 0xFF), bytes 2-3 = mirror start ("E0")
assert data[0:2] == bytes([0xFF, 0xFF])
assert data[2:4] == b"E0"
def test_mirror_outside_block_returns_physical(self):
"""Blocks before mirror region return physical memory."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06")
run(tag.power_on())
tag.set_nfc_mirror(sel=1, block=20, byte_offset=0)
cmd = bytes([0x02, 0x21, 5, 0x11, 0x22, 0x33, 0x44])
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
cmd = bytes([0x02, 0x20, 5])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[1:5] == bytes([0x11, 0x22, 0x33, 0x44])
# ---------------------------------------------------------------------------
# Privacy mode 2 + PICK RANDOM ID
# ---------------------------------------------------------------------------
class TestIcode3PrivacyMode2:
def test_privacy_mode2_inventory_zeroed_uid(self):
"""In privacy mode 2, inventory returns zeroed UID."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06",
privacy_password=0xDEADBEEF)
run(tag.power_on())
tag._privacy_mode_sel = 2
tag.enter_privacy_mode()
inv = bytes([0x26, 0x01, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
assert resp is not None
# UID should be E0 04 00 00 00 00 00 00 (reversed in response)
uid_in_resp = resp.data[2:10]
uid_msb = bytes(reversed(uid_in_resp))
assert uid_msb == b"\xE0\x04\x00\x00\x00\x00\x00\x00"
def test_privacy_mode2_allows_reads(self):
"""Privacy mode 2 allows READ SINGLE BLOCK."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06",
privacy_password=0xDEADBEEF)
run(tag.power_on())
# Write data first
cmd = bytes([0x02, 0x21, 0, 0xAA, 0xBB, 0xCC, 0xDD])
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
tag._privacy_mode_sel = 2
tag.enter_privacy_mode()
# Read should work in privacy mode 2
cmd = bytes([0x02, 0x20, 0])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
def test_privacy_mode1_blocks_reads(self):
"""Privacy mode 1 blocks everything except GET_RANDOM/SET_PASSWORD."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06",
privacy_password=0xDEADBEEF)
run(tag.power_on())
tag._privacy_mode_sel = 1
tag.enter_privacy_mode()
# Read should fail in privacy mode 1
cmd = bytes([0x02, 0x20, 0])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None
def test_pick_random_id(self):
"""PICK RANDOM ID (0xC2) generates random UID for inventory."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06",
privacy_password=0xDEADBEEF)
run(tag.power_on())
tag._privacy_mode_sel = 2
tag.enter_privacy_mode()
# PICK RANDOM ID
cmd = bytes([0x02, 0xC2, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
# Now inventory should return the random ID, not the zeroed one
inv = bytes([0x26, 0x01, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
uid_in_resp = bytes(reversed(resp.data[2:10]))
# Random ID format: E0 04 00 00 CID1 CID0 RID1 RID0
assert uid_in_resp[0:2] == b"\xE0\x04"
assert uid_in_resp[2:4] == b"\x00\x00"
# Last 4 bytes should be non-zero (random + CID)
# (statistically near-impossible to be all zero)
def test_pick_random_id_fails_outside_privacy(self):
"""PICK RANDOM ID fails when not in privacy mode."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06")
run(tag.power_on())
cmd = bytes([0x02, 0xC2, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None or resp.data[0] & 0x01
# ---------------------------------------------------------------------------
# 48-byte originality signature (SL2S3003 8.5.3.20)
# ---------------------------------------------------------------------------
class TestIcode3Signature48:
def test_default_32_byte_signature(self):
"""Default READ SIGNATURE returns 32 bytes (1 flag + 32 sig = 33)."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
cmd = bytes([0x02, 0xBD, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
assert len(resp.data) == 33 # 1 flag + 32 signature
def test_48_byte_signature_mode(self):
"""With 48-byte mode enabled, READ SIGNATURE returns 49 bytes."""
from pm3py.sim.icode3 import Icode3Tag
sig48 = bytes(range(48))
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4), signature=sig48)
run(tag.power_on())
tag.set_signature_mode_48(True)
cmd = bytes([0x02, 0xBD, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
assert len(resp.data) == 49 # 1 flag + 48 signature
assert resp.data[1:] == sig48
def test_48_byte_mode_disabled_truncates(self):
"""With 48-byte mode disabled, READ SIGNATURE returns first 32 bytes."""
from pm3py.sim.icode3 import Icode3Tag
sig48 = bytes(range(48))
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4), signature=sig48)
run(tag.power_on())
# Mode defaults to 32-byte
cmd = bytes([0x02, 0xBD, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert len(resp.data) == 33 # 1 flag + 32 signature
# ---------------------------------------------------------------------------
# 24-bit counter preset vs increment (SL2S3003 8.5.3.25)
# ---------------------------------------------------------------------------
class TestIcode3CounterPreset:
def test_value_0x000001_increments(self):
"""Writing 0x000001 to block 75 increments counter by 1."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
# Counter starts at 0, write 0x000001 → increment by 1 → 1
cmd = bytes([0x02, 0x21, 75, 0x01, 0x00, 0x00, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
cmd = bytes([0x02, 0x20, 75])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
counter = int.from_bytes(resp.data[1:4], 'little')
assert counter == 1
def test_value_not_0x000001_presets(self):
"""Writing != 0x000001 to block 75 presets (overwrites) counter."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
# Set counter to 100 first
offset = 75 * 4
tag._memory[offset:offset + 3] = (100).to_bytes(3, 'little')
# Write 0x000100 (256) → should preset to 256, NOT add to 100
cmd = bytes([0x02, 0x21, 75, 0x00, 0x01, 0x00, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
cmd = bytes([0x02, 0x20, 75])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
counter = int.from_bytes(resp.data[1:4], 'little')
assert counter == 256 # preset, not 356
def test_0x000001_twice_increments_twice(self):
"""Writing 0x000001 twice increments counter from 0 → 1 → 2."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
cmd = bytes([0x02, 0x21, 75, 0x01, 0x00, 0x00, 0x00])
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
cmd = bytes([0x02, 0x20, 75])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
counter = int.from_bytes(resp.data[1:4], 'little')
assert counter == 2
def test_prot_byte_written(self):
"""PROT byte (data[3]) is written to memory alongside counter."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
run(tag.power_on())
# Preset counter to 0x000005 with PROT=0xAB
cmd = bytes([0x02, 0x21, 75, 0x05, 0x00, 0x00, 0xAB])
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
cmd = bytes([0x02, 0x20, 75])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[4] == 0xAB # PROT byte at offset 3 in block

View File

@@ -0,0 +1,341 @@
"""Tests for ICODE SLIX (SL2S2002) transponder model."""
import asyncio
import struct
import pytest
from pm3py.sim.frame import RFFrame
from pm3py.sim.nxp_icode import (
CMD_SET_EAS, CMD_RESET_EAS, CMD_LOCK_EAS, CMD_EAS_ALARM,
CMD_PASSWORD_PROTECT_EAS_AFI, CMD_WRITE_EAS_ID,
CMD_INVENTORY_READ, CMD_FAST_INVENTORY_READ,
)
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# Helper: GET_RANDOM + XOR password
# ---------------------------------------------------------------------------
def _xor_password(tag, pwd_value: int) -> bytes:
"""Get random from tag, return XOR'd password bytes."""
resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0xB2, 0x04]))))
r = resp.data[1:3]
pwd = struct.pack("<I", pwd_value)
return bytes([pwd[0] ^ r[0], pwd[1] ^ r[1], pwd[2] ^ r[0], pwd[3] ^ r[1]])
def _authenticate(tag, pwd_id: int, pwd_value: int):
"""GET_RANDOM + SET_PASSWORD for a given password ID."""
xored = _xor_password(tag, pwd_value)
cmd = bytes([0x02, 0xB3, 0x04, pwd_id]) + xored
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00, f"auth failed for pwd_id {pwd_id:#x}"
PWD_EAS_AFI = 0x10
# ---------------------------------------------------------------------------
# Basic SLIX properties
# ---------------------------------------------------------------------------
class TestIcodeSlixBasics:
def test_import(self):
"""IcodeSlixTag can be imported."""
from pm3py.sim.icode_slix import IcodeSlixTag
def test_inherits_nxp_icode(self):
"""SLIX inherits from NxpIcodeTag."""
from pm3py.sim.icode_slix import IcodeSlixTag
from pm3py.sim.nxp_icode import NxpIcodeTag
assert issubclass(IcodeSlixTag, NxpIcodeTag)
def test_default_28_blocks(self):
"""SLIX defaults to 28 blocks × 4 bytes."""
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
assert tag._num_blocks == 28
assert tag._block_size == 4
def test_responds_to_inventory(self):
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
run(tag.power_on())
inv = bytes([0x26, 0x01, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
assert resp is not None
def test_read_write_block(self):
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
run(tag.power_on())
# Write block 5
cmd = bytes([0x02, 0x21, 0x05, 0xAA, 0xBB, 0xCC, 0xDD])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
# Read it back
cmd = bytes([0x02, 0x20, 0x05])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[1:5] == bytes([0xAA, 0xBB, 0xCC, 0xDD])
# ---------------------------------------------------------------------------
# Single password (EAS/AFI only)
# ---------------------------------------------------------------------------
class TestIcodeSlixPassword:
def test_set_password_eas_afi(self):
"""SET_PASSWORD with correct EAS/AFI password succeeds."""
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4),
eas_afi_password=0xAABBCCDD)
run(tag.power_on())
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
def test_set_password_wrong(self):
"""SET_PASSWORD with wrong password fails."""
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4),
eas_afi_password=0xAABBCCDD)
run(tag.power_on())
xored = _xor_password(tag, 0x00000000)
cmd = bytes([0x02, 0xB3, 0x04, PWD_EAS_AFI]) + xored
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] & 0x01
def test_write_password(self):
"""WRITE_PASSWORD changes the EAS/AFI password."""
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4),
eas_afi_password=0xAABBCCDD)
run(tag.power_on())
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
new_xored = _xor_password(tag, 0x11223344)
cmd = bytes([0x02, 0xB4, 0x04, PWD_EAS_AFI]) + new_xored
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
# New password works
_authenticate(tag, PWD_EAS_AFI, 0x11223344)
def test_lock_password(self):
"""LOCK_PASSWORD prevents WRITE_PASSWORD."""
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4),
eas_afi_password=0xAABBCCDD)
run(tag.power_on())
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
cmd = bytes([0x02, 0xB5, 0x04, PWD_EAS_AFI])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
# WRITE_PASSWORD now fails
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
new_xored = _xor_password(tag, 0x11223344)
cmd = bytes([0x02, 0xB4, 0x04, PWD_EAS_AFI]) + new_xored
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] & 0x01
def test_no_privacy_password(self):
"""SLIX has no privacy — only EAS/AFI password accepted."""
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4),
eas_afi_password=0xAABBCCDD)
run(tag.power_on())
# Try to auth with privacy pwd_id — should fail
xored = _xor_password(tag, 0xAABBCCDD)
cmd = bytes([0x02, 0xB3, 0x04, 0x04]) + xored # pwd_id=0x04 (privacy)
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] & 0x01
# ---------------------------------------------------------------------------
# EAS subsystem
# ---------------------------------------------------------------------------
class TestIcodeSlixEas:
def test_set_eas(self):
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
run(tag.power_on())
cmd = bytes([0x02, CMD_SET_EAS, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
assert tag.eas_enabled
def test_reset_eas(self):
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
run(tag.power_on())
tag.set_eas(True)
cmd = bytes([0x02, CMD_RESET_EAS, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
assert not tag.eas_enabled
def test_eas_alarm(self):
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
run(tag.power_on())
tag.set_eas(True)
cmd = bytes([0x02, CMD_EAS_ALARM, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
assert len(resp.data) >= 33
def test_eas_alarm_silent_when_disabled(self):
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
run(tag.power_on())
cmd = bytes([0x02, CMD_EAS_ALARM, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None
def test_lock_eas(self):
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
run(tag.power_on())
tag.set_eas(True)
cmd = bytes([0x02, CMD_LOCK_EAS, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
# Can't change now
cmd = bytes([0x02, CMD_RESET_EAS, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] & 0x01
def test_password_protect_eas_afi(self):
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4),
eas_afi_password=0xAABBCCDD)
run(tag.power_on())
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
cmd = bytes([0x02, CMD_PASSWORD_PROTECT_EAS_AFI, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
assert tag._eas_password_protected
def test_write_eas_id(self):
"""WRITE EAS ID is SLIX2-only (EAS Selective per AN11809)."""
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
eas_afi_password=0xAABBCCDD)
run(tag.power_on())
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
cmd = bytes([0x02, CMD_WRITE_EAS_ID, 0x04, 0x34, 0x12])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
assert tag._eas_id == 0x1234
def test_slix_rejects_write_eas_id(self):
"""SLIX (SL2S2002) does not support WRITE EAS ID (0xA7)."""
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
run(tag.power_on())
cmd = bytes([0x02, CMD_WRITE_EAS_ID, 0x04, 0x34, 0x12])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None
# ---------------------------------------------------------------------------
# INVENTORY READ
# ---------------------------------------------------------------------------
class TestIcodeSlixInventoryRead:
def test_inventory_read(self):
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10\x03\x04\x05\x06")
run(tag.power_on())
cmd = bytes([0x06, CMD_INVENTORY_READ, 0x04, 0x00, 0x00, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
def test_fast_inventory_read(self):
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10\x03\x04\x05\x06")
run(tag.power_on())
cmd = bytes([0x06, CMD_FAST_INVENTORY_READ, 0x04, 0x00, 0x00, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is not None
assert resp.data[0] == 0x00
# ---------------------------------------------------------------------------
# SLIX2 still works after refactoring
# ---------------------------------------------------------------------------
class TestSlix2StillWorks:
"""Verify SLIX2 inherits from SLIX and retains all features."""
def test_slix2_inherits_slix(self):
from pm3py.sim.icode_slix import IcodeSlixTag
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
assert issubclass(IcodeSlix2Tag, IcodeSlixTag)
def test_slix2_privacy_still_works(self):
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
privacy_password=0xDEADBEEF)
run(tag.power_on())
tag.enter_privacy_mode()
assert tag.privacy_mode
def test_slix2_destroy_still_works(self):
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
destroy_password=0xDEAD1234)
run(tag.power_on())
xored = _xor_password(tag, 0xDEAD1234)
cmd = bytes([0x02, 0xB9, 0x04]) + xored
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
# Dead
inv = bytes([0x26, 0x01, 0x00])
assert run(tag.handle_frame(RFFrame.from_bytes(inv))) is None
def test_slix2_eas_inherited_from_slix(self):
"""SLIX2 EAS works via inheritance from SLIX."""
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
run(tag.power_on())
cmd = bytes([0x02, CMD_SET_EAS, 0x04])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp.data[0] == 0x00
assert tag.eas_enabled
# ---------------------------------------------------------------------------
# SLIX should NOT have SLIX2-only features
# ---------------------------------------------------------------------------
class TestSlixLacksPrivacy:
def test_no_enable_privacy(self):
"""SLIX does not respond to ENABLE PRIVACY (0xBA)."""
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
run(tag.power_on())
cmd = bytes([0x02, 0xBA, 0x04, 0x00, 0x00, 0x00, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
# Should be None (unhandled) or error
assert resp is None or resp.data[0] & 0x01
def test_no_destroy(self):
"""SLIX does not respond to DESTROY (0xB9)."""
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
run(tag.power_on())
cmd = bytes([0x02, 0xB9, 0x04, 0x00, 0x00, 0x00, 0x00])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None or resp.data[0] & 0x01
def test_no_protect_page(self):
"""SLIX does not respond to PROTECT PAGE (0xB6)."""
from pm3py.sim.icode_slix import IcodeSlixTag
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
run(tag.power_on())
cmd = bytes([0x02, 0xB6, 0x04, 0x08, 0x01])
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
assert resp is None or resp.data[0] & 0x01

105
tests/test_sim_implants.py Normal file
View File

@@ -0,0 +1,105 @@
"""Tests for implant preset profiles."""
import asyncio
import pytest
from pm3py.sim.medium import SoftwareMedium
from pm3py.sim.implants import (
xEM, xNT, xM1, FlexDF, NExT,
MagicMifareClassicTag,
)
from pm3py.sim.em4100 import EM4100Reader
from pm3py.sim.iso14443a import Reader14443A, _compute_bcc
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
class TestXEM:
def test_creates_t5577_configured_as_em4100(self):
tag = xEM(tag_id=0x1122334455)
assert tag.blocks[0] != 0 # config block set
def test_readable_as_em4100(self):
medium = SoftwareMedium()
tag = xEM(tag_id=0x1122334455)
run(medium.attach(tag))
reader = EM4100Reader(medium)
result = run(reader.read_id())
assert result is not None
assert result["tag_id"] == 0x1122334455
class TestXNT:
def test_creates_ntag216_like(self):
tag = xNT()
assert tag._total_pages >= 45 # NTAG213+ size
def test_is_type2_tag(self):
from pm3py.sim.ndef import NfcType2Tag
tag = xNT()
assert isinstance(tag, NfcType2Tag)
def test_has_7byte_uid(self):
tag = xNT()
assert len(tag._uid) == 7
class TestXM1:
def test_creates_mifare_classic_1k(self):
tag = xM1()
assert tag._size == "1k"
assert len(tag._data) == 1024
def test_default_keys_ff(self):
tag = xM1()
assert tag._keys_a[0] == b"\xFF" * 6
class TestMagicMifareClassic:
def test_gen1a_backdoor_write_block0(self):
"""Magic gen1a allows direct write to block 0."""
tag = MagicMifareClassicTag(uid=b"\x01\x02\x03\x04",
magic_type="gen1a")
run(tag.power_on())
# gen1a responds to special WUPA sequence for backdoor
assert tag.magic_type == "gen1a"
# Direct block 0 write should work
new_data = b"\xDE\xAD\xBE\xEF" + b"\x00" * 12
tag.write_block_raw(0, new_data)
assert tag.read_block_raw(0) == new_data
def test_gen2_cuid(self):
tag = MagicMifareClassicTag(uid=b"\x01\x02\x03\x04",
magic_type="gen2")
assert tag.magic_type == "gen2"
class TestFlexDF:
def test_creates_desfire(self):
from pm3py.sim.desfire import DesfireTag
tag = FlexDF()
assert isinstance(tag, DesfireTag)
def test_has_7byte_uid(self):
tag = FlexDF()
assert len(tag._uid) == 7
class TestNExT:
def test_creates_dual_frequency(self):
lf, hf = NExT(tag_id=0x1122334455)
# LF side is T5577 configured as EM4100
assert lf.blocks[0] != 0
# HF side is NfcType2Tag (xNT)
from pm3py.sim.ndef import NfcType2Tag
assert isinstance(hf, NfcType2Tag)
def test_both_sides_work(self):
lf, hf = NExT(tag_id=0xAABBCCDDEE)
# LF
lf_medium = SoftwareMedium()
run(lf_medium.attach(lf))
reader = EM4100Reader(lf_medium)
result = run(reader.read_id())
assert result["tag_id"] == 0xAABBCCDDEE

218
tests/test_sim_lf.py Normal file
View File

@@ -0,0 +1,218 @@
"""Tests for pm3py.sim LF transponders and readers — TagLF, EM4100, HID, T5577."""
import asyncio
import pytest
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import SoftwareMedium
from pm3py.sim.lf_base import TagLF, ReaderLF, Modulation
from pm3py.sim.em4100 import EM4100Tag, EM4100Reader
from pm3py.sim.hid import HIDProxTag, HIDReader
from pm3py.sim.t5577 import T5577Tag, T5577Reader
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# TagLF — base LF transponder
# ---------------------------------------------------------------------------
class TestTagLFBase:
def test_modulation_types_exist(self):
assert Modulation.ASK is not None
assert Modulation.FSK is not None
assert Modulation.PSK is not None
assert Modulation.NRZ is not None
def test_taglf_responds_to_field(self):
"""Any LF tag responds when RF field is present (energized)."""
tag = EM4100Tag(tag_id=0x1A2B3C4D5E)
run(tag.power_on())
# LF tags continuously transmit when energized
# A "read" frame from the reader is just an empty energize command
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x00")))
assert resp is not None
# ---------------------------------------------------------------------------
# EM4100 — read-only 64-bit Manchester ASK tag
# ---------------------------------------------------------------------------
class TestEM4100Tag:
def test_construction(self):
tag = EM4100Tag(tag_id=0x1A2B3C4D5E)
assert tag.tag_id == 0x1A2B3C4D5E
def test_encodes_64bit_data(self):
"""EM4100 data: 9-bit header + 10 rows of (4 data + 1 parity) + 4 col parity + stop."""
tag = EM4100Tag(tag_id=0x1A2B3C4D5E)
data = tag.encoded_data
assert len(data) == 64 # 64 bits total
def test_header_is_9_ones(self):
tag = EM4100Tag(tag_id=0x0000000000)
data = tag.encoded_data
assert all(b == 1 for b in data[:9])
def test_row_parity(self):
"""Each 4-bit row has even parity appended."""
tag = EM4100Tag(tag_id=0x1A2B3C4D5E)
data = tag.encoded_data
# 10 rows starting at bit 9, each 5 bits (4 data + 1 parity)
for row in range(10):
start = 9 + row * 5
row_bits = data[start:start + 5]
assert sum(row_bits) % 2 == 0, f"Row {row} parity error"
def test_column_parity(self):
"""4 column parity bits after the data rows."""
tag = EM4100Tag(tag_id=0x1A2B3C4D5E)
data = tag.encoded_data
# Column parity at bits 59-62
for col in range(4):
col_sum = 0
for row in range(10):
col_sum += data[9 + row * 5 + col]
col_sum += data[59 + col] # parity bit itself
assert col_sum % 2 == 0, f"Column {col} parity error"
def test_stop_bit(self):
tag = EM4100Tag(tag_id=0x0000000000)
data = tag.encoded_data
assert data[63] == 0 # stop bit
def test_responds_to_read(self):
medium = SoftwareMedium()
tag = EM4100Tag(tag_id=0x1A2B3C4D5E)
run(medium.attach(tag))
reader = EM4100Reader(medium)
result = run(reader.read_id())
assert result is not None
assert result["tag_id"] == 0x1A2B3C4D5E
def test_decode_roundtrip(self):
"""Encode then decode should return original ID."""
original_id = 0xDEADBEEF01
tag = EM4100Tag(tag_id=original_id)
decoded = EM4100Tag.decode_data(tag.encoded_data)
assert decoded == original_id
# ---------------------------------------------------------------------------
# HID ProxCard — FSK modulated, Wiegand credential
# ---------------------------------------------------------------------------
class TestHIDProxTag:
def test_construction_26bit(self):
tag = HIDProxTag(facility_code=42, card_number=12345, format="H10301")
assert tag.facility_code == 42
assert tag.card_number == 12345
assert tag.format == "H10301"
def test_26bit_wiegand_encoding(self):
"""H10301: 1 even parity + 8 FC + 16 CN + 1 odd parity = 26 bits."""
tag = HIDProxTag(facility_code=42, card_number=12345, format="H10301")
wiegand = tag.wiegand_bits
assert len(wiegand) == 26
def test_26bit_parity(self):
"""First 13 bits: even parity. Last 13 bits: odd parity."""
tag = HIDProxTag(facility_code=42, card_number=12345, format="H10301")
wiegand = tag.wiegand_bits
assert sum(wiegand[:13]) % 2 == 0 # even parity on first half
assert sum(wiegand[13:]) % 2 == 1 # odd parity on second half
def test_37bit_format(self):
tag = HIDProxTag(facility_code=1000, card_number=50000, format="H10304")
wiegand = tag.wiegand_bits
assert len(wiegand) == 37
def test_reader_reads_credential(self):
medium = SoftwareMedium()
tag = HIDProxTag(facility_code=42, card_number=12345)
run(medium.attach(tag))
reader = HIDReader(medium)
result = run(reader.read_credential())
assert result is not None
assert result["facility_code"] == 42
assert result["card_number"] == 12345
class TestHIDProxDecoding:
def test_decode_26bit_roundtrip(self):
tag = HIDProxTag(facility_code=100, card_number=9999, format="H10301")
decoded = HIDProxTag.decode_wiegand(tag.wiegand_bits, "H10301")
assert decoded["facility_code"] == 100
assert decoded["card_number"] == 9999
# ---------------------------------------------------------------------------
# T5577 — programmable LF emulator
# ---------------------------------------------------------------------------
class TestT5577Tag:
def test_construction(self):
tag = T5577Tag()
assert len(tag.blocks) == 8
assert all(b == 0 for b in tag.blocks)
def test_read_block(self):
tag = T5577Tag()
tag.blocks[1] = 0xDEADBEEF
run(tag.power_on())
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x01\x01"))) # read block 1
assert resp is not None
def test_write_block(self):
tag = T5577Tag()
run(tag.power_on())
# Write block 1 = 0xCAFEBABE
import struct
write_data = b"\x02\x01" + struct.pack(">I", 0xCAFEBABE)
resp = run(tag.handle_frame(RFFrame.from_bytes(write_data)))
assert tag.blocks[1] == 0xCAFEBABE
def test_password_protection(self):
tag = T5577Tag(password=0x12345678)
run(tag.power_on())
# Read without password should fail
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x01\x01")))
assert resp is None
# Read with correct password
import struct
pwd_read = b"\x03\x01" + struct.pack(">I", 0x12345678)
resp = run(tag.handle_frame(RFFrame.from_bytes(pwd_read)))
assert resp is not None
def test_configure_as_em4100(self):
"""T5577 can be configured to emulate EM4100."""
tag = T5577Tag.preset("em4100", tag_id=0x1A2B3C4D5E)
assert tag.blocks[0] != 0 # config block set
# Block 0 should have ASK/Manchester config
def test_reader_read_block(self):
medium = SoftwareMedium()
tag = T5577Tag()
tag.blocks[1] = 0xDEADBEEF
run(medium.attach(tag))
reader = T5577Reader(medium)
result = run(reader.read_block(1))
assert result["data"] == 0xDEADBEEF
def test_reader_write_block(self):
medium = SoftwareMedium()
tag = T5577Tag()
run(medium.attach(tag))
reader = T5577Reader(medium)
run(reader.write_block(1, 0xCAFEBABE))
result = run(reader.read_block(1))
assert result["data"] == 0xCAFEBABE

View File

@@ -0,0 +1,251 @@
import pytest
from pm3py.sim.mcu_protocol import (
cobs_encode, cobs_decode,
MsgType, build_frame, parse_frame,
StreamDeframer,
)
class TestCobsCodec:
def test_encode_empty(self):
assert cobs_encode(b"") == b"\x01"
def test_encode_no_zeros(self):
assert cobs_encode(b"\x01\x02\x03") == b"\x04\x01\x02\x03"
def test_encode_single_zero(self):
assert cobs_encode(b"\x00") == b"\x01\x01"
def test_encode_zeros_in_data(self):
assert cobs_encode(b"\x01\x00\x02") == b"\x02\x01\x02\x02"
def test_decode_roundtrip(self):
for data in [b"", b"\x00", b"\x01\x02\x03", b"\x00\x00\x00",
b"hello world", bytes(range(256))]:
assert cobs_decode(cobs_encode(data)) == data
def test_decode_empty_raises(self):
with pytest.raises(ValueError):
cobs_decode(b"")
def test_decode_invalid_raises(self):
with pytest.raises(ValueError):
cobs_decode(b"\xFF")
class TestMessageFraming:
def test_build_frame_no_payload(self):
frame = build_frame(MsgType.RESET)
assert frame[-1] == 0x00
decoded = cobs_decode(frame[:-1])
assert decoded == bytes([0xF0])
def test_build_frame_with_payload(self):
frame = build_frame(MsgType.SET_ED, bytes([0x01]))
decoded = cobs_decode(frame[:-1])
assert decoded == bytes([0x20, 0x01])
def test_parse_frame_i2c_write(self):
raw = bytes([MsgType.I2C_WRITE, 0x20, 0x00, 0xDE, 0xAD])
msg_type, payload = parse_frame(raw)
assert msg_type == MsgType.I2C_WRITE
assert payload == bytes([0x20, 0x00, 0xDE, 0xAD])
def test_parse_frame_mcu_ready(self):
raw = bytes([MsgType.MCU_READY, 0x01, 0x03])
msg_type, payload = parse_frame(raw)
assert msg_type == MsgType.MCU_READY
assert payload == bytes([0x01, 0x03])
def test_parse_frame_empty_payload(self):
raw = bytes([MsgType.I2C_STOP])
msg_type, payload = parse_frame(raw)
assert msg_type == MsgType.I2C_STOP
assert payload == b""
def test_parse_frame_too_short(self):
with pytest.raises(ValueError):
parse_frame(b"")
class TestStreamDeframer:
def test_single_complete_frame(self):
df = StreamDeframer()
frame = build_frame(MsgType.MCU_READY, bytes([0x01, 0x00]))
msgs = df.feed(frame)
assert len(msgs) == 1
assert msgs[0][0] == MsgType.MCU_READY
assert msgs[0][1] == bytes([0x01, 0x00])
def test_partial_then_complete(self):
df = StreamDeframer()
frame = build_frame(MsgType.I2C_WRITE, bytes([0x20, 0x00, 0xAB]))
mid = len(frame) // 2
msgs1 = df.feed(frame[:mid])
assert len(msgs1) == 0
msgs2 = df.feed(frame[mid:])
assert len(msgs2) == 1
assert msgs2[0][0] == MsgType.I2C_WRITE
def test_multiple_frames_in_one_chunk(self):
df = StreamDeframer()
f1 = build_frame(MsgType.I2C_WRITE, bytes([0x20, 0x00]))
f2 = build_frame(MsgType.I2C_STOP)
msgs = df.feed(f1 + f2)
assert len(msgs) == 2
assert msgs[0][0] == MsgType.I2C_WRITE
assert msgs[1][0] == MsgType.I2C_STOP
def test_corrupted_frame_skipped(self):
df = StreamDeframer()
garbage = bytes([0xFF, 0xFE, 0x00])
valid = build_frame(MsgType.MCU_READY, bytes([0x01, 0x00]))
msgs = df.feed(garbage + valid)
assert len(msgs) == 1
assert msgs[0][0] == MsgType.MCU_READY
def test_empty_feed(self):
df = StreamDeframer()
msgs = df.feed(b"")
assert len(msgs) == 0
import time
from pm3py.sim.mcu_bridge import McuBridge
class MockSerial:
"""Mock serial port for testing McuBridge."""
def __init__(self):
self._rx_buf = bytearray()
self._tx_buf = bytearray()
self.is_open = True
self.timeout = 0.1
def read(self, size=1):
if not self._rx_buf:
time.sleep(0.01)
return b""
data = bytes(self._rx_buf[:size])
self._rx_buf = self._rx_buf[size:]
return data
def write(self, data):
self._tx_buf.extend(data)
return len(data)
def inject(self, data: bytes):
self._rx_buf.extend(data)
def get_sent(self) -> bytes:
data = bytes(self._tx_buf)
self._tx_buf.clear()
return data
def close(self):
self.is_open = False
class TestMcuBridge:
def test_connect_receives_mcu_ready(self):
mock = MockSerial()
bridge = McuBridge(port=mock)
ready_events = []
bridge.on_ready = lambda ver, caps: ready_events.append((ver, caps))
bridge.start()
mock.inject(build_frame(MsgType.MCU_READY, bytes([0x01, 0x03])))
time.sleep(0.15)
bridge.stop()
assert len(ready_events) == 1
assert ready_events[0] == (0x01, 0x03)
def test_i2c_write_callback(self):
mock = MockSerial()
bridge = McuBridge(port=mock)
writes = []
bridge.on_i2c_write = lambda addr, data: writes.append((addr, data))
bridge.start()
mock.inject(build_frame(MsgType.I2C_WRITE, bytes([0x20, 0x00, 0xDE, 0xAD])))
time.sleep(0.15)
bridge.stop()
assert len(writes) == 1
assert writes[0] == (0x2000, bytes([0xDE, 0xAD]))
def test_i2c_read_req_callback(self):
mock = MockSerial()
bridge = McuBridge(port=mock)
reads = []
bridge.on_i2c_read_req = lambda addr, length: reads.append((addr, length))
bridge.start()
mock.inject(build_frame(MsgType.I2C_READ_REQ, bytes([0x20, 0x00, 0x04])))
time.sleep(0.15)
bridge.stop()
assert len(reads) == 1
assert reads[0] == (0x2000, 4)
def test_send_set_ed(self):
mock = MockSerial()
bridge = McuBridge(port=mock)
bridge.start()
bridge.send_set_ed(True)
time.sleep(0.05)
bridge.stop()
sent = mock.get_sent()
assert len(sent) > 0
assert sent[-1] == 0x00
msg_type, payload = parse_frame(cobs_decode(sent[:-1]))
assert msg_type == MsgType.SET_ED
assert payload == bytes([0x01])
def test_send_set_eh_voltage(self):
mock = MockSerial()
bridge = McuBridge(port=mock)
bridge.start()
bridge.send_set_eh_voltage(2400)
time.sleep(0.05)
bridge.stop()
sent = mock.get_sent()
assert sent[-1] == 0x00
msg_type, payload = parse_frame(cobs_decode(sent[:-1]))
assert msg_type == MsgType.SET_EH_VOLTAGE
assert payload == bytes([0x09, 0x60])
def test_send_write_sram(self):
mock = MockSerial()
bridge = McuBridge(port=mock)
bridge.start()
bridge.send_write_sram(0x10, bytes([0xAA, 0xBB, 0xCC]))
time.sleep(0.05)
bridge.stop()
sent = mock.get_sent()
assert sent[-1] == 0x00
msg_type, payload = parse_frame(cobs_decode(sent[:-1]))
assert msg_type == MsgType.WRITE_SRAM
assert payload == bytes([0x10, 0xAA, 0xBB, 0xCC])
def test_send_i2c_read_response(self):
mock = MockSerial()
bridge = McuBridge(port=mock)
bridge.start()
bridge.send_i2c_read_response(bytes([0x01, 0x02, 0x03, 0x04]))
time.sleep(0.05)
bridge.stop()
sent = mock.get_sent()
assert sent[-1] == 0x00
msg_type, payload = parse_frame(cobs_decode(sent[:-1]))
assert msg_type == MsgType.I2C_READ_RESPONSE
assert payload == bytes([0x01, 0x02, 0x03, 0x04])
def test_stop_is_idempotent(self):
mock = MockSerial()
bridge = McuBridge(port=mock)
bridge.start()
bridge.stop()
bridge.stop() # should not raise
class TestMcuBridgeOpen:
def test_open_creates_bridge(self):
from pm3py.sim import McuBridge as ImportedBridge
assert ImportedBridge is McuBridge
assert hasattr(McuBridge, 'open')

227
tests/test_sim_medium.py Normal file
View File

@@ -0,0 +1,227 @@
"""Tests for pm3py.sim.medium — Medium ABC and SoftwareMedium."""
import asyncio
import pytest
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import SoftwareMedium
from pm3py.sim.transponder import Transponder
class StubTransponder(Transponder):
"""Minimal transponder that echoes back a fixed response."""
def __init__(self, response: RFFrame | None = None):
super().__init__()
self._response = response
self._state = "IDLE"
self._powered = False
self._received: list[RFFrame] = []
async def power_on(self) -> None:
self._powered = True
self._state = "READY"
async def power_off(self) -> None:
self._powered = False
self._state = "OFF"
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
self._received.append(frame)
return self._response
@property
def state(self) -> str:
return self._state
class TestSoftwareMediumAttachDetach:
"""Test transponder attachment and detachment."""
def test_attach_returns_unique_ids(self):
medium = SoftwareMedium()
t1 = StubTransponder()
t2 = StubTransponder()
id1 = asyncio.get_event_loop().run_until_complete(medium.attach(t1))
id2 = asyncio.get_event_loop().run_until_complete(medium.attach(t2))
assert id1 != id2
def test_attach_powers_on_transponder(self):
medium = SoftwareMedium()
t = StubTransponder()
assert not t._powered
asyncio.get_event_loop().run_until_complete(medium.attach(t))
assert t._powered
def test_detach_powers_off_transponder(self):
medium = SoftwareMedium()
t = StubTransponder()
loop = asyncio.get_event_loop()
tid = loop.run_until_complete(medium.attach(t))
loop.run_until_complete(medium.detach(tid))
assert not t._powered
def test_detach_nonexistent_id_raises(self):
medium = SoftwareMedium()
with pytest.raises(KeyError):
asyncio.get_event_loop().run_until_complete(medium.detach(999))
class TestSoftwareMediumSingleTransponder:
"""Test basic transmit/receive with one transponder."""
def test_reader_transmit_delivers_frame_to_transponder(self):
medium = SoftwareMedium()
t = StubTransponder(response=RFFrame.from_hex("AABB"))
loop = asyncio.get_event_loop()
loop.run_until_complete(medium.attach(t))
cmd = RFFrame.from_hex("26") # REQA
loop.run_until_complete(medium.transmit_reader(cmd))
assert len(t._received) == 1
assert t._received[0].data == b"\x26"
def test_reader_receives_transponder_response(self):
medium = SoftwareMedium()
response = RFFrame.from_hex("4400")
t = StubTransponder(response=response)
loop = asyncio.get_event_loop()
loop.run_until_complete(medium.attach(t))
cmd = RFFrame.from_hex("26")
loop.run_until_complete(medium.transmit_reader(cmd))
result = loop.run_until_complete(medium.receive_reader())
assert result is not None
assert result.data == b"\x44\x00"
def test_no_transponders_returns_none(self):
medium = SoftwareMedium()
loop = asyncio.get_event_loop()
cmd = RFFrame.from_hex("26")
loop.run_until_complete(medium.transmit_reader(cmd))
result = loop.run_until_complete(medium.receive_reader())
assert result is None
def test_transponder_returning_none_means_no_response(self):
medium = SoftwareMedium()
t = StubTransponder(response=None) # stays quiet
loop = asyncio.get_event_loop()
loop.run_until_complete(medium.attach(t))
cmd = RFFrame.from_hex("26")
loop.run_until_complete(medium.transmit_reader(cmd))
result = loop.run_until_complete(medium.receive_reader())
assert result is None
class TestSoftwareMediumCollision:
"""Test multi-transponder collision detection."""
def test_two_identical_responses_no_collision(self):
medium = SoftwareMedium()
t1 = StubTransponder(response=RFFrame.from_hex("A5"))
t2 = StubTransponder(response=RFFrame.from_hex("A5"))
loop = asyncio.get_event_loop()
loop.run_until_complete(medium.attach(t1))
loop.run_until_complete(medium.attach(t2))
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
result = loop.run_until_complete(medium.receive_reader())
assert result is not None
assert result.data == b"\xA5"
assert not result.has_collision
def test_two_different_responses_detect_collision(self):
medium = SoftwareMedium()
t1 = StubTransponder(response=RFFrame.from_hex("A0")) # 10100000
t2 = StubTransponder(response=RFFrame.from_hex("A5")) # 10100101
loop = asyncio.get_event_loop()
loop.run_until_complete(medium.attach(t1))
loop.run_until_complete(medium.attach(t2))
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
result = loop.run_until_complete(medium.receive_reader())
assert result is not None
assert result.has_collision
assert len(result.collision_positions) == 2
def test_three_transponders_one_quiet(self):
"""One transponder stays quiet — only two respond."""
medium = SoftwareMedium()
t1 = StubTransponder(response=RFFrame.from_hex("AB"))
t2 = StubTransponder(response=None) # quiet
t3 = StubTransponder(response=RFFrame.from_hex("AB"))
loop = asyncio.get_event_loop()
loop.run_until_complete(medium.attach(t1))
loop.run_until_complete(medium.attach(t2))
loop.run_until_complete(medium.attach(t3))
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
result = loop.run_until_complete(medium.receive_reader())
assert result is not None
assert result.data == b"\xAB"
assert not result.has_collision
def test_collision_bits_default_to_one(self):
medium = SoftwareMedium()
t1 = StubTransponder(response=RFFrame.from_bytes(b"\x00"))
t2 = StubTransponder(response=RFFrame.from_bytes(b"\xFF"))
loop = asyncio.get_event_loop()
loop.run_until_complete(medium.attach(t1))
loop.run_until_complete(medium.attach(t2))
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
result = loop.run_until_complete(medium.receive_reader())
# All 8 bits collide, all should be 1
assert result.data == b"\xFF"
class TestSoftwareMediumDynamicInjection:
"""Test attach/detach mid-protocol."""
def test_detach_mid_protocol(self):
medium = SoftwareMedium()
t1 = StubTransponder(response=RFFrame.from_hex("AA"))
t2 = StubTransponder(response=RFFrame.from_hex("BB"))
loop = asyncio.get_event_loop()
loop.run_until_complete(medium.attach(t1))
tid2 = loop.run_until_complete(medium.attach(t2))
# First round: collision
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
r1 = loop.run_until_complete(medium.receive_reader())
assert r1.has_collision
# Detach t2
loop.run_until_complete(medium.detach(tid2))
# Second round: no collision
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
r2 = loop.run_until_complete(medium.receive_reader())
assert not r2.has_collision
assert r2.data == b"\xAA"
def test_attach_mid_protocol(self):
medium = SoftwareMedium()
t1 = StubTransponder(response=RFFrame.from_hex("AA"))
loop = asyncio.get_event_loop()
loop.run_until_complete(medium.attach(t1))
# First round: single tag
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
r1 = loop.run_until_complete(medium.receive_reader())
assert not r1.has_collision
# Inject new tag
t2 = StubTransponder(response=RFFrame.from_hex("BB"))
loop.run_until_complete(medium.attach(t2))
# Second round: collision
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
r2 = loop.run_until_complete(medium.receive_reader())
assert r2.has_collision

210
tests/test_sim_memory.py Normal file
View File

@@ -0,0 +1,210 @@
"""Tests for DirtyByteArray, MemoryRegion, BlockAccess, Transponder regions."""
import pytest
from pm3py.sim.memory import DirtyByteArray, MemoryRegion, BlockAccess
from pm3py.sim.transponder import Transponder
from pm3py.sim.frame import RFFrame
class TestDirtyByteArray:
def test_starts_clean(self):
d = DirtyByteArray(16)
assert not d.dirty
def test_setitem_marks_dirty(self):
d = DirtyByteArray(16)
d[0] = 0xFF
assert d.dirty
def test_slice_setitem_marks_dirty(self):
d = DirtyByteArray(16)
d[0:4] = b"\xDE\xAD\xBE\xEF"
assert d.dirty
def test_clear_dirty(self):
d = DirtyByteArray(16)
d[0] = 0xFF
d.clear_dirty()
assert not d.dirty
def test_extend_marks_dirty(self):
d = DirtyByteArray(b"\x00")
d.extend(b"\xFF")
assert d.dirty
def test_from_bytes(self):
d = DirtyByteArray(b"\x01\x02\x03")
assert len(d) == 3
assert not d.dirty
def test_callback_on_dirty(self):
called = []
d = DirtyByteArray(16)
d.on_dirty = lambda: called.append(True)
d[0] = 0xFF
assert len(called) == 1
class TestBlockAccess:
def test_defaults_open(self):
a = BlockAccess()
assert a.read == "open"
assert a.write == "open"
assert a.read_key is None
def test_password_protected(self):
a = BlockAccess(read="password", read_key=1)
assert a.read == "password"
assert a.read_key == 1
class TestMemoryRegion:
def test_construction(self):
r = MemoryRegion(name="user", data=DirtyByteArray(112), block_size=4)
assert r.name == "user"
assert len(r.data) == 112
assert r.block_size == 4
def test_eml_offset_default_negative(self):
r = MemoryRegion(name="config", data=DirtyByteArray(8))
assert r.eml_offset == -1
def test_num_blocks(self):
r = MemoryRegion(name="user", data=DirtyByteArray(112), block_size=4)
assert r.num_blocks == 28
def test_access_for_block_default(self):
r = MemoryRegion(name="user", data=DirtyByteArray(16), block_size=4)
assert r.access_for_block(0).read == "open"
def test_access_for_block_with_map(self):
r = MemoryRegion(name="user", data=DirtyByteArray(16), block_size=4,
access_map=[
BlockAccess(),
BlockAccess(),
BlockAccess(read="password", read_key=1),
BlockAccess(read="password", read_key=1),
])
assert r.access_for_block(0).read == "open"
assert r.access_for_block(2).read == "password"
def test_dirty_propagates_from_data(self):
r = MemoryRegion(name="user", data=DirtyByteArray(16), block_size=4)
assert not r.data.dirty
r.data[0:4] = b"\xDE\xAD\xBE\xEF"
assert r.data.dirty
class StubTransponder(Transponder):
def __init__(self):
super().__init__()
self.regions["user"] = MemoryRegion(
name="user", data=DirtyByteArray(16), block_size=4, eml_offset=100)
self.regions["config"] = MemoryRegion(
name="config", data=DirtyByteArray(8), block_size=0, eml_offset=-1)
async def power_on(self): pass
async def power_off(self): pass
async def handle_frame(self, frame): return None
@property
def state(self): return "TEST"
class TestTransponderRegions:
def test_has_regions_dict(self):
t = StubTransponder()
assert "user" in t.regions
assert "config" in t.regions
def test_authenticate(self):
t = StubTransponder()
assert len(t._authenticated) == 0
t.authenticate(1)
assert 1 in t._authenticated
assert t._access_dirty
def test_deauthenticate(self):
t = StubTransponder()
t.authenticate(1)
t._access_dirty = False
t.deauthenticate(1)
assert 1 not in t._authenticated
assert t._access_dirty
def test_deauthenticate_all(self):
t = StubTransponder()
t.authenticate(1)
t.authenticate(2)
t.deauthenticate()
assert len(t._authenticated) == 0
class TestTag15693Regions:
def test_has_user_region(self):
from pm3py.sim.iso15693 import Tag15693
tag = Tag15693(uid=bytes(8), block_size=4, num_blocks=28)
assert "user" in tag.regions
assert tag.regions["user"].block_size == 4
assert tag.regions["user"].eml_offset == 175
def test_memory_property_aliases_region(self):
from pm3py.sim.iso15693 import Tag15693
tag = Tag15693(uid=bytes(8), block_size=4, num_blocks=28)
tag._memory[0:4] = b"\xDE\xAD\xBE\xEF"
assert tag.regions["user"].data[0:4] == b"\xDE\xAD\xBE\xEF"
assert tag.regions["user"].data.dirty
class TestIcodeSlix2AccessMap:
def test_protection_pointer_sets_access_map(self):
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), protection_pointer=4,
read_password=0xAABBCCDD)
user = tag.regions["user"]
assert user.access_for_block(0).read == "open"
assert user.access_for_block(3).read == "open"
assert user.access_for_block(4).read == "password"
assert user.access_for_block(4).read_key == 1
def test_no_protection_pointer_all_open(self):
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
user = tag.regions["user"]
assert user.access_for_block(0).read == "open"
# access_map exists for block 79 counter, but all reads are open
assert user.access_for_block(78).read == "open"
assert user.access_for_block(79).write_mode == "counter"
class TestCompileFromRegions:
def test_open_blocks_have_data_entries(self):
from pm3py.sim.iso15693 import Tag15693
from pm3py.sim.table_compiler import TableCompiler
tag = Tag15693(uid=bytes(8), block_size=4, num_blocks=4)
tag._memory[0:4] = b"\xDE\xAD\xBE\xEF"
table = TableCompiler.compile_from_regions(tag)
entry = table.lookup(bytes([0x02, 0x20, 0x00]))
assert entry is not None
assert entry.response[1:5] == b"\xDE\xAD\xBE\xEF"
def test_protected_blocks_without_auth(self):
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
from pm3py.sim.table_compiler import TableCompiler
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), protection_pointer=2,
read_password=0xAABBCCDD, block_size=4, num_blocks=4)
table = TableCompiler.compile_from_regions(tag)
# Block 0 (open) should have entry
assert table.lookup(bytes([0x02, 0x20, 0x00])) is not None
# Block 2 (protected) should have no entry (relay)
assert table.lookup(bytes([0x02, 0x20, 0x02])) is None
def test_protected_blocks_with_auth(self):
from pm3py.sim.icode_slix2 import IcodeSlix2Tag, PWD_READ
from pm3py.sim.table_compiler import TableCompiler
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), protection_pointer=2,
read_password=0xAABBCCDD, block_size=4, num_blocks=4)
tag.authenticate(PWD_READ)
table = TableCompiler.compile_from_regions(tag)
# Block 2 now readable
entry = table.lookup(bytes([0x02, 0x20, 0x02]))
assert entry is not None
assert entry.response[0] == 0x00 # no error

313
tests/test_sim_mifare.py Normal file
View File

@@ -0,0 +1,313 @@
"""Tests for pm3py.sim.crypto1 and pm3py.sim.mifare — Crypto-1 cipher and MIFARE Classic."""
import asyncio
import struct
import pytest
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import SoftwareMedium
from pm3py.sim.crypto1 import Crypto1
from pm3py.sim.mifare import MifareClassicTag, MifareClassicReader
from pm3py.sim.iso14443a import Reader14443A, REQA, CL1, NVB_SELECT, _compute_bcc
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# Crypto-1 LFSR
# ---------------------------------------------------------------------------
class TestCrypto1LFSR:
"""Test Crypto-1 cipher against known test vectors."""
def test_key_load(self):
"""Verify LFSR state after loading a key."""
c = Crypto1(b"\xFF\xFF\xFF\xFF\xFF\xFF")
# After loading all-ones key, LFSR should be non-zero
assert c._lfsr != 0
def test_generate_keystream_is_deterministic(self):
"""Same key produces same keystream."""
c1 = Crypto1(b"\xA0\xA1\xA2\xA3\xA4\xA5")
c2 = Crypto1(b"\xA0\xA1\xA2\xA3\xA4\xA5")
ks1 = [c1.generate_bit() for _ in range(32)]
ks2 = [c2.generate_bit() for _ in range(32)]
assert ks1 == ks2
def test_different_keys_different_keystream(self):
c1 = Crypto1(b"\x00\x00\x00\x00\x00\x00")
c2 = Crypto1(b"\xFF\xFF\xFF\xFF\xFF\xFF")
ks1 = [c1.generate_bit() for _ in range(32)]
ks2 = [c2.generate_bit() for _ in range(32)]
assert ks1 != ks2
def test_encrypt_decrypt_roundtrip(self):
"""Encrypting then decrypting with same state should return original."""
key = b"\xA0\xA1\xA2\xA3\xA4\xA5"
plaintext = b"\xDE\xAD\xBE\xEF"
c_enc = Crypto1(key)
encrypted = c_enc.encrypt_bytes(plaintext)
c_dec = Crypto1(key)
decrypted = c_dec.encrypt_bytes(encrypted) # XOR cipher: enc == dec
assert decrypted == plaintext
def test_known_vector_lfsr_feedback(self):
"""Verify the LFSR feedback polynomial is correct.
The Crypto-1 LFSR polynomial is:
x^48 + x^43 + x^39 + x^38 + x^36 + x^34 + x^33 + x^31 + x^29 +
x^24 + x^23 + x^21 + x^19 + x^13 + x^9 + x^7 + x^6 + x^5 + 1
"""
c = Crypto1(b"\x00\x00\x00\x00\x00\x01")
# Just verify it doesn't crash and produces deterministic output
bits = [c.generate_bit() for _ in range(48)]
assert len(bits) == 48
class TestCrypto1Auth:
"""Test Crypto-1 mutual authentication protocol."""
def test_tag_nonce_generation(self):
"""Tag generates a 4-byte nonce."""
c = Crypto1(b"\xFF\xFF\xFF\xFF\xFF\xFF")
nt = c.generate_nonce()
assert len(nt) == 4
def test_auth_mutual_success(self):
"""Full mutual authentication between tag and reader Crypto-1 instances."""
key = b"\xFF\xFF\xFF\xFF\xFF\xFF"
uid = b"\x01\x02\x03\x04"
# Tag side: generate nonce
tag_crypto = Crypto1(key)
nt = tag_crypto.generate_nonce()
# Initialize both sides with uid ^ nt
uid_int = struct.unpack(">I", uid)[0]
nt_int = struct.unpack(">I", nt)[0]
tag_crypto = Crypto1(key)
tag_crypto.init_auth(uid_int, nt_int)
reader_crypto = Crypto1(key)
reader_crypto.init_auth(uid_int, nt_int)
# Reader generates nr (random) and computes ar = suc(nt, 64)
nr = b"\xAB\xCD\xEF\x01"
# Encrypt nr with reader's keystream
nr_enc = reader_crypto.encrypt_bytes(nr)
# Reader computes ar (encrypted successor of nt)
ar = reader_crypto.encrypt_bytes(struct.pack(">I", _suc(nt_int, 64)))
# Tag decrypts nr
nr_dec = tag_crypto.encrypt_bytes(nr_enc)
assert nr_dec == nr
# Tag decrypts ar and verifies
ar_dec_int = struct.unpack(">I", tag_crypto.encrypt_bytes(ar))[0]
assert ar_dec_int == _suc(nt_int, 64)
# Tag sends at (encrypted successor of nt, 96)
at = tag_crypto.encrypt_bytes(struct.pack(">I", _suc(nt_int, 96)))
# Reader verifies at
at_dec_int = struct.unpack(">I", reader_crypto.encrypt_bytes(at))[0]
assert at_dec_int == _suc(nt_int, 96)
def _suc(nt: int, n: int) -> int:
"""Compute successor of nt by n LFSR clocks (simplified for test)."""
# In real Crypto-1, suc is the LFSR state after n clocks
# For testing, we use a simple PRNG-like computation
val = nt
for _ in range(n):
bit = ((val >> 31) ^ (val >> 20) ^ (val >> 15) ^ (val >> 0)) & 1
val = ((val << 1) | bit) & 0xFFFFFFFF
return val
# ---------------------------------------------------------------------------
# MifareClassicTag — transponder model
# ---------------------------------------------------------------------------
class TestMifareClassicTagBasics:
"""Test MifareClassicTag construction and 14443-A compliance."""
def test_1k_atqa_sak(self):
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
run(tag.power_on())
resp = run(tag.handle_frame(RFFrame.from_hex("26")))
assert resp.data == b"\x04\x00" # ATQA for 1K
# SELECT
uid = b"\x01\x02\x03\x04"
bcc = _compute_bcc(uid)
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
assert resp.data[0] == 0x08 # SAK for 1K
def test_4k_atqa_sak(self):
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="4k")
run(tag.power_on())
resp = run(tag.handle_frame(RFFrame.from_hex("26")))
assert resp.data == b"\x02\x00" # ATQA for 4K
uid = b"\x01\x02\x03\x04"
bcc = _compute_bcc(uid)
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
assert resp.data[0] == 0x18 # SAK for 4K
def test_rejects_rats(self):
"""MIFARE Classic is Part 3 only."""
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
uid = b"\x01\x02\x03\x04"
bcc = _compute_bcc(uid)
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
resp = run(tag.handle_frame(RFFrame.from_hex("E050")))
assert resp is None
def test_default_memory_all_zeros(self):
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
assert len(tag._data) == 1024 # 64 blocks * 16 bytes
assert tag._data[:16] == b"\x00" * 16
def test_4k_memory_size(self):
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="4k")
assert len(tag._data) == 4096 # 256 blocks * 16 bytes
def test_default_keys_are_ff(self):
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04")
for sector in range(16):
assert tag._keys_a[sector] == b"\xFF" * 6
assert tag._keys_b[sector] == b"\xFF" * 6
class TestMifareClassicTagAuth:
"""Test MIFARE Classic authentication."""
def test_auth_command_returns_tag_nonce(self):
"""AUTH command (0x60/0x61) should return a 4-byte encrypted tag nonce."""
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
uid = b"\x01\x02\x03\x04"
bcc = _compute_bcc(uid)
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
# AUTH_A for block 0
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x60\x00")))
assert resp is not None
assert len(resp.data) == 4 # tag nonce (nt)
def test_unauthenticated_read_rejected(self):
"""READ without authentication should be rejected."""
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
uid = b"\x01\x02\x03\x04"
bcc = _compute_bcc(uid)
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
# READ block 0 without auth
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x00")))
assert resp is None # rejected
class TestMifareClassicTagMemory:
"""Test MIFARE Classic memory layout."""
def test_1k_sector_block_mapping(self):
"""1K: sectors 0-15, 4 blocks each."""
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
assert tag.sector_for_block(0) == 0
assert tag.sector_for_block(3) == 0
assert tag.sector_for_block(4) == 1
assert tag.sector_for_block(63) == 15
def test_4k_sector_block_mapping(self):
"""4K: sectors 0-31 = 4 blocks, sectors 32-39 = 16 blocks."""
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="4k")
assert tag.sector_for_block(0) == 0
assert tag.sector_for_block(127) == 31 # last block of sector 31
assert tag.sector_for_block(128) == 32 # first block of sector 32
assert tag.sector_for_block(255) == 39 # last block of sector 39
def test_read_block_data(self):
"""Direct read of block data from memory."""
data = bytearray(1024)
data[0:16] = b"\xDE\xAD\xBE\xEF" + b"\x00" * 12
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k", data=data)
assert tag.read_block_raw(0) == b"\xDE\xAD\xBE\xEF" + b"\x00" * 12
def test_write_block_data(self):
"""Direct write of block data to memory."""
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
new_data = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10"
tag.write_block_raw(4, new_data)
assert tag.read_block_raw(4) == new_data
class TestMifareClassicReader:
"""Test MifareClassicReader with full auth + read/write."""
def test_read_block_with_default_key(self):
"""Reader authenticates and reads a block."""
medium = SoftwareMedium()
data = bytearray(1024)
data[16:32] = b"\xCA\xFE\xBA\xBE" + b"\x00" * 12
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k", data=data)
run(medium.attach(tag))
reader = MifareClassicReader(medium)
result = run(reader.read_block(
uid=b"\x01\x02\x03\x04",
block=1,
key=b"\xFF\xFF\xFF\xFF\xFF\xFF",
))
assert result["success"]
assert result["data"][:4] == b"\xCA\xFE\xBA\xBE"
def test_write_block_with_default_key(self):
"""Reader authenticates and writes a block."""
medium = SoftwareMedium()
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
run(medium.attach(tag))
reader = MifareClassicReader(medium)
new_data = b"\x01" * 16
result = run(reader.write_block(
uid=b"\x01\x02\x03\x04",
block=4,
data=new_data,
key=b"\xFF\xFF\xFF\xFF\xFF\xFF",
))
assert result["success"]
# Verify by reading back
result = run(reader.read_block(
uid=b"\x01\x02\x03\x04",
block=4,
key=b"\xFF\xFF\xFF\xFF\xFF\xFF",
))
assert result["data"] == new_data
def test_wrong_key_fails(self):
"""Reader with wrong key cannot authenticate."""
medium = SoftwareMedium()
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
run(medium.attach(tag))
reader = MifareClassicReader(medium)
result = run(reader.read_block(
uid=b"\x01\x02\x03\x04",
block=0,
key=b"\x00\x00\x00\x00\x00\x00", # wrong key
))
assert not result["success"]

189
tests/test_sim_ndef.py Normal file
View File

@@ -0,0 +1,189 @@
"""Tests for NDEF Type 2 and Type 4 tag models."""
import asyncio
import pytest
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import SoftwareMedium
from pm3py.sim.iso14443a import Reader14443A, _compute_bcc
from pm3py.sim.ndef import NfcType2Tag, NfcType4Tag
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# NFC Type 2 Tag (extends Tag14443A_3)
# ---------------------------------------------------------------------------
class TestNfcType2Tag:
def test_atqa_sak(self):
tag = NfcType2Tag(uid=b"\x04\x01\x02\x03\x04\x05\x06", ndef_message=b"")
run(tag.power_on())
resp = run(tag.handle_frame(RFFrame.from_hex("26")))
assert resp.data == b"\x44\x00" # NTAG ATQA
# SELECT (7-byte UID needs cascade)
uid = b"\x04\x01\x02\x03\x04\x05\x06"
ct_uid = b"\x88" + uid[0:3]
bcc1 = _compute_bcc(ct_uid)
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1]))))
cl2_uid = uid[3:7]
bcc2 = _compute_bcc(cl2_uid)
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2_uid + bytes([bcc2]))))
assert resp.data[0] & 0x20 == 0 # SAK bit 5 = 0 (no ISO-DEP)
def test_read_returns_4_pages(self):
"""READ command returns 16 bytes (4 pages of 4 bytes)."""
tag = NfcType2Tag(uid=b"\x04\x01\x02\x03\x04\x05\x06",
ndef_message=b"\xD1\x01\x04\x54\x02enHi")
run(tag.power_on())
self._select(tag)
# READ page 0
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x00")))
assert resp is not None
assert len(resp.data) == 16 # 4 pages
def test_cc_block_at_page_3(self):
"""Page 3 is the capability container."""
tag = NfcType2Tag(uid=b"\x04\x01\x02\x03\x04\x05\x06",
ndef_message=b"\xD1\x01\x04\x54\x02enHi",
total_pages=45) # NTAG213-like
run(tag.power_on())
self._select(tag)
# READ page 3 (CC is in pages 3)
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x03")))
cc = resp.data[0:4]
assert cc[0] == 0xE1 # NDEF magic
assert cc[1] == 0x10 # version 1.0
assert cc[2] > 0 # size (in 8-byte units)
assert cc[3] == 0x00 # read/write access
def test_ndef_data_in_pages_4_plus(self):
"""NDEF message stored starting at page 4 as TLV."""
msg = b"\xD1\x01\x04\x54\x02enHi"
tag = NfcType2Tag(uid=b"\x04\x01\x02\x03\x04\x05\x06", ndef_message=msg)
run(tag.power_on())
self._select(tag)
# READ page 4
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x04")))
# TLV: type=0x03 (NDEF), length, message...
assert resp.data[0] == 0x03 # NDEF TLV type
assert resp.data[1] == len(msg)
def test_write_page(self):
"""WRITE command writes 4 bytes to a page."""
tag = NfcType2Tag(uid=b"\x04\x01\x02\x03\x04\x05\x06", ndef_message=b"")
run(tag.power_on())
self._select(tag)
# WRITE page 4
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x04\xDE\xAD\xBE\xEF")))
assert resp is not None # ACK
# Read back
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x04")))
assert resp.data[0:4] == b"\xDE\xAD\xBE\xEF"
def _select(self, tag):
uid = tag._uid
run(tag.handle_frame(RFFrame.from_hex("26")))
ct_uid = b"\x88" + uid[0:3]
bcc1 = _compute_bcc(ct_uid)
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1]))))
cl2_uid = uid[3:7]
bcc2 = _compute_bcc(cl2_uid)
run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2_uid + bytes([bcc2]))))
# ---------------------------------------------------------------------------
# NFC Type 4 Tag (extends Tag14443A_4)
# ---------------------------------------------------------------------------
class TestNfcType4Tag:
def test_sak_indicates_isodep(self):
tag = NfcType4Tag(uid=b"\x04\x01\x02\x03", ndef_message=b"")
run(tag.power_on())
run(tag.handle_frame(RFFrame.from_hex("26")))
uid = b"\x04\x01\x02\x03"
bcc = _compute_bcc(uid)
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
assert resp.data[0] & 0x20 != 0 # SAK bit 5 = 1
def test_rats_returns_ats(self):
tag = NfcType4Tag(uid=b"\x04\x01\x02\x03", ndef_message=b"")
run(tag.power_on())
self._select_and_rats(tag)
assert tag.state == "PROTOCOL"
def test_select_ndef_application(self):
"""SELECT NDEF application by AID D2760000850101."""
tag = NfcType4Tag(uid=b"\x04\x01\x02\x03",
ndef_message=b"\xD1\x01\x04\x54\x02enHi")
run(tag.power_on())
self._select_and_rats(tag)
# SELECT AID via I-block
select_apdu = b"\x00\xA4\x04\x00\x07\xD2\x76\x00\x00\x85\x01\x01"
iblock = b"\x02" + select_apdu
resp = run(tag.handle_frame(RFFrame.from_bytes(iblock)))
assert resp is not None
# Should get 9000 (success)
assert resp.data[-2:] == b"\x90\x00" or resp.data[1:3] == b"\x90\x00"
def test_select_cc_file(self):
"""SELECT CC file (E103) then READ BINARY."""
tag = NfcType4Tag(uid=b"\x04\x01\x02\x03",
ndef_message=b"\xD1\x01\x04\x54\x02enHi")
run(tag.power_on())
self._select_and_rats(tag)
# Select NDEF app
self._send_apdu(tag, b"\x00\xA4\x04\x00\x07\xD2\x76\x00\x00\x85\x01\x01")
# Select CC file (E103)
resp = self._send_apdu(tag, b"\x00\xA4\x00\x0C\x02\xE1\x03")
assert resp is not None
# READ BINARY offset=0, length=15
resp = self._send_apdu(tag, b"\x00\xB0\x00\x00\x0F")
assert resp is not None
# CC file starts with CCLEN(2) + version + ...
payload = resp[:-2] # strip SW
assert len(payload) >= 7
def test_read_ndef_file(self):
"""SELECT NDEF file (E104) then READ BINARY to get NDEF message."""
msg = b"\xD1\x01\x04\x54\x02enHi"
tag = NfcType4Tag(uid=b"\x04\x01\x02\x03", ndef_message=msg)
run(tag.power_on())
self._select_and_rats(tag)
# Select NDEF app
self._send_apdu(tag, b"\x00\xA4\x04\x00\x07\xD2\x76\x00\x00\x85\x01\x01")
# Select NDEF file (E104)
self._send_apdu(tag, b"\x00\xA4\x00\x0C\x02\xE1\x04")
# READ BINARY — first 2 bytes are NDEF length
resp = self._send_apdu(tag, b"\x00\xB0\x00\x00\x20")
payload = resp[:-2]
nlen = (payload[0] << 8) | payload[1]
assert nlen == len(msg)
assert payload[2:2 + nlen] == msg
def _select_and_rats(self, tag):
run(tag.handle_frame(RFFrame.from_hex("26")))
uid = tag._uid[:4]
bcc = _compute_bcc(uid)
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
run(tag.handle_frame(RFFrame.from_hex("E050")))
def _send_apdu(self, tag, apdu: bytes) -> bytes:
"""Send APDU via I-block, return response payload (including SW)."""
pcb = 0x02
resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([pcb]) + apdu)))
if resp is None:
return b""
return resp.data[1:] # strip PCB

1688
tests/test_sim_ntag5.py Normal file

File diff suppressed because it is too large Load Diff

1016
tests/test_sim_nxp_icode.py Normal file

File diff suppressed because it is too large Load Diff

149
tests/test_sim_pm3medium.py Normal file
View File

@@ -0,0 +1,149 @@
"""Tests for PM3Medium — hardware-backed Medium using Proxmark3."""
import asyncio
import struct
import pytest
from unittest.mock import AsyncMock, MagicMock
from pm3py.sim.frame import RFFrame
from pm3py.sim.pm3medium import PM3ReaderMedium
from pm3py.core.transport import PM3Response
from pm3py.core.protocol import Cmd, PM3Status
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
def _mock_transport():
t = AsyncMock()
return t
def _14a_scan_response(uid=b"\x01\x02\x03\x04", atqa=b"\x04\x00", sak=0x08):
"""Build a mock iso14a_card_select_t response."""
# iso14a_card_select_t: uid[10] + uidlen[1] + atqa[2] + sak[1] + ats_len[1] + ats[256]
data = uid + b"\x00" * (10 - len(uid))
data += bytes([len(uid)])
data += atqa
data += bytes([sak])
data += bytes([0]) # ats_len
data += b"\x00" * 256 # ats
# Select status comes from oldarg[0]: 1=OK+ATS, 2=OK no ATS
return PM3Response(cmd=Cmd.HF_ISO14443A_READER, status=PM3Status.SUCCESS,
reason=0, ng=False, data=data,
oldarg=(2, 0, 0))
def _14a_raw_response(data=b"\x90\x00"):
"""Build a mock raw APDU response."""
return PM3Response(cmd=Cmd.HF_ISO14443A_READER, status=PM3Status.SUCCESS,
reason=0, ng=False, data=data,
oldarg=(len(data), 0, 0))
def _15693_response(data=b"\x00"):
"""Build a mock ISO 15693 response."""
return PM3Response(cmd=Cmd.HF_ISO15693_COMMAND, status=PM3Status.SUCCESS,
reason=0, ng=True, data=data, oldarg=None)
# ---------------------------------------------------------------------------
# PM3ReaderMedium — 14443-A reader
# ---------------------------------------------------------------------------
class TestPM3ReaderMedium14443A:
def test_transmit_receive_14a_scan(self):
t = _mock_transport()
t.send_mix.return_value = _14a_scan_response()
medium = PM3ReaderMedium(t, protocol="14443a")
# Transmit REQA (triggers scan)
run(medium.transmit_reader(RFFrame.from_hex("26")))
resp = run(medium.receive_reader())
assert resp is not None
assert t.send_mix.called
def test_transmit_receive_14a_raw(self):
t = _mock_transport()
t.send_mix.return_value = _14a_raw_response(b"\x90\x00")
medium = PM3ReaderMedium(t, protocol="14443a")
# Send a raw APDU
apdu = b"\x00\xA4\x04\x00"
run(medium.transmit_reader(RFFrame.from_bytes(apdu)))
resp = run(medium.receive_reader())
assert resp is not None
assert resp.data == b"\x90\x00"
def test_dropfield(self):
t = _mock_transport()
t.send_ng_no_response = AsyncMock()
medium = PM3ReaderMedium(t, protocol="14443a")
run(medium.dropfield())
t.send_ng_no_response.assert_called_once()
# ---------------------------------------------------------------------------
# PM3ReaderMedium — 15693 reader
# ---------------------------------------------------------------------------
class TestPM3ReaderMedium15693:
def test_transmit_receive_15693_inventory(self):
t = _mock_transport()
uid_lsb = b"\x03\x1B\x6A\x5C\x50\x01\x04\xE0"
resp_data = b"\x00\x00" + uid_lsb # flags + dsfid + uid
t.send_ng.return_value = _15693_response(resp_data)
medium = PM3ReaderMedium(t, protocol="15693")
inv = bytes([0x26, 0x01, 0x00])
run(medium.transmit_reader(RFFrame.from_bytes(inv)))
resp = run(medium.receive_reader())
assert resp is not None
assert len(resp.data) >= 10
def test_transmit_receive_15693_read_block(self):
t = _mock_transport()
block_data = b"\x00\xDE\xAD\xBE\xEF" # flags + data
t.send_ng.return_value = _15693_response(block_data)
medium = PM3ReaderMedium(t, protocol="15693")
read_cmd = bytes([0x22, 0x20]) + b"\x01" * 8 + bytes([0x00])
run(medium.transmit_reader(RFFrame.from_bytes(read_cmd)))
resp = run(medium.receive_reader())
assert resp is not None
assert resp.data[1:5] == b"\xDE\xAD\xBE\xEF"
def test_timeout_returns_none(self):
t = _mock_transport()
t.send_ng.side_effect = TimeoutError()
medium = PM3ReaderMedium(t, protocol="15693")
inv = bytes([0x26, 0x01, 0x00])
run(medium.transmit_reader(RFFrame.from_bytes(inv)))
resp = run(medium.receive_reader())
assert resp is None
# ---------------------------------------------------------------------------
# PM3ReaderMedium — attach/detach (not applicable for hardware)
# ---------------------------------------------------------------------------
class TestPM3ReaderMediumLimitations:
def test_attach_raises(self):
"""Hardware medium doesn't support attaching software transponders."""
t = _mock_transport()
medium = PM3ReaderMedium(t, protocol="14443a")
with pytest.raises(NotImplementedError):
run(medium.attach(None))
def test_detach_raises(self):
t = _mock_transport()
medium = PM3ReaderMedium(t, protocol="14443a")
with pytest.raises(NotImplementedError):
run(medium.detach(0))

214
tests/test_sim_reader.py Normal file
View File

@@ -0,0 +1,214 @@
"""Tests for pm3py.sim.reader — Reader ABC, ScriptedReader, InteractiveReader."""
import asyncio
import pytest
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import SoftwareMedium
from pm3py.sim.reader import Reader, ScriptedReader, InteractiveReader, ReaderStep, StepResult
from pm3py.sim.transponder import Transponder
class EchoTransponder(Transponder):
"""Returns the received frame back as response."""
async def power_on(self) -> None:
pass
async def power_off(self) -> None:
pass
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
return frame
@property
def state(self) -> str:
return "ECHO"
class FixedTransponder(Transponder):
"""Returns a fixed response to any frame."""
def __init__(self, response: RFFrame):
super().__init__()
self._response = response
async def power_on(self) -> None:
pass
async def power_off(self) -> None:
pass
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
return self._response
@property
def state(self) -> str:
return "FIXED"
class SimpleReader(Reader):
"""Minimal reader implementation for testing the base class."""
async def run(self) -> dict:
resp = await self.transceive(RFFrame.from_hex("26"))
return {"response": resp}
class TestReaderBase:
"""Test Reader ABC transceive."""
def test_transceive_sends_and_receives(self):
loop = asyncio.get_event_loop()
medium = SoftwareMedium()
loop.run_until_complete(medium.attach(EchoTransponder()))
reader = SimpleReader(medium)
result = loop.run_until_complete(reader.run())
assert result["response"] is not None
assert result["response"].data == b"\x26"
def test_transceive_no_transponder_returns_none(self):
loop = asyncio.get_event_loop()
medium = SoftwareMedium()
reader = SimpleReader(medium)
result = loop.run_until_complete(reader.run())
assert result["response"] is None
class TestScriptedReader:
"""Test scripted reader sequence execution."""
def test_run_script_all_pass(self):
loop = asyncio.get_event_loop()
medium = SoftwareMedium()
response = RFFrame.from_hex("4400")
loop.run_until_complete(medium.attach(FixedTransponder(response)))
steps = [
ReaderStep(
frame=RFFrame.from_hex("26"),
description="Send REQA",
check=lambda r: r is not None and r.data == b"\x44\x00",
),
ReaderStep(
frame=RFFrame.from_hex("9320"),
description="Send ANTICOL",
check=lambda r: r is not None,
),
]
scripted = ScriptedReader(medium)
results = loop.run_until_complete(scripted.run_script(steps))
assert len(results) == 2
assert all(r.passed for r in results)
def test_run_script_check_fails(self):
loop = asyncio.get_event_loop()
medium = SoftwareMedium()
response = RFFrame.from_hex("4400")
loop.run_until_complete(medium.attach(FixedTransponder(response)))
steps = [
ReaderStep(
frame=RFFrame.from_hex("26"),
description="Send REQA",
check=lambda r: r is not None and r.data == b"\xFF\xFF", # wrong
),
]
scripted = ScriptedReader(medium)
results = loop.run_until_complete(scripted.run_script(steps))
assert len(results) == 1
assert not results[0].passed
def test_stop_on_fail(self):
loop = asyncio.get_event_loop()
medium = SoftwareMedium()
response = RFFrame.from_hex("4400")
loop.run_until_complete(medium.attach(FixedTransponder(response)))
steps = [
ReaderStep(
frame=RFFrame.from_hex("26"),
description="Fail here",
check=lambda r: False,
stop_on_fail=True,
),
ReaderStep(
frame=RFFrame.from_hex("9320"),
description="Should not run",
),
]
scripted = ScriptedReader(medium)
results = loop.run_until_complete(scripted.run_script(steps))
assert len(results) == 1 # stopped after first failure
def test_no_check_means_pass(self):
loop = asyncio.get_event_loop()
medium = SoftwareMedium()
loop.run_until_complete(medium.attach(FixedTransponder(RFFrame.from_hex("00"))))
steps = [
ReaderStep(frame=RFFrame.from_hex("26"), description="No check"),
]
scripted = ScriptedReader(medium)
results = loop.run_until_complete(scripted.run_script(steps))
assert results[0].passed
class TestInteractiveReader:
"""Test interactive reader step-through."""
def test_send_and_receive(self):
loop = asyncio.get_event_loop()
medium = SoftwareMedium()
loop.run_until_complete(medium.attach(EchoTransponder()))
interactive = InteractiveReader(medium)
resp = loop.run_until_complete(interactive.send(RFFrame.from_hex("26")))
assert resp is not None
assert resp.data == b"\x26"
def test_send_hex_convenience(self):
loop = asyncio.get_event_loop()
medium = SoftwareMedium()
loop.run_until_complete(medium.attach(EchoTransponder()))
interactive = InteractiveReader(medium)
resp = loop.run_until_complete(interactive.send_hex("A5"))
assert resp is not None
assert resp.data == b"\xA5"
def test_history_tracks_exchanges(self):
loop = asyncio.get_event_loop()
medium = SoftwareMedium()
loop.run_until_complete(medium.attach(EchoTransponder()))
interactive = InteractiveReader(medium)
loop.run_until_complete(interactive.send_hex("26"))
loop.run_until_complete(interactive.send_hex("9320"))
assert len(interactive.history) == 2
assert interactive.history[0][0].data == b"\x26"
assert interactive.history[1][0].data == b"\x93\x20"
def test_dump_trace_returns_string(self):
loop = asyncio.get_event_loop()
medium = SoftwareMedium()
loop.run_until_complete(medium.attach(EchoTransponder()))
interactive = InteractiveReader(medium)
loop.run_until_complete(interactive.send_hex("26"))
trace = interactive.dump_trace()
assert isinstance(trace, str)
assert len(trace) > 0

View File

@@ -0,0 +1,599 @@
"""Tests for table compiler and sim session."""
import asyncio
import struct
import pytest
from unittest.mock import AsyncMock
from pm3py.sim.frame import RFFrame
from pm3py.sim.iso14443a import Tag14443A_3, Tag14443A_4, _compute_bcc
from pm3py.sim.mifare import MifareClassicTag
from pm3py.sim.iso15693 import Tag15693
from pm3py.sim.desfire import DesfireTag
from pm3py.sim.table_compiler import (
TableEntry, ResponseTable, TableCompiler,
EML_READ, EML_WRITE, MATCH_PREFIX,
)
from pm3py.sim.nxp_icode import NxpIcodeTag
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# TableEntry
# ---------------------------------------------------------------------------
class TestTableEntry:
def test_serialize_size(self):
entry = TableEntry(match=b"\x26", response=b"\x04\x00")
raw = entry.serialize()
assert len(raw) == 120
def test_serialize_roundtrip(self):
entry = TableEntry(
match=b"\x26",
match_mode=0,
response=b"\x04\x00",
)
raw = entry.serialize()
assert len(raw) == 120
parsed = TableEntry.deserialize(raw)
assert parsed.match == b"\x26"
assert parsed.response == b"\x04\x00"
assert parsed.match_mode == 0
def test_eml_fields_roundtrip(self):
entry = TableEntry(
match=b"\x30\x04",
response=b"\x00",
eml_action=1,
eml_offset=64,
eml_len=16,
eml_resp_insert=1,
)
raw = entry.serialize()
parsed = TableEntry.deserialize(raw)
assert parsed.eml_action == 1
assert parsed.eml_offset == 64
assert parsed.eml_len == 16
assert parsed.eml_resp_insert == 1
def test_group_fields_roundtrip(self):
entry = TableEntry(
match=b"\x60\x00",
response=b"\xAB\xCD\xEF\x01",
group=1,
activate_groups=0x00000006,
deactivate_groups=0x00000001,
set_auth=1,
clear_auth=0,
)
raw = entry.serialize()
parsed = TableEntry.deserialize(raw)
assert parsed.group == 1
assert parsed.activate_groups == 0x00000006
assert parsed.deactivate_groups == 0x00000001
assert parsed.set_auth == 1
assert parsed.clear_auth == 0
def test_consume_flag(self):
entry = TableEntry(match=b"\x60\x00", response=b"\xAB\xCD\xEF\x01", flags=0x02)
raw = entry.serialize()
parsed = TableEntry.deserialize(raw)
assert parsed.flags & 0x02
def test_exact_match(self):
entry = TableEntry(match=b"\x26", response=b"\x04\x00")
assert entry.matches(b"\x26")
assert not entry.matches(b"\x52")
def test_prefix_match(self):
entry = TableEntry(match=b"\x93\x20", match_mode=1, response=b"\x01\x02\x03\x04\x04")
assert entry.matches(b"\x93\x20")
assert entry.matches(b"\x93\x20\xFF\xFF")
assert not entry.matches(b"\x95\x20")
def test_crc_flag(self):
entry = TableEntry(match=b"\x26", response=b"\x08", response_flags=0x01)
assert entry.response_flags & 0x01 # CRC append flag
def test_stateful_flag(self):
entry = TableEntry(match=b"\x60\x00", response=b"\xAB\xCD\xEF\x01", flags=0x02)
assert entry.flags & 0x02 # consume after use
# ---------------------------------------------------------------------------
# ResponseTable
# ---------------------------------------------------------------------------
class TestResponseTable:
def test_serialize_multiple_entries(self):
table = ResponseTable(entries=[
TableEntry(match=b"\x26", response=b"\x04\x00"),
TableEntry(match=b"\x52", response=b"\x04\x00"),
])
raw = table.serialize()
assert len(raw) == 240 # 2 * 120
def test_from_trace(self):
from pm3py.sim.replay import TraceEntry
trace = [
TraceEntry(direction="reader", frame=RFFrame.from_hex("26")),
TraceEntry(direction="tag", frame=RFFrame.from_hex("0400")),
TraceEntry(direction="reader", frame=RFFrame.from_hex("9320")),
TraceEntry(direction="tag", frame=RFFrame.from_hex("0102030404")),
]
table = ResponseTable.from_trace(trace)
assert len(table.entries) == 2
assert table.entries[0].match == b"\x26"
assert table.entries[0].response == b"\x04\x00"
def test_lookup(self):
table = ResponseTable(entries=[
TableEntry(match=b"\x26", response=b"\x04\x00"),
TableEntry(match=b"\x52", response=b"\x04\x00"),
])
entry = table.lookup(b"\x26")
assert entry is not None
assert entry.response == b"\x04\x00"
assert table.lookup(b"\xFF") is None
def test_overlay_replaces_matching(self):
base = ResponseTable(entries=[
TableEntry(match=b"\x26", response=b"\x04\x00"),
])
overlay = ResponseTable(entries=[
TableEntry(match=b"\x26", response=b"\x44\x00"), # different response
])
base.overlay(overlay)
entry = base.lookup(b"\x26")
assert entry.response == b"\x44\x00"
# ---------------------------------------------------------------------------
# TableCompiler — 14443-A
# ---------------------------------------------------------------------------
class TestTableCompiler14a:
def test_compile_4byte_uid_tag(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04", atqa=b"\x04\x00", sak=0x08)
table = TableCompiler.compile_14a(tag)
# Should have: REQA, WUPA, ANTICOL CL1, SELECT CL1
assert len(table.entries) >= 4
# REQA → ATQA
reqa = table.lookup(b"\x26")
assert reqa is not None
assert reqa.response == b"\x04\x00"
# WUPA → ATQA
wupa = table.lookup(b"\x52")
assert wupa is not None
assert wupa.response == b"\x04\x00"
def test_compile_7byte_uid_tag(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04\x05\x06\x07", sak=0x08)
table = TableCompiler.compile_14a(tag)
# Should have: REQA, WUPA, ANTICOL CL1, SELECT CL1, ANTICOL CL2, SELECT CL2
assert len(table.entries) >= 6
def test_compile_part4_includes_rats(self):
tag = Tag14443A_4(uid=b"\x01\x02\x03\x04", sak=0x20,
ats=b"\x05\x78\x80\x70\x02")
table = TableCompiler.compile_14a(tag)
# Should have RATS entry
rats = table.lookup(b"\xE0")
assert rats is not None
assert rats.response == b"\x05\x78\x80\x70\x02"
def test_compile_includes_hlta(self):
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04")
table = TableCompiler.compile_14a(tag)
# HLTA should NOT be in table (no response per spec)
hlta = table.lookup(b"\x50\x00")
assert hlta is None
class TestTableCompiler15693:
def test_compile_15693_has_inventory(self):
tag = Tag15693(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06", dsfid=0x00)
table = TableCompiler.compile_15693(tag)
assert len(table.entries) >= 1 # at least inventory
def test_compile_15693_has_system_info(self):
tag = Tag15693(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06",
dsfid=0x42, block_size=4, num_blocks=28)
table = TableCompiler.compile_15693(tag)
# System info should be present (unaddressed)
sysinfo = table.lookup(bytes([0x02, 0x2B]))
assert sysinfo is not None
class TestTableCompilerMifare:
def test_compile_mifare_has_auth_entries(self):
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
table = TableCompiler.compile_mifare(tag)
# Should have anticollision + auth entries for each sector
assert len(table.entries) >= 4 # at least REQA, WUPA, ANTICOL, SELECT
def test_compile_mifare_auth_is_stateful(self):
"""Auth entries should be marked stateful (consumed after use)."""
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
table = TableCompiler.compile_mifare(tag)
# Find an auth entry (AUTH_A for block 0 = 0x60 0x00)
auth_entries = [e for e in table.entries if len(e.match) >= 1 and e.match[0] == 0x60]
if auth_entries:
# Auth nonce response should be stateful
assert auth_entries[0].flags & 0x02
# ---------------------------------------------------------------------------
# SimSession (mock transport)
# ---------------------------------------------------------------------------
class TestSimSession:
def test_upload_table(self):
from pm3py.sim.sim_session import SimSession
t = AsyncMock()
t.send_ng.return_value = AsyncMock()
session = SimSession(t)
table = ResponseTable(entries=[
TableEntry(match=b"\x26", response=b"\x04\x00"),
])
run(session.upload_table(table))
t.send_ng.assert_called()
# Verify the serialized data was sent
call_args = t.send_ng.call_args
assert len(call_args[0][1]) == 120 # one entry
def test_upload_large_table_chunks(self):
"""Tables larger than 512 bytes should be chunked."""
from pm3py.sim.sim_session import SimSession
t = AsyncMock()
t.send_ng.return_value = AsyncMock()
session = SimSession(t)
# 5 entries = 600 bytes > 512
entries = [TableEntry(match=bytes([i]), response=bytes([i+1])) for i in range(5)]
table = ResponseTable(entries=entries)
run(session.upload_table(table))
# Should have been called multiple times
assert t.send_ng.call_count >= 2
# ---------------------------------------------------------------------------
# TableCompiler — NXP ICODE
# ---------------------------------------------------------------------------
class TestTableCompilerNxpIcode:
def test_compile_has_get_random(self):
tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06")
table = TableCompiler.compile_nxp_icode(tag)
entry = table.lookup(bytes([0x02, 0xB2, 0x04]))
assert entry is not None
assert len(entry.response) == 3 # flags + 2 random bytes
def test_compile_has_get_nxp_system_info(self):
tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06")
table = TableCompiler.compile_nxp_icode(tag)
entry = table.lookup(bytes([0x02, 0xAB, 0x04]))
assert entry is not None
assert len(entry.response) == 8 # flags + PP + PP_cond + lock + feature_flags(4)
def test_compile_has_read_signature(self):
sig = bytes(range(32))
tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06", signature=sig)
table = TableCompiler.compile_nxp_icode(tag)
entry = table.lookup(bytes([0x02, 0xBD, 0x04]))
assert entry is not None
assert len(entry.response) == 33 # flags + 32-byte signature
assert entry.response[1:] == sig
def test_compile_no_config_register_entries(self):
"""0xA1/0xA2 are FAST INVENTORY READ / SET EAS, not config registers."""
tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06")
table = TableCompiler.compile_nxp_icode(tag)
# Should NOT have entries for 0xA1 or 0xA2
assert table.lookup(bytes([0x02, 0xA1, 0x04, 0x00])) is None
assert table.lookup(bytes([0x02, 0xA2, 0x04, 0x00])) is None
# ---------------------------------------------------------------------------
# TableCompiler — SLIX2
# ---------------------------------------------------------------------------
class TestTableCompilerSlix2:
def test_compile_has_set_password(self):
tag = IcodeSlix2Tag(
uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06",
read_password=0x12345678,
)
table = TableCompiler.compile_slix2(tag)
fallback = [e for e in table.entries if
len(e.match) >= 2 and e.match[1] == 0xB3 and
e.match_mode == MATCH_PREFIX]
assert len(fallback) > 0
assert fallback[-1].response == bytes([0x01, 0x0F])
def test_compile_set_password_sets_auth(self):
tag = IcodeSlix2Tag(
uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06",
read_password=0x12345678,
)
table = TableCompiler.compile_slix2(tag)
auth_entries = [e for e in table.entries if e.set_auth != 0]
assert len(auth_entries) > 0
def test_compile_includes_nxp_base_entries(self):
"""SLIX2 table includes NXP base entries (GET_RANDOM, READ_SIGNATURE, etc.)."""
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06")
table = TableCompiler.compile_slix2(tag)
# Should have GET_RANDOM from nxp_icode compiler
assert table.lookup(bytes([0x02, 0xB2, 0x04])) is not None
# Should have GET_NXP_SYSTEM_INFO
assert table.lookup(bytes([0x02, 0xAB, 0x04])) is not None
# Should have READ_SIGNATURE
assert table.lookup(bytes([0x02, 0xBD, 0x04])) is not None
def test_compile_enable_privacy_entry(self):
"""SLIX2 table has ENABLE_PRIVACY (0xBA) entries when privacy password set."""
tag = IcodeSlix2Tag(
uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06",
privacy_password=0xAABBCCDD,
)
table = TableCompiler.compile_slix2(tag)
# Should have ENABLE_PRIVACY entries (correct + fallback)
enable_entries = [e for e in table.entries if
len(e.match) >= 2 and e.match[1] == 0xBA]
assert len(enable_entries) >= 2 # correct password + fallback
def test_compile_privacy_groups(self):
"""ENABLE_PRIVACY correct password entry activates a group."""
tag = IcodeSlix2Tag(
uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06",
privacy_password=0xAABBCCDD,
)
table = TableCompiler.compile_slix2(tag)
privacy_entries = [e for e in table.entries if e.activate_groups != 0]
assert len(privacy_entries) > 0
def test_compile_eas_afi_password(self):
"""EAS/AFI password (pwd_id 0x10) generates a table entry."""
tag = IcodeSlix2Tag(
uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06",
eas_afi_password=0xAABBCCDD,
)
table = TableCompiler.compile_slix2(tag)
auth_entries = [e for e in table.entries if e.set_auth == 0x10]
assert len(auth_entries) > 0
# ---------------------------------------------------------------------------
# TableCompiler — EAS ALARM
# ---------------------------------------------------------------------------
class TestTableCompilerEasAlarm:
def test_slix2_has_eas_alarm_entry(self):
"""SLIX2 table includes EAS ALARM (0xA5) entry."""
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06")
tag.set_eas(True)
table = TableCompiler.compile_slix2(tag)
# EAS ALARM should match 0x02 0xA5
entry = table.lookup(bytes([0x02, 0xA5]))
assert entry is not None
assert entry.response[0] == 0x00 # success
assert len(entry.response) >= 33 # flags + 32-byte EAS sequence
def test_eas_alarm_disabled_returns_none(self):
"""EAS ALARM entry not present when EAS disabled."""
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06")
# EAS not set
table = TableCompiler.compile_slix2(tag)
entry = table.lookup(bytes([0x02, 0xA5]))
# Should either be absent or return error
assert entry is None or entry.response[0] & 0x01
# ---------------------------------------------------------------------------
# TableCompiler — ICODE 3
# ---------------------------------------------------------------------------
class TestTableCompilerIcode3:
def test_compile_icode3_exists(self):
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag()
table = TableCompiler.compile_icode3(tag)
assert len(table.entries) > 0
def test_compile_icode3_includes_slix2_entries(self):
"""ICODE 3 table includes SLIX2 base entries."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag()
table = TableCompiler.compile_icode3(tag)
# Should have GET_RANDOM, GET_NXP_SYSTEM_INFO, READ_SIGNATURE
assert table.lookup(bytes([0x02, 0xB2])) is not None
assert table.lookup(bytes([0x02, 0xAB])) is not None
assert table.lookup(bytes([0x02, 0xBD])) is not None
def test_compile_icode3_system_info_has_feature_flags(self):
"""ICODE 3 GET NXP SYSTEM INFO has proper feature flags."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag()
table = TableCompiler.compile_icode3(tag)
entry = table.lookup(bytes([0x02, 0xAB]))
assert entry is not None
feature_flags = struct.unpack_from("<I", entry.response, 4)[0]
assert feature_flags & (1 << 1) # COUNTER
assert feature_flags & (1 << 12) # PRIVACY
assert feature_flags & (1 << 13) # DESTROY
def test_compile_icode3_config_password(self):
"""ICODE 3 table includes config password (pwd_id 0x20)."""
from pm3py.sim.icode3 import Icode3Tag
tag = Icode3Tag(config_password=0xAABBCCDD)
table = TableCompiler.compile_icode3(tag)
auth_entries = [e for e in table.entries if e.set_auth != 0]
# Should have entries for config password (0x20)
pwd_ids = set()
for e in table.entries:
if e.set_auth != 0 and len(e.match) >= 3:
pwd_ids.add(e.match[2]) # pwd_id byte
assert 0x20 in pwd_ids
def test_session_dispatches_icode3(self):
"""SimSession uses compile_icode3 for Icode3Tag."""
from pm3py.sim.icode3 import Icode3Tag
from pm3py.sim.sim_session import SimSession
from unittest.mock import MagicMock
tag = Icode3Tag()
mock_ser = MagicMock()
mock_ser.in_waiting = 0
mock_ser.read.return_value = b""
session = SimSession(port=mock_ser)
session.start_15693(tag, compile=True, trace=False)
calls = mock_ser.write.call_args_list
assert len(calls) >= 2
# ---------------------------------------------------------------------------
# TableCompiler — ICODE DNA
# ---------------------------------------------------------------------------
class TestTableCompilerDna:
def test_compile_icode_dna_exists(self):
from pm3py.sim.icode_dna import IcodeDnaTag
tag = IcodeDnaTag()
table = TableCompiler.compile_icode_dna(tag)
assert len(table.entries) > 0
def test_compile_includes_base_entries(self):
"""DNA table includes GET_RANDOM, READ_SIGNATURE from NXP base."""
from pm3py.sim.icode_dna import IcodeDnaTag
tag = IcodeDnaTag()
table = TableCompiler.compile_icode_dna(tag)
assert table.lookup(bytes([0x02, 0xB2])) is not None # GET_RANDOM
assert table.lookup(bytes([0x02, 0xBD])) is not None # READ_SIGNATURE
def test_compile_has_dna_feature_flags(self):
"""GET NXP SYSTEM INFO has DNA-specific feature flags."""
from pm3py.sim.icode_dna import IcodeDnaTag
tag = IcodeDnaTag()
table = TableCompiler.compile_icode_dna(tag)
entry = table.lookup(bytes([0x02, 0xAB]))
assert entry is not None
feature_flags = struct.unpack_from("<I", entry.response, 4)[0]
assert feature_flags & (1 << 1) # COUNTER
assert feature_flags & (1 << 3) # EAS_PP
assert feature_flags & (1 << 4) # AFI_PP
assert feature_flags & (1 << 12) # PRIVACY
assert feature_flags & (1 << 13) # DESTROY
def test_compile_has_eas(self):
"""DNA table includes EAS entries."""
from pm3py.sim.icode_dna import IcodeDnaTag
tag = IcodeDnaTag()
tag.set_eas(True)
table = TableCompiler.compile_icode_dna(tag)
entry = table.lookup(bytes([0x02, 0xA5])) # EAS ALARM
assert entry is not None
def test_session_dispatches_dna(self):
"""SimSession uses compile_icode_dna for IcodeDnaTag."""
from pm3py.sim.icode_dna import IcodeDnaTag
from pm3py.sim.sim_session import SimSession
from unittest.mock import MagicMock
tag = IcodeDnaTag()
mock_ser = MagicMock()
mock_ser.in_waiting = 0
mock_ser.read.return_value = b""
session = SimSession(port=mock_ser)
session.start_15693(tag, compile=True, trace=False)
calls = mock_ser.write.call_args_list
assert len(calls) >= 2
# ---------------------------------------------------------------------------
# SimSession — table compile on start_15693
# ---------------------------------------------------------------------------
class TestSimSessionTableCompile:
def test_start_15693_compiles_nxp_table(self):
"""SimSession uses compile_icode3 for Icode3Tag."""
from pm3py.sim.icode3 import Icode3Tag
from pm3py.sim.sim_session import SimSession
from unittest.mock import MagicMock
tag = Icode3Tag()
mock_ser = MagicMock()
mock_ser.in_waiting = 0
mock_ser.read.return_value = b""
session = SimSession(port=mock_ser)
session.start_15693(tag, compile=True, trace=False)
calls = mock_ser.write.call_args_list
assert len(calls) >= 2 # SIM start + EML sync + table upload
# ---------------------------------------------------------------------------
# SimSession — table compile on start_15693
# ---------------------------------------------------------------------------
class TestSimSessionTableCompile:
def test_start_15693_compiles_nxp_table(self):
"""start_15693 with compile=True should upload table entries."""
from pm3py.sim.sim_session import SimSession
from unittest.mock import MagicMock
tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06")
mock_ser = MagicMock()
mock_ser.in_waiting = 0
mock_ser.read.return_value = b""
session = SimSession(port=mock_ser)
session.start_15693(tag, compile=True, trace=False)
# Should have written: SIM command + EML sync + table upload
calls = mock_ser.write.call_args_list
assert len(calls) >= 2
def test_start_15693_no_compile_default(self):
"""start_15693 without compile should not upload table."""
from pm3py.sim.sim_session import SimSession, CMD_SIM_TABLE_UPLOAD
from unittest.mock import MagicMock
tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06")
mock_ser = MagicMock()
mock_ser.in_waiting = 0
mock_ser.read.return_value = b""
session = SimSession(port=mock_ser)
session.start_15693(tag, compile=False, trace=False)
# Check no frame contains CMD_SIM_TABLE_UPLOAD
for call in mock_ser.write.call_args_list:
frame = call[0][0]
if len(frame) >= 10:
cmd_in_frame = struct.unpack_from("<H", frame, 6)[0]
assert cmd_in_frame != CMD_SIM_TABLE_UPLOAD

578
tests/test_sim_trace_fmt.py Normal file
View File

@@ -0,0 +1,578 @@
"""Tests for pm3py.sim.trace_fmt — trace formatting and protocol decoding."""
import os
import pytest
from pm3py.sim.trace_fmt import decode_15693, decode_15693_nxp, decode_14443a, TraceFormatter
class TestDecode15693Request:
"""Decode reader->tag (direction=0) 15693 commands."""
def test_inventory(self):
payload = bytes([0x26, 0x01, 0x00])
result = decode_15693(0, payload)
assert result == "INVENTORY"
def test_inventory_with_mask(self):
payload = bytes([0x26, 0x01, 0x08, 0xAB])
result = decode_15693(0, payload)
assert result == "INVENTORY mask=8"
def test_stay_quiet(self):
payload = bytes([0x22, 0x02]) + b"\x01" * 8
result = decode_15693(0, payload)
assert result == "STAY QUIET"
def test_read_single_block_addressed(self):
payload = bytes([0x22, 0x20]) + b"\x01" * 8 + bytes([0x03])
result = decode_15693(0, payload)
assert result == "READ SINGLE BLOCK #3"
def test_read_single_block_unaddressed(self):
payload = bytes([0x02, 0x20, 0x00])
result = decode_15693(0, payload)
assert result == "READ SINGLE BLOCK #0"
def test_write_single_block(self):
payload = bytes([0x22, 0x21]) + b"\x01" * 8 + bytes([0x05]) + b"\xDE\xAD\xBE\xEF"
result = decode_15693(0, payload)
assert result == "WRITE SINGLE BLOCK #5 [4B]"
def test_read_multiple_block(self):
payload = bytes([0x22, 0x23]) + b"\x01" * 8 + bytes([0x00, 0x0D])
result = decode_15693(0, payload)
assert result == "READ MULTIPLE BLOCK #0+13"
def test_reset_to_ready(self):
payload = bytes([0x22, 0x26]) + b"\x01" * 8
result = decode_15693(0, payload)
assert result == "RESET TO READY"
def test_get_system_info(self):
payload = bytes([0x22, 0x2B]) + b"\x01" * 8
result = decode_15693(0, payload)
assert result == "GET SYSTEM INFO"
def test_get_multiple_block_security(self):
payload = bytes([0x22, 0x2C]) + b"\x01" * 8 + bytes([0x00, 0x3F])
result = decode_15693(0, payload)
assert result == "GET MULTIPLE BLOCK SECURITY #0+63"
def test_nxp_command_decoded(self):
payload = bytes([0x22, 0xA1, 0x04, 0x02])
result = decode_15693(0, payload)
assert result == "NXP FAST INVENTORY READ"
def test_unknown_command(self):
payload = bytes([0x22, 0xFF])
result = decode_15693(0, payload)
assert result == "UNKNOWN CMD 0xFF"
def test_too_short(self):
result = decode_15693(0, bytes([0x26]))
assert result is None
class TestDecode15693NxpRequest:
"""Decode NXP custom reader->tag (direction=0) commands."""
def test_nxp_fast_inventory_read(self):
payload = bytes([0x22, 0xA1, 0x04, 0x02])
result = decode_15693_nxp(0, payload)
assert result == "NXP FAST INVENTORY READ"
def test_nxp_set_eas(self):
payload = bytes([0x22, 0xA2, 0x04, 0x03, 0xFF])
result = decode_15693_nxp(0, payload)
assert result == "NXP SET EAS"
def test_nxp_get_random(self):
payload = bytes([0x22, 0xB2, 0x04])
result = decode_15693_nxp(0, payload)
assert result == "NXP GET RANDOM"
def test_nxp_set_password(self):
payload = bytes([0x22, 0xB3, 0x04, 0x01]) + b"\x00" * 4
result = decode_15693_nxp(0, payload)
assert result == "NXP SET PASSWORD id=1"
def test_response_returns_none(self):
payload = bytes([0x00])
result = decode_15693_nxp(1, payload)
assert result is None
def test_unknown_nxp_command(self):
payload = bytes([0x22, 0xC0, 0x04])
result = decode_15693_nxp(0, payload)
assert result is None
class TestDecode15693Response:
"""Decode tag->reader (direction=1) 15693 responses."""
def test_ok_empty(self):
result = decode_15693(1, bytes([0x00]))
assert result == "OK"
def test_inventory_response(self):
uid_lsb = bytes([0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x04, 0xE0])
payload = bytes([0x00, 0x00]) + uid_lsb
result = decode_15693(1, payload)
assert result == "OK INVENTORY UID=E004010101010101"
def test_error_response(self):
payload = bytes([0x01, 0x0F])
result = decode_15693(1, payload)
assert result == "ERROR 0x0F"
def test_error_block_not_available(self):
payload = bytes([0x01, 0x10])
result = decode_15693(1, payload)
assert result == "ERROR 0x10 block not available"
def test_data_response(self):
payload = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF])
result = decode_15693(1, payload)
assert result == "OK [4B]"
def test_too_short(self):
result = decode_15693(1, b"")
assert result is None
class TestDecode14443aRequest:
"""Decode reader->tag 14443-A commands."""
def test_reqa(self):
assert decode_14443a(0, bytes([0x26])) == "REQA"
def test_wupa(self):
assert decode_14443a(0, bytes([0x52])) == "WUPA"
def test_hlta(self):
assert decode_14443a(0, bytes([0x50, 0x00])) == "HLTA"
def test_anticol_cl1(self):
assert decode_14443a(0, bytes([0x93, 0x20])) == "ANTICOL CL1"
def test_select_cl1(self):
payload = bytes([0x93, 0x70]) + b"\x01\x02\x03\x04\x04"
assert decode_14443a(0, payload) == "SELECT CL1"
def test_anticol_cl2(self):
assert decode_14443a(0, bytes([0x95, 0x20])) == "ANTICOL CL2"
def test_select_cl2(self):
payload = bytes([0x95, 0x70]) + b"\x01\x02\x03\x04\x04"
assert decode_14443a(0, payload) == "SELECT CL2"
def test_anticol_cl3(self):
assert decode_14443a(0, bytes([0x97, 0x20])) == "ANTICOL CL3"
def test_select_cl3(self):
payload = bytes([0x97, 0x70]) + b"\x01\x02\x03\x04\x04"
assert decode_14443a(0, payload) == "SELECT CL3"
def test_rats(self):
assert decode_14443a(0, bytes([0xE0, 0x50])) == "RATS"
def test_iblock_even(self):
assert decode_14443a(0, bytes([0x02, 0x00, 0xA4])) == "I-BLOCK(0)"
def test_iblock_odd(self):
assert decode_14443a(0, bytes([0x03, 0x00, 0xA4])) == "I-BLOCK(1)"
def test_rack(self):
assert decode_14443a(0, bytes([0xA2])) == "R-ACK(0)"
assert decode_14443a(0, bytes([0xA3])) == "R-ACK(1)"
def test_rnak(self):
assert decode_14443a(0, bytes([0xB2])) == "R-NAK(0)"
assert decode_14443a(0, bytes([0xB3])) == "R-NAK(1)"
def test_deselect(self):
assert decode_14443a(0, bytes([0xC2])) == "S(DESELECT)"
def test_wtx(self):
assert decode_14443a(0, bytes([0xF2, 0x01])) == "S(WTX)"
def test_unknown(self):
assert decode_14443a(0, bytes([0xFF])) is None
def test_empty(self):
assert decode_14443a(0, b"") is None
class TestDecode14443aResponse:
"""Decode tag->reader 14443-A responses."""
def test_atqa(self):
assert decode_14443a(1, bytes([0x04, 0x00])) == "ATQA 04 00"
def test_sak(self):
assert decode_14443a(1, bytes([0x20])) == "SAK 20"
def test_ats(self):
ats = bytes([0x05, 0x78, 0x80, 0x70, 0x02])
assert decode_14443a(1, ats) == "ATS [5]"
def test_iblock_response(self):
assert decode_14443a(1, bytes([0x02, 0x90, 0x00])) == "I-BLOCK(0)"
def test_empty(self):
assert decode_14443a(1, b"") is None
class TestTraceFormatterBasic:
"""Test formatting output (colors stripped for assertion)."""
def _strip_ansi(self, s: str) -> str:
import re
return re.sub(r'\033\[[0-9;]*m', '', s)
def test_starts_with_newline(self):
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
assert result.startswith("\n")
def test_sim_mode_tag(self):
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
assert "[Sim]" in result
def test_reader_mode_tag(self):
fmt = TraceFormatter(mode="reader", decoder=decode_15693)
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
assert "[Rdr]" in result
def test_sniff_mode_tag(self):
fmt = TraceFormatter(mode="sniff", decoder=decode_15693)
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
assert "[Snf]" in result
def test_reader_to_tag_arrow(self):
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
assert "Reader \u2192 Tag:" in result
def test_tag_to_reader_arrow(self):
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
result = self._strip_ansi(fmt.format(1, bytes([0x00])))
assert "Tag \u2192 Reader:" in result
def test_hex_space_separated(self):
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
result = self._strip_ansi(fmt.format(0, bytes([0x22, 0x20, 0x03])))
assert "22 20 03" in result
def test_hex_uppercase(self):
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
result = self._strip_ansi(fmt.format(0, bytes([0xDE, 0xAD])))
assert "DE AD" in result
def test_annotation_present(self):
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
assert "INVENTORY" in result
def test_no_annotation_for_unknown(self):
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
result = self._strip_ansi(fmt.format(0, bytes([0x22, 0xFF])))
assert "22 FF" in result
def test_no_decoder(self):
fmt = TraceFormatter(mode="sim")
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
assert "26 01 00" in result
assert "INVENTORY" not in result
def test_custom_decoder(self):
def my_decoder(d, p):
return "CUSTOM"
fmt = TraceFormatter(mode="sim", decoder=my_decoder)
result = self._strip_ansi(fmt.format(0, bytes([0x00])))
assert "CUSTOM" in result
class TestTraceFormatterDimOurSide:
"""Our side (the one we're simulating) should be dimmed."""
def test_sim_mode_dims_tag_response(self):
"""In sim mode, tag→reader (our side) should have dim annotation."""
fmt = TraceFormatter(mode="sim", decoder=decode_15693, is_tty=True)
result = fmt.format(1, bytes([0x00]))
# dim (\033[2m) should appear before direction color for annotation
assert "\033[2m" in result
def test_sim_mode_bright_reader_command(self):
"""In sim mode, reader→tag (their side) should NOT be dimmed."""
fmt = TraceFormatter(mode="sim", decoder=decode_15693, is_tty=True)
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
# Annotation should use direction color (cyan), not dim
assert "\033[36mINVENTORY\033[0m" in result
def test_reader_mode_dims_reader_command(self):
"""In reader mode, reader→tag (our side) should be dimmed."""
fmt = TraceFormatter(mode="reader", decoder=decode_15693, is_tty=True)
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
assert "\033[2m" in result
def test_sniff_mode_dims_neither(self):
"""In sniff mode, neither side is dimmed — annotations use direction colors."""
fmt = TraceFormatter(mode="sniff", decoder=decode_15693, is_tty=True)
r0 = fmt.format(0, bytes([0x26, 0x01, 0x00]))
r1 = fmt.format(1, bytes([0x00]))
# reader→tag uses cyan, tag→reader uses yellow
assert "\033[36mINVENTORY\033[0m" in r0
assert "\033[33mOK\033[0m" in r1
class TestTraceFormatterWrapping:
"""Test column-aligned wrapping for long payloads."""
def _strip_ansi(self, s: str) -> str:
import re
return re.sub(r'\033\[[0-9;]*m', '', s)
def test_short_payload_inline_annotation(self):
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=80)
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
lines = result.strip().split("\n")
assert len(lines) == 1
assert "INVENTORY" in lines[0]
assert "26 01 00" in lines[0]
def test_annotation_right_justified(self):
"""Annotation should end at the terminal's right edge."""
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=80)
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
line = result.strip()
assert line.endswith("INVENTORY")
assert len(line) == 80
def test_wrapped_annotation_right_justified(self):
"""When wrapped, annotation line should be right-justified."""
# Use wider terminal so annotation fits within avail
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=80)
# Short response that still wraps at width=80 won't work, so test
# with a response that has a short annotation
payload = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF])
# At width=80 this fits on one line — annotation right-justified
result = self._strip_ansi(fmt.format(1, payload))
line = result.strip()
assert line.endswith("OK [4B]")
assert len(line) == 80
def test_long_payload_wraps(self):
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=60)
long_payload = bytes([0x00]) + bytes(40)
result = self._strip_ansi(fmt.format(1, long_payload))
lines = result.strip().split("\n")
assert len(lines) > 1
# Continuation lines should be indented to prefix column
first_hex_col = lines[0].index("00")
for line in lines[1:]:
if line.strip():
leading = len(line) - len(line.lstrip())
assert leading >= first_hex_col - 1
def test_wrapped_annotation_on_own_line(self):
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=50)
uid_lsb = bytes([0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x04, 0xE0])
payload = bytes([0x00, 0x00]) + uid_lsb
result = self._strip_ansi(fmt.format(1, payload))
lines = result.strip().split("\n")
if len(lines) > 1:
annotation_lines = [l for l in lines if "INVENTORY" in l]
hex_lines = [l for l in lines if "00 01" in l.lower() or "04 e0" in l.lower()]
if len(hex_lines) > 1:
assert len(annotation_lines) == 1
assert annotation_lines[0] not in hex_lines
def test_long_annotation_wraps_to_next_line(self):
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
from pm3py.sim.type5 import ndef_text
msg = ndef_text("A" * 30)
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
payload = bytes([0x00]) + cc + tlv
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=80)
result = self._strip_ansi(fmt.format(1, payload))
lines = result.strip().split("\n")
ann_lines = [l for l in lines if "NDEF" in l]
assert len(ann_lines) >= 1
class TestTraceFormatterCRC:
"""CRC bytes rendered separately and excluded from decoding."""
def _strip_ansi(self, s: str) -> str:
import re
return re.sub(r'\033\[[0-9;]*m', '', s)
def test_crc_bytes_in_output(self):
"""CRC bytes still appear in hex output."""
# Inventory response with CRC: flags + dsfid + uid(8) + crc(2)
uid_lsb = bytes([0x04, 0x03, 0x02, 0x01, 0xEF, 0xBE, 0xAD, 0xDE])
payload = bytes([0x00, 0x00]) + uid_lsb + bytes([0x84, 0x62])
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=2, width=120)
result = self._strip_ansi(fmt.format(1, payload))
assert "84 62" in result
def test_crc_excluded_from_decode(self):
"""Decoder sees data without CRC — inventory response recognized."""
uid_lsb = bytes([0x04, 0x03, 0x02, 0x01, 0xEF, 0xBE, 0xAD, 0xDE])
payload = bytes([0x00, 0x00]) + uid_lsb + bytes([0x84, 0x62])
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=2, width=120)
result = self._strip_ansi(fmt.format(1, payload))
assert "OK INVENTORY UID=" in result
def test_read_response_with_crc(self):
"""Read response with CRC decoded as OK [4B] not OK [6B]."""
payload = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF, 0x48, 0xD1])
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=2, width=120)
result = self._strip_ansi(fmt.format(1, payload))
assert "OK [4B]" in result
def test_crc_different_color(self):
"""CRC bytes use distinct color from data bytes."""
payload = bytes([0x00, 0xDE, 0xAD, 0x48, 0xD1])
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=2, is_tty=True, width=120)
result = fmt.format(1, payload)
# Data uses dim, CRC uses bold white
assert "\033[2m" in result # dim for data
assert "\033[1;37m" in result # bold white for CRC
def test_no_crc_when_crc_len_zero(self):
"""No CRC splitting when crc_len=0."""
payload = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF])
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=0, width=120)
result = self._strip_ansi(fmt.format(1, payload))
assert "OK [4B]" in result
def test_short_payload_no_crc_split(self):
"""Payload shorter than crc_len doesn't crash."""
payload = bytes([0x00])
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=2, width=120)
result = self._strip_ansi(fmt.format(1, payload))
assert "00" in result
def test_crc_in_commands(self):
"""Reader→tag commands include CRC — all bytes appear in hex output."""
# INVENTORY with CRC: 26 01 00 F6 0A — CRC stripped for decoding
payload = bytes([0x26, 0x01, 0x00, 0xF6, 0x0A])
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=2, width=120)
result = self._strip_ansi(fmt.format(0, payload))
assert "INVENTORY" in result
assert "26 01 00" in result
assert "F6 0A" in result
class TestTraceFormatterNoColor:
"""Colors suppressed when is_tty=False."""
def test_no_ansi_when_not_tty(self):
fmt = TraceFormatter(mode="sim", decoder=decode_15693, is_tty=False)
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
assert "\033[" not in result
def test_ansi_present_when_tty(self):
fmt = TraceFormatter(mode="sim", decoder=decode_15693, is_tty=True)
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
assert "\033[" in result
class TestTransponderDecodeTrace:
"""Verify decode_trace on transponder hierarchy."""
def test_base_transponder_returns_none(self):
from pm3py.sim.transponder import Transponder
# Can't instantiate ABC directly, but we can check the method exists
assert hasattr(Transponder, 'decode_trace')
def test_tag15693_decodes_standard(self):
from pm3py.sim.iso15693 import Tag15693
tag = Tag15693(uid=bytes(8))
result = tag.decode_trace(0, bytes([0x26, 0x01, 0x00]))
assert result == "INVENTORY"
def test_tag15693_decodes_nxp_names(self):
"""Base Tag15693 decodes NXP command names (via standard decoder)."""
from pm3py.sim.iso15693 import Tag15693
tag = Tag15693(uid=bytes(8))
result = tag.decode_trace(0, bytes([0x22, 0xA1, 0x04, 0x02]))
assert result == "NXP FAST INVENTORY READ"
def test_nxp_icode_decodes_nxp(self):
from pm3py.sim.nxp_icode import NxpIcodeTag
tag = NxpIcodeTag(uid=b"\xE0\x04" + bytes(6))
result = tag.decode_trace(0, bytes([0x22, 0xA1, 0x04, 0x02]))
assert result == "NXP FAST INVENTORY READ"
def test_nxp_icode_falls_back_to_standard(self):
from pm3py.sim.nxp_icode import NxpIcodeTag
tag = NxpIcodeTag(uid=b"\xE0\x04" + bytes(6))
result = tag.decode_trace(0, bytes([0x26, 0x01, 0x00]))
assert result == "INVENTORY"
def test_icode_slix2_inherits_nxp(self):
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
result = tag.decode_trace(0, bytes([0x22, 0xB2, 0x04]))
assert result == "NXP GET RANDOM"
def test_tag14443a_decodes(self):
from pm3py.sim.iso14443a import Tag14443A
tag = Tag14443A(uid=bytes(4))
result = tag.decode_trace(0, bytes([0x26]))
assert result == "REQA"
def test_tag14443a_no_15693(self):
from pm3py.sim.iso14443a import Tag14443A
tag = Tag14443A(uid=bytes(4))
result = tag.decode_trace(0, bytes([0x26, 0x01, 0x00]))
# 14443a decoder sees 0x26 as REQA (single byte check happens first)
# This is fine — the protocol context determines which decoder runs
assert result is not None # it will decode as REQA since b0=0x26
from unittest.mock import MagicMock, patch
import struct
class TestSimSessionTraceWiring:
"""Verify SimSession uses TraceFormatter with tag.decode_trace."""
def _make_trace_frame(self, direction: int, payload: bytes) -> bytes:
from pm3py.core.transport import RESP_POSTAMBLE_NOCRC
from pm3py.core.protocol import RESP_PREAMBLE_MAGIC
from pm3py.sim.sim_session import CMD_HF_ISO15693_SIM_TRACE
data = bytes([direction]) + payload
length = len(data) | 0x8000
header = struct.pack("<IHbbH", RESP_PREAMBLE_MAGIC, length, 0, 0, CMD_HF_ISO15693_SIM_TRACE)
postamble = struct.pack("<H", RESP_POSTAMBLE_NOCRC)
return header + data + postamble
@patch("pm3py.sim.sim_session.TraceFormatter")
def test_trace_uses_tag_decoder(self, MockFormatter):
from pm3py.sim.sim_session import SimSession
mock_fmt = MockFormatter.return_value
session = SimSession()
session._formatter = mock_fmt
session._active = True
frame = self._make_trace_frame(0, bytes([0x26, 0x01, 0x00]))
mock_serial = MagicMock()
mock_serial.in_waiting = len(frame)
call_count = 0
def read_side_effect(n):
nonlocal call_count
call_count += 1
if call_count == 1:
return frame
session._active = False
return b""
mock_serial.read.side_effect = read_side_effect
session._trace_reader(mock_serial)
mock_fmt.print.assert_called_once_with(0, bytes([0x26, 0x01, 0x00]), crc_fail=False)