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:
146
docs/plans/2026-03-18-sim-migration.md
Normal file
146
docs/plans/2026-03-18-sim-migration.md
Normal 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
|
||||
@@ -200,6 +200,12 @@ class Cmd(IntEnum):
|
||||
HF_ISO15693_COMMAND = 0x0313
|
||||
HF_ISO15693_FINDAFI = 0x0315
|
||||
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_SIMULATE = 0x0384
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
4
pm3py/sim/access_control/__init__.py
Normal file
4
pm3py/sim/access_control/__init__.py
Normal 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
|
||||
78
pm3py/sim/access_control/credential.py
Normal file
78
pm3py/sim/access_control/credential.py
Normal 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)
|
||||
91
pm3py/sim/access_control/osdp.py
Normal file
91
pm3py/sim/access_control/osdp.py
Normal 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
|
||||
73
pm3py/sim/access_control/wiegand.py
Normal file
73
pm3py/sim/access_control/wiegand.py
Normal 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
348
pm3py/sim/auth_aes.py
Normal 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
116
pm3py/sim/auth_password.py
Normal 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
103
pm3py/sim/crypto1.py
Normal 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
276
pm3py/sim/desfire.py
Normal 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
103
pm3py/sim/dual_session.py
Normal 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
99
pm3py/sim/em4100.py
Normal 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
81
pm3py/sim/frame.py
Normal 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
97
pm3py/sim/fuzzer.py
Normal 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
121
pm3py/sim/hid.py
Normal 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
414
pm3py/sim/icode3.py
Normal 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
276
pm3py/sim/icode_dna.py
Normal 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
169
pm3py/sim/icode_slix.py
Normal 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
257
pm3py/sim/icode_slix2.py
Normal 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
66
pm3py/sim/implants.py
Normal 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
387
pm3py/sim/iso14443a.py
Normal 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
518
pm3py/sim/iso15693.py
Normal 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
69
pm3py/sim/lf_base.py
Normal 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
113
pm3py/sim/mcu_bridge.py
Normal 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
116
pm3py/sim/mcu_protocol.py
Normal 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
72
pm3py/sim/medium.py
Normal 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
70
pm3py/sim/memory.py
Normal 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
358
pm3py/sim/mifare.py
Normal 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
282
pm3py/sim/ndef.py
Normal 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
23
pm3py/sim/ntag5_boost.py
Normal 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
261
pm3py/sim/ntag5_link.py
Normal 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
446
pm3py/sim/ntag5_platform.py
Normal 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
21
pm3py/sim/ntag5_switch.py
Normal 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
86
pm3py/sim/nxp_icode.py
Normal 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
148
pm3py/sim/pm3medium.py
Normal 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
96
pm3py/sim/reader.py
Normal 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
89
pm3py/sim/relay.py
Normal 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
83
pm3py/sim/replay.py
Normal 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
387
pm3py/sim/sim_session.py
Normal 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
163
pm3py/sim/t5577.py
Normal 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
563
pm3py/sim/table_compiler.py
Normal 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
560
pm3py/sim/trace_fmt.py
Normal 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
130
pm3py/sim/transponder.py
Normal 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
150
pm3py/sim/type5.py
Normal 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
419
tests/test_sim_14443a.py
Normal 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
285
tests/test_sim_15693.py
Normal 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
|
||||
160
tests/test_sim_access_control.py
Normal file
160
tests/test_sim_access_control.py
Normal 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
189
tests/test_sim_advanced.py
Normal 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
420
tests/test_sim_aes_auth.py
Normal 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
181
tests/test_sim_desfire.py
Normal 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"]
|
||||
211
tests/test_sim_dual_session.py
Normal file
211
tests/test_sim_dual_session.py
Normal 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
167
tests/test_sim_frame.py
Normal 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
638
tests/test_sim_icode3.py
Normal 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
|
||||
341
tests/test_sim_icode_slix.py
Normal file
341
tests/test_sim_icode_slix.py
Normal 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
105
tests/test_sim_implants.py
Normal 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
218
tests/test_sim_lf.py
Normal 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
|
||||
251
tests/test_sim_mcu_bridge.py
Normal file
251
tests/test_sim_mcu_bridge.py
Normal 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
227
tests/test_sim_medium.py
Normal 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
210
tests/test_sim_memory.py
Normal 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
313
tests/test_sim_mifare.py
Normal 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
189
tests/test_sim_ndef.py
Normal 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
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
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
149
tests/test_sim_pm3medium.py
Normal 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
214
tests/test_sim_reader.py
Normal 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
|
||||
599
tests/test_sim_table_compiler.py
Normal file
599
tests/test_sim_table_compiler.py
Normal 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
578
tests/test_sim_trace_fmt.py
Normal 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)
|
||||
Reference in New Issue
Block a user