From 668170457e7a96fd416829f0db5770ea5ec6d840 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 18 Mar 2026 20:04:55 -0700 Subject: [PATCH] feat: migrate sim framework from worktree to master MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/plans/2026-03-18-sim-migration.md | 146 ++ pm3py/core/protocol.py | 6 + pm3py/sim/__init__.py | 69 +- pm3py/sim/access_control/__init__.py | 4 + pm3py/sim/access_control/credential.py | 78 ++ pm3py/sim/access_control/osdp.py | 91 ++ pm3py/sim/access_control/wiegand.py | 73 + pm3py/sim/auth_aes.py | 348 +++++ pm3py/sim/auth_password.py | 116 ++ pm3py/sim/crypto1.py | 103 ++ pm3py/sim/desfire.py | 276 ++++ pm3py/sim/dual_session.py | 103 ++ pm3py/sim/em4100.py | 99 ++ pm3py/sim/frame.py | 81 ++ pm3py/sim/fuzzer.py | 97 ++ pm3py/sim/hid.py | 121 ++ pm3py/sim/icode3.py | 414 ++++++ pm3py/sim/icode_dna.py | 276 ++++ pm3py/sim/icode_slix.py | 169 +++ pm3py/sim/icode_slix2.py | 257 ++++ pm3py/sim/implants.py | 66 + pm3py/sim/iso14443a.py | 387 ++++++ pm3py/sim/iso15693.py | 518 ++++++++ pm3py/sim/lf_base.py | 69 + pm3py/sim/mcu_bridge.py | 113 ++ pm3py/sim/mcu_protocol.py | 116 ++ pm3py/sim/medium.py | 72 + pm3py/sim/memory.py | 70 + pm3py/sim/mifare.py | 358 +++++ pm3py/sim/ndef.py | 282 ++++ pm3py/sim/ntag5_boost.py | 23 + pm3py/sim/ntag5_link.py | 261 ++++ pm3py/sim/ntag5_platform.py | 446 +++++++ pm3py/sim/ntag5_switch.py | 21 + pm3py/sim/nxp_icode.py | 86 ++ pm3py/sim/pm3medium.py | 148 +++ pm3py/sim/reader.py | 96 ++ pm3py/sim/relay.py | 89 ++ pm3py/sim/replay.py | 83 ++ pm3py/sim/sim_session.py | 387 ++++++ pm3py/sim/t5577.py | 163 +++ pm3py/sim/table_compiler.py | 563 ++++++++ pm3py/sim/trace_fmt.py | 560 ++++++++ pm3py/sim/transponder.py | 130 ++ pm3py/sim/type5.py | 150 +++ tests/test_sim_14443a.py | 419 ++++++ tests/test_sim_15693.py | 285 ++++ tests/test_sim_access_control.py | 160 +++ tests/test_sim_advanced.py | 189 +++ tests/test_sim_aes_auth.py | 420 ++++++ tests/test_sim_desfire.py | 181 +++ tests/test_sim_dual_session.py | 211 +++ tests/test_sim_frame.py | 167 +++ tests/test_sim_icode3.py | 638 +++++++++ tests/test_sim_icode_slix.py | 341 +++++ tests/test_sim_implants.py | 105 ++ tests/test_sim_lf.py | 218 +++ tests/test_sim_mcu_bridge.py | 251 ++++ tests/test_sim_medium.py | 227 ++++ tests/test_sim_memory.py | 210 +++ tests/test_sim_mifare.py | 313 +++++ tests/test_sim_ndef.py | 189 +++ tests/test_sim_ntag5.py | 1688 ++++++++++++++++++++++++ tests/test_sim_nxp_icode.py | 1016 ++++++++++++++ tests/test_sim_pm3medium.py | 149 +++ tests/test_sim_reader.py | 214 +++ tests/test_sim_table_compiler.py | 599 +++++++++ tests/test_sim_trace_fmt.py | 578 ++++++++ 68 files changed, 16951 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-03-18-sim-migration.md create mode 100644 pm3py/sim/access_control/__init__.py create mode 100644 pm3py/sim/access_control/credential.py create mode 100644 pm3py/sim/access_control/osdp.py create mode 100644 pm3py/sim/access_control/wiegand.py create mode 100644 pm3py/sim/auth_aes.py create mode 100644 pm3py/sim/auth_password.py create mode 100644 pm3py/sim/crypto1.py create mode 100644 pm3py/sim/desfire.py create mode 100644 pm3py/sim/dual_session.py create mode 100644 pm3py/sim/em4100.py create mode 100644 pm3py/sim/frame.py create mode 100644 pm3py/sim/fuzzer.py create mode 100644 pm3py/sim/hid.py create mode 100644 pm3py/sim/icode3.py create mode 100644 pm3py/sim/icode_dna.py create mode 100644 pm3py/sim/icode_slix.py create mode 100644 pm3py/sim/icode_slix2.py create mode 100644 pm3py/sim/implants.py create mode 100644 pm3py/sim/iso14443a.py create mode 100644 pm3py/sim/iso15693.py create mode 100644 pm3py/sim/lf_base.py create mode 100644 pm3py/sim/mcu_bridge.py create mode 100644 pm3py/sim/mcu_protocol.py create mode 100644 pm3py/sim/medium.py create mode 100644 pm3py/sim/memory.py create mode 100644 pm3py/sim/mifare.py create mode 100644 pm3py/sim/ndef.py create mode 100644 pm3py/sim/ntag5_boost.py create mode 100644 pm3py/sim/ntag5_link.py create mode 100644 pm3py/sim/ntag5_platform.py create mode 100644 pm3py/sim/ntag5_switch.py create mode 100644 pm3py/sim/nxp_icode.py create mode 100644 pm3py/sim/pm3medium.py create mode 100644 pm3py/sim/reader.py create mode 100644 pm3py/sim/relay.py create mode 100644 pm3py/sim/replay.py create mode 100644 pm3py/sim/sim_session.py create mode 100644 pm3py/sim/t5577.py create mode 100644 pm3py/sim/table_compiler.py create mode 100644 pm3py/sim/trace_fmt.py create mode 100644 pm3py/sim/transponder.py create mode 100644 pm3py/sim/type5.py create mode 100644 tests/test_sim_14443a.py create mode 100644 tests/test_sim_15693.py create mode 100644 tests/test_sim_access_control.py create mode 100644 tests/test_sim_advanced.py create mode 100644 tests/test_sim_aes_auth.py create mode 100644 tests/test_sim_desfire.py create mode 100644 tests/test_sim_dual_session.py create mode 100644 tests/test_sim_frame.py create mode 100644 tests/test_sim_icode3.py create mode 100644 tests/test_sim_icode_slix.py create mode 100644 tests/test_sim_implants.py create mode 100644 tests/test_sim_lf.py create mode 100644 tests/test_sim_mcu_bridge.py create mode 100644 tests/test_sim_medium.py create mode 100644 tests/test_sim_memory.py create mode 100644 tests/test_sim_mifare.py create mode 100644 tests/test_sim_ndef.py create mode 100644 tests/test_sim_ntag5.py create mode 100644 tests/test_sim_nxp_icode.py create mode 100644 tests/test_sim_pm3medium.py create mode 100644 tests/test_sim_reader.py create mode 100644 tests/test_sim_table_compiler.py create mode 100644 tests/test_sim_trace_fmt.py diff --git a/docs/plans/2026-03-18-sim-migration.md b/docs/plans/2026-03-18-sim-migration.md new file mode 100644 index 0000000..39ffa91 --- /dev/null +++ b/docs/plans/2026-03-18-sim-migration.md @@ -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 diff --git a/pm3py/core/protocol.py b/pm3py/core/protocol.py index c94e5dd..a9fd85c 100644 --- a/pm3py/core/protocol.py +++ b/pm3py/core/protocol.py @@ -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 diff --git a/pm3py/sim/__init__.py b/pm3py/sim/__init__.py index 314f07a..cd0739c 100644 --- a/pm3py/sim/__init__.py +++ b/pm3py/sim/__init__.py @@ -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", +] diff --git a/pm3py/sim/access_control/__init__.py b/pm3py/sim/access_control/__init__.py new file mode 100644 index 0000000..a511276 --- /dev/null +++ b/pm3py/sim/access_control/__init__.py @@ -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 diff --git a/pm3py/sim/access_control/credential.py b/pm3py/sim/access_control/credential.py new file mode 100644 index 0000000..e06fedb --- /dev/null +++ b/pm3py/sim/access_control/credential.py @@ -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) diff --git a/pm3py/sim/access_control/osdp.py b/pm3py/sim/access_control/osdp.py new file mode 100644 index 0000000..d387f91 --- /dev/null +++ b/pm3py/sim/access_control/osdp.py @@ -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(" 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(" 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 diff --git a/pm3py/sim/access_control/wiegand.py b/pm3py/sim/access_control/wiegand.py new file mode 100644 index 0000000..c86f83b --- /dev/null +++ b/pm3py/sim/access_control/wiegand.py @@ -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() diff --git a/pm3py/sim/auth_aes.py b/pm3py/sim/auth_aes.py new file mode 100644 index 0000000..ccfbb33 --- /dev/null +++ b/pm3py/sim/auth_aes.py @@ -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])) diff --git a/pm3py/sim/auth_password.py b/pm3py/sim/auth_password.py new file mode 100644 index 0000000..47d71ec --- /dev/null +++ b/pm3py/sim/auth_password.py @@ -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(" 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(" 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])) diff --git a/pm3py/sim/crypto1.py b/pm3py/sim/crypto1.py new file mode 100644 index 0000000..0430f87 --- /dev/null +++ b/pm3py/sim/crypto1.py @@ -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) diff --git a/pm3py/sim/desfire.py b/pm3py/sim/desfire.py new file mode 100644 index 0000000..c2dc0d7 --- /dev/null +++ b/pm3py/sim/desfire.py @@ -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} diff --git a/pm3py/sim/dual_session.py b/pm3py/sim/dual_session.py new file mode 100644 index 0000000..c4803e5 --- /dev/null +++ b/pm3py/sim/dual_session.py @@ -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) diff --git a/pm3py/sim/em4100.py b/pm3py/sim/em4100.py new file mode 100644 index 0000000..4a80f95 --- /dev/null +++ b/pm3py/sim/em4100.py @@ -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} diff --git a/pm3py/sim/frame.py b/pm3py/sim/frame.py new file mode 100644 index 0000000..9d919f3 --- /dev/null +++ b/pm3py/sim/frame.py @@ -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})" diff --git a/pm3py/sim/fuzzer.py b/pm3py/sim/fuzzer.py new file mode 100644 index 0000000..ffd5025 --- /dev/null +++ b/pm3py/sim/fuzzer.py @@ -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 diff --git a/pm3py/sim/hid.py b/pm3py/sim/hid.py new file mode 100644 index 0000000..48e63a3 --- /dev/null +++ b/pm3py/sim/hid.py @@ -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) diff --git a/pm3py/sim/icode3.py b/pm3py/sim/icode3.py new file mode 100644 index 0000000..a02d116 --- /dev/null +++ b/pm3py/sim/icode3.py @@ -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(" 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(" 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(" 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)) diff --git a/pm3py/sim/icode_slix2.py b/pm3py/sim/icode_slix2.py new file mode 100644 index 0000000..cb10003 --- /dev/null +++ b/pm3py/sim/icode_slix2.py @@ -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(" 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(" 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) diff --git a/pm3py/sim/implants.py b/pm3py/sim/implants.py new file mode 100644 index 0000000..0c62b94 --- /dev/null +++ b/pm3py/sim/implants.py @@ -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 diff --git a/pm3py/sim/iso14443a.py b/pm3py/sim/iso14443a.py new file mode 100644 index 0000000..2e546f1 --- /dev/null +++ b/pm3py/sim/iso14443a.py @@ -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:] diff --git a/pm3py/sim/iso15693.py b/pm3py/sim/iso15693.py new file mode 100644 index 0000000..1831ba1 --- /dev/null +++ b/pm3py/sim/iso15693.py @@ -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(" 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(" 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(" 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("= 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(" 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, + } diff --git a/pm3py/sim/lf_base.py b/pm3py/sim/lf_base.py new file mode 100644 index 0000000..c83115f --- /dev/null +++ b/pm3py/sim/lf_base.py @@ -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} diff --git a/pm3py/sim/mcu_bridge.py b/pm3py/sim/mcu_bridge.py new file mode 100644 index 0000000..b6f3bd5 --- /dev/null +++ b/pm3py/sim/mcu_bridge.py @@ -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) diff --git a/pm3py/sim/mcu_protocol.py b/pm3py/sim/mcu_protocol.py new file mode 100644 index 0000000..62013bc --- /dev/null +++ b/pm3py/sim/mcu_protocol.py @@ -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 diff --git a/pm3py/sim/medium.py b/pm3py/sim/medium.py new file mode 100644 index 0000000..ae3e671 --- /dev/null +++ b/pm3py/sim/medium.py @@ -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) diff --git a/pm3py/sim/memory.py b/pm3py/sim/memory.py new file mode 100644 index 0000000..c9d8e93 --- /dev/null +++ b/pm3py/sim/memory.py @@ -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 diff --git a/pm3py/sim/mifare.py b/pm3py/sim/mifare.py new file mode 100644 index 0000000..488014f --- /dev/null +++ b/pm3py/sim/mifare.py @@ -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} diff --git a/pm3py/sim/ndef.py b/pm3py/sim/ndef.py new file mode 100644 index 0000000..c78caa7 --- /dev/null +++ b/pm3py/sim/ndef.py @@ -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 diff --git a/pm3py/sim/ntag5_boost.py b/pm3py/sim/ntag5_boost.py new file mode 100644 index 0000000..abc130c --- /dev/null +++ b/pm3py/sim/ntag5_boost.py @@ -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) diff --git a/pm3py/sim/ntag5_link.py b/pm3py/sim/ntag5_link.py new file mode 100644 index 0000000..c69bf2a --- /dev/null +++ b/pm3py/sim/ntag5_link.py @@ -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])) diff --git a/pm3py/sim/ntag5_platform.py b/pm3py/sim/ntag5_platform.py new file mode 100644 index 0000000..90113e2 --- /dev/null +++ b/pm3py/sim/ntag5_platform.py @@ -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 diff --git a/pm3py/sim/ntag5_switch.py b/pm3py/sim/ntag5_switch.py new file mode 100644 index 0000000..771caab --- /dev/null +++ b/pm3py/sim/ntag5_switch.py @@ -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) diff --git a/pm3py/sim/nxp_icode.py b/pm3py/sim/nxp_icode.py new file mode 100644 index 0000000..cb45901 --- /dev/null +++ b/pm3py/sim/nxp_icode.py @@ -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 diff --git a/pm3py/sim/pm3medium.py b/pm3py/sim/pm3medium.py new file mode 100644 index 0000000..dd770ae --- /dev/null +++ b/pm3py/sim/pm3medium.py @@ -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(" 0: + self._last_resp = RFFrame.from_bytes(resp.data) + else: + self._last_resp = None diff --git a/pm3py/sim/reader.py b/pm3py/sim/reader.py new file mode 100644 index 0000000..8f2182a --- /dev/null +++ b/pm3py/sim/reader.py @@ -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) diff --git a/pm3py/sim/relay.py b/pm3py/sim/relay.py new file mode 100644 index 0000000..7d1b8c9 --- /dev/null +++ b/pm3py/sim/relay.py @@ -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)) diff --git a/pm3py/sim/replay.py b/pm3py/sim/replay.py new file mode 100644 index 0000000..f4d7554 --- /dev/null +++ b/pm3py/sim/replay.py @@ -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 diff --git a/pm3py/sim/sim_session.py b/pm3py/sim/sim_session.py new file mode 100644 index 0000000..9ff4c10 --- /dev/null +++ b/pm3py/sim/sim_session.py @@ -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(" 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(" 0: + buf = buf[idx:] + + if len(buf) < RESP_PREAMBLE_SIZE: + break + + length_ng = struct.unpack_from("= 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(" 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 diff --git a/pm3py/sim/t5577.py b/pm3py/sim/t5577.py new file mode 100644 index 0000000..2c7985b --- /dev/null +++ b/pm3py/sim/t5577.py @@ -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} diff --git a/pm3py/sim/table_compiler.py b/pm3py/sim/table_compiler.py new file mode 100644 index 0000000..41f2ef6 --- /dev/null +++ b/pm3py/sim/table_compiler.py @@ -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(' 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(' 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(" 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(" 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(" 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) diff --git a/pm3py/sim/trace_fmt.py b/pm3py/sim/trace_fmt.py new file mode 100644 index 0000000..477c9b1 --- /dev/null +++ b/pm3py/sim/trace_fmt.py @@ -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() diff --git a/pm3py/sim/transponder.py b/pm3py/sim/transponder.py new file mode 100644 index 0000000..b3700df --- /dev/null +++ b/pm3py/sim/transponder.py @@ -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(" 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) diff --git a/pm3py/sim/type5.py b/pm3py/sim/type5.py new file mode 100644 index 0000000..4daee9f --- /dev/null +++ b/pm3py/sim/type5.py @@ -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) diff --git a/tests/test_sim_14443a.py b/tests/test_sim_14443a.py new file mode 100644 index 0000000..204d86a --- /dev/null +++ b/tests/test_sim_14443a.py @@ -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 diff --git a/tests/test_sim_15693.py b/tests/test_sim_15693.py new file mode 100644 index 0000000..0d7b24a --- /dev/null +++ b/tests/test_sim_15693.py @@ -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 diff --git a/tests/test_sim_access_control.py b/tests/test_sim_access_control.py new file mode 100644 index 0000000..adceb70 --- /dev/null +++ b/tests/test_sim_access_control.py @@ -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 diff --git a/tests/test_sim_advanced.py b/tests/test_sim_advanced.py new file mode 100644 index 0000000..4ce3d37 --- /dev/null +++ b/tests/test_sim_advanced.py @@ -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) diff --git a/tests/test_sim_aes_auth.py b/tests/test_sim_aes_auth.py new file mode 100644 index 0000000..5b1f702 --- /dev/null +++ b/tests/test_sim_aes_auth.py @@ -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 diff --git a/tests/test_sim_desfire.py b/tests/test_sim_desfire.py new file mode 100644 index 0000000..db26e44 --- /dev/null +++ b/tests/test_sim_desfire.py @@ -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"] diff --git a/tests/test_sim_dual_session.py b/tests/test_sim_dual_session.py new file mode 100644 index 0000000..64e6ea8 --- /dev/null +++ b/tests/test_sim_dual_session.py @@ -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 diff --git a/tests/test_sim_frame.py b/tests/test_sim_frame.py new file mode 100644 index 0000000..007e281 --- /dev/null +++ b/tests/test_sim_frame.py @@ -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 diff --git a/tests/test_sim_icode3.py b/tests/test_sim_icode3.py new file mode 100644 index 0000000..676bb56 --- /dev/null +++ b/tests/test_sim_icode3.py @@ -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("= 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(" 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("= 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 diff --git a/tests/test_sim_implants.py b/tests/test_sim_implants.py new file mode 100644 index 0000000..ebb7366 --- /dev/null +++ b/tests/test_sim_implants.py @@ -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 diff --git a/tests/test_sim_lf.py b/tests/test_sim_lf.py new file mode 100644 index 0000000..5d92665 --- /dev/null +++ b/tests/test_sim_lf.py @@ -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 diff --git a/tests/test_sim_mcu_bridge.py b/tests/test_sim_mcu_bridge.py new file mode 100644 index 0000000..9f0a368 --- /dev/null +++ b/tests/test_sim_mcu_bridge.py @@ -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') diff --git a/tests/test_sim_medium.py b/tests/test_sim_medium.py new file mode 100644 index 0000000..c3eae97 --- /dev/null +++ b/tests/test_sim_medium.py @@ -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 diff --git a/tests/test_sim_memory.py b/tests/test_sim_memory.py new file mode 100644 index 0000000..c8630c2 --- /dev/null +++ b/tests/test_sim_memory.py @@ -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 diff --git a/tests/test_sim_mifare.py b/tests/test_sim_mifare.py new file mode 100644 index 0000000..015ee62 --- /dev/null +++ b/tests/test_sim_mifare.py @@ -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"] diff --git a/tests/test_sim_ndef.py b/tests/test_sim_ndef.py new file mode 100644 index 0000000..da1b599 --- /dev/null +++ b/tests/test_sim_ndef.py @@ -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 diff --git a/tests/test_sim_ntag5.py b/tests/test_sim_ntag5.py new file mode 100644 index 0000000..adfa708 --- /dev/null +++ b/tests/test_sim_ntag5.py @@ -0,0 +1,1688 @@ +"""Tests for NTAG 5 family: IcodeDnaTag, Switch, Link, Boost.""" +import asyncio +import pytest + +from pm3py.sim.frame import RFFrame + + +def run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +# --------------------------------------------------------------------------- +# ICODE DNA +# --------------------------------------------------------------------------- + +class TestIcodeDna: + def test_inherits_slix2(self): + from pm3py.sim.icode_dna import IcodeDnaTag + from pm3py.sim.icode_slix2 import IcodeSlix2Tag + assert issubclass(IcodeDnaTag, IcodeSlix2Tag) + + def test_default_64_blocks(self): + from pm3py.sim.icode_dna import IcodeDnaTag + tag = IcodeDnaTag() + assert tag._num_blocks == 64 + + def test_uid_prefix(self): + from pm3py.sim.icode_dna import IcodeDnaTag + tag = IcodeDnaTag() + assert tag._uid[:4] == b"\xE0\x04\x01\x18" + + def test_counter_on_last_block(self): + from pm3py.sim.icode_dna import IcodeDnaTag + tag = IcodeDnaTag() + user = tag.regions["user"] + assert user.access_for_block(63).write_mode == "counter" + + def test_4_aes_keys(self): + from pm3py.sim.icode_dna import IcodeDnaTag + tag = IcodeDnaTag() + assert len(tag._aes_keys) == 4 + assert all(k is None for k in tag._aes_keys) + + def test_aes_keys_from_constructor(self): + from pm3py.sim.icode_dna import IcodeDnaTag + key0 = b"\xAA" * 16 + tag = IcodeDnaTag(aes_keys=[key0, None, None, None]) + assert tag._aes_keys[0] == key0 + + def test_config_memory_48_blocks(self): + from pm3py.sim.icode_dna import IcodeDnaTag + tag = IcodeDnaTag() + assert len(tag._config_memory) == 48 * 4 + + def test_read_config(self): + from pm3py.sim.icode_dna import IcodeDnaTag + tag = IcodeDnaTag() + run(tag.power_on()) + # Read config header block 0x08 + cmd = bytes([0x02, 0xC0, 0x04, 0x08, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 + assert resp.data[1] == 0x81 # CONFIG_HEADER_WRITABLE default + + def test_write_config(self): + from pm3py.sim.icode_dna import IcodeDnaTag + tag = IcodeDnaTag() + run(tag.power_on()) + # Write CID to block 0x09 + cmd = bytes([0x02, 0xC1, 0x04, 0x09, 0x53, 0xD0, 0x00, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + # Read back + cmd = bytes([0x02, 0xC0, 0x04, 0x09, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[1:3] == bytes([0x53, 0xD0]) + + def test_key_storage_masked_when_active(self): + """Key blocks return zeros when key header is active.""" + from pm3py.sim.icode_dna import IcodeDnaTag, KEY_HEADER_ACTIVE_LOCKED + tag = IcodeDnaTag(aes_keys=[b"\xAA" * 16, None, None, None]) + run(tag.power_on()) + + # Key0 readable when header is NOT active + cmd = bytes([0x02, 0xC0, 0x04, 0x20, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[1] == 0xAA # readable + + # Activate key header + tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED + cmd = bytes([0x02, 0xC0, 0x04, 0x20, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[1:5] == bytes(4) # masked + + def test_key_write_rejected_when_active(self): + """Cannot write key blocks when header is active.""" + from pm3py.sim.icode_dna import IcodeDnaTag, KEY_HEADER_ACTIVE_LOCKED + tag = IcodeDnaTag() + run(tag.power_on()) + tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED + + cmd = bytes([0x02, 0xC1, 0x04, 0x20, 0xFF, 0xFF, 0xFF, 0xFF]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] & 0x01 # error + + def test_key_header_one_way(self): + """Key header only goes up (lower → higher values).""" + from pm3py.sim.icode_dna import IcodeDnaTag, KEY_HEADER_ACTIVE_LOCKED + tag = IcodeDnaTag() + run(tag.power_on()) + + # Write key header to 0xE7 (active) + cmd = bytes([0x02, 0xC1, 0x04, 0x10, 0x00, 0xE7, 0x00, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + assert tag._key_headers[0] == KEY_HEADER_ACTIVE_LOCKED + + def test_gch_one_way(self): + """NFC_GCH only programs one-way.""" + from pm3py.sim.icode_dna import IcodeDnaTag, GCH_ACTIVATED + tag = IcodeDnaTag() + run(tag.power_on()) + + # Write GCH to 0xC1 (activated) + cmd = bytes([0x02, 0xC1, 0x04, 0x0C, 0x00, 0xC1, 0x00, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + assert tag._nfc_gch == GCH_ACTIVATED + + def test_cid_default(self): + from pm3py.sim.icode_dna import IcodeDnaTag + tag = IcodeDnaTag() + assert tag._cid == 0xC000 + + def test_responds_to_inventory(self): + from pm3py.sim.icode_dna import IcodeDnaTag + tag = IcodeDnaTag() + run(tag.power_on()) + resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x26, 0x01, 0x00])))) + assert resp is not None + + def test_inherits_eas(self): + from pm3py.sim.icode_dna import IcodeDnaTag + tag = IcodeDnaTag() + run(tag.power_on()) + cmd = bytes([0x02, 0xA2, 0x04]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + assert tag.eas_enabled + + +# --------------------------------------------------------------------------- +# NTAG 5 Platform (base) +# --------------------------------------------------------------------------- + +class TestNtag5Platform: + def test_config_memory(self): + from pm3py.sim.ntag5_platform import Ntag5PlatformTag + tag = Ntag5PlatformTag(num_blocks=128) + assert len(tag._config_memory) >= 160 * 4 + + def test_read_config(self): + from pm3py.sim.ntag5_platform import Ntag5PlatformTag + tag = Ntag5PlatformTag(num_blocks=128) + run(tag.power_on()) + cmd = bytes([0x02, 0xC0, 0x04, 0x3F, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 + assert resp.data[1] == 0xA5 # DEV_SEC_CONFIG default + + def test_write_config(self): + from pm3py.sim.ntag5_platform import Ntag5PlatformTag + tag = Ntag5PlatformTag(num_blocks=128) + run(tag.power_on()) + cmd = bytes([0x02, 0xC1, 0x04, 0x57, 0x34, 0x12, 0x00, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + # Read back + cmd = bytes([0x02, 0xC0, 0x04, 0x57, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[1:3] == bytes([0x34, 0x12]) + + +# --------------------------------------------------------------------------- +# NTAG 5 Switch +# --------------------------------------------------------------------------- + +class TestNtag5Switch: + def test_inherits_platform(self): + from pm3py.sim.ntag5_switch import Ntag5SwitchTag + from pm3py.sim.ntag5_platform import Ntag5PlatformTag + assert issubclass(Ntag5SwitchTag, Ntag5PlatformTag) + + def test_default_128_blocks(self): + from pm3py.sim.ntag5_switch import Ntag5SwitchTag + tag = Ntag5SwitchTag() + assert tag._num_blocks == 128 + + def test_no_aes(self): + from pm3py.sim.ntag5_switch import Ntag5SwitchTag + tag = Ntag5SwitchTag() + assert not tag._aes_capable + + def test_counter_on_block_127(self): + from pm3py.sim.ntag5_switch import Ntag5SwitchTag + tag = Ntag5SwitchTag() + user = tag.regions["user"] + assert user.access_for_block(127).write_mode == "counter" + assert user.access_for_block(0).write_mode == "normal" + + def test_responds_to_inventory(self): + from pm3py.sim.ntag5_switch import Ntag5SwitchTag + tag = Ntag5SwitchTag() + run(tag.power_on()) + resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x26, 0x01, 0x00])))) + assert resp is not None + + def test_switch_rejects_stay_quiet_persistent(self): + from pm3py.sim.ntag5_switch import Ntag5SwitchTag + tag = Ntag5SwitchTag() + run(tag.power_on()) + cmd = bytes([0x22, 0xBC]) + tag._uid[::-1] + bytes([0x04]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] & 0x01 # error flag set + + +# --------------------------------------------------------------------------- +# NTAG 5 Link +# --------------------------------------------------------------------------- + +class TestNtag5Link: + def test_inherits_platform(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + from pm3py.sim.ntag5_platform import Ntag5PlatformTag + assert issubclass(Ntag5LinkTag, Ntag5PlatformTag) + + def test_default_512_blocks(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + assert tag._num_blocks == 512 + + def test_i2c_address(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + assert tag.i2c_address == 0x54 + + def test_sram(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + assert len(tag.sram) == 256 + + def test_i2c_sram_roundtrip(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + tag.i2c_write(0x2000, b"\xAA\xBB\xCC\xDD") + assert tag.i2c_read(0x2000, 4) == b"\xAA\xBB\xCC\xDD" + + def test_sram_mirror_read(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag, ARBITER_MIRROR + tag = Ntag5LinkTag() + run(tag.power_on()) + tag._sram_enabled = True + tag._arbiter_mode = ARBITER_MIRROR + tag._sram[0:4] = b"\xDE\xAD\xBE\xEF" + cmd = bytes([0x02, 0x20, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[1:5] == b"\xDE\xAD\xBE\xEF" + + def test_password_only_variant(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag(aes_capable=False) + assert not tag._aes_capable + + def test_aes_variant(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag(aes_capable=True) + assert tag._aes_capable + + +# --------------------------------------------------------------------------- +# Arbitration +# --------------------------------------------------------------------------- + +class TestArbitration: + def test_acquire_nfc_succeeds_no_lock(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + assert tag.acquire_nfc() is True + assert tag.nfc_if_locked is True + + def test_acquire_nfc_fails_when_i2c_locked(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + tag.set_i2c_locked(True) + assert tag.acquire_nfc() is False + assert tag.nfc_if_locked is False + + def test_acquire_i2c_succeeds_no_lock(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + assert tag.acquire_i2c() is True + assert tag.i2c_if_locked is True + + def test_acquire_i2c_fails_when_nfc_locked(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + tag.set_nfc_locked(True) + assert tag.acquire_i2c() is False + assert tag.i2c_if_locked is False + + def test_release_clears_lock(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + tag.acquire_nfc() + assert tag.nfc_if_locked is True + tag.release_nfc() + assert tag.nfc_if_locked is False + + tag.acquire_i2c() + assert tag.i2c_if_locked is True + tag.release_i2c() + assert tag.i2c_if_locked is False + + def test_nfc_sram_read_blocked_when_i2c_locked(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag, ARBITER_MIRROR + tag = Ntag5LinkTag() + run(tag.power_on()) + tag._sram_enabled = True + tag._arbiter_mode = ARBITER_MIRROR + tag._sram[0:4] = b"\xDE\xAD\xBE\xEF" + tag.set_i2c_locked(True) + cmd = bytes([0x02, 0x20, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + # Error response: flags=0x01, error_code=0x0F (not supported) + assert resp.data[0] == 0x01 + assert resp.data[1] == 0x0F + + def test_nfc_sram_read_works_with_nfc_lock(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag, ARBITER_MIRROR + tag = Ntag5LinkTag() + run(tag.power_on()) + tag._sram_enabled = True + tag._arbiter_mode = ARBITER_MIRROR + tag._sram[0:4] = b"\xDE\xAD\xBE\xEF" + tag.acquire_nfc() + cmd = bytes([0x02, 0x20, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + assert resp.data[1:5] == b"\xDE\xAD\xBE\xEF" + + def test_i2c_sram_read_blocked_when_nfc_locked(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + tag.set_nfc_locked(True) + result = tag.i2c_read(0x2000, 4) + assert result is None + + def test_i2c_sram_write_blocked_when_nfc_locked(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + tag.set_nfc_locked(True) + result = tag.i2c_write(0x2000, b"\x01\x02\x03\x04") + assert result is False + + def test_lock_unlock_sequential_access(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + + # NFC acquires, writes via SRAM directly, releases + assert tag.acquire_nfc() is True + tag._sram[0:4] = b"\xAA\xBB\xCC\xDD" + tag.release_nfc() + + # I2C acquires, reads SRAM, releases + assert tag.acquire_i2c() is True + data = tag.i2c_read(0x2000, 4) + assert data == b"\xAA\xBB\xCC\xDD" + tag.release_i2c() + + # NFC can acquire again + assert tag.acquire_nfc() is True + tag.release_nfc() + + +# --------------------------------------------------------------------------- +# NTAG 5 Boost +# --------------------------------------------------------------------------- + +class TestNtag5Boost: + def test_inherits_link(self): + from pm3py.sim.ntag5_boost import Ntag5BoostTag + from pm3py.sim.ntag5_link import Ntag5LinkTag + assert issubclass(Ntag5BoostTag, Ntag5LinkTag) + + def test_aes_always(self): + from pm3py.sim.ntag5_boost import Ntag5BoostTag + tag = Ntag5BoostTag() + assert tag._aes_capable + + def test_default_512_blocks(self): + from pm3py.sim.ntag5_boost import Ntag5BoostTag + tag = Ntag5BoostTag() + assert tag._num_blocks == 512 + + def test_has_sram(self): + from pm3py.sim.ntag5_boost import Ntag5BoostTag + tag = Ntag5BoostTag() + assert len(tag.sram) == 256 + + def test_boost_rejects_stay_quiet_persistent(self): + from pm3py.sim.ntag5_boost import Ntag5BoostTag + tag = Ntag5BoostTag() + run(tag.power_on()) + cmd = bytes([0x22, 0xBC]) + tag._uid[::-1] + bytes([0x04]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] & 0x01 # error flag set + + +# --------------------------------------------------------------------------- +# Session registers +# --------------------------------------------------------------------------- + +class TestSessionRegisters: + """Session register state machine for NTAG 5 platform.""" + + def _make_tag(self): + from pm3py.sim.ntag5_platform import Ntag5PlatformTag + tag = Ntag5PlatformTag(num_blocks=128) + run(tag.power_on()) + return tag + + def test_default_register_values(self): + """Default: NFC_FIELD_OK=0, VCC=0, no locks, no boot.""" + tag = self._make_tag() + assert tag.nfc_field_ok is False + assert tag.nfc_boot_ok is False + assert tag.vcc_supply_ok is False + assert tag.vcc_boot_ok is False + assert tag.i2c_if_locked is False + assert tag.nfc_if_locked is False + assert tag.eeprom_wr_busy is False + assert tag.eeprom_wr_error is False + assert tag.sram_data_ready is False + assert tag.synch_block_write is False + assert tag.synch_block_read is False + assert tag.pt_transfer_dir is False + + def test_set_nfc_field_true(self): + """set_nfc_field(True) sets NFC_FIELD_OK and NFC_BOOT_OK.""" + tag = self._make_tag() + tag.set_nfc_field(True) + assert tag.nfc_field_ok is True + assert tag.nfc_boot_ok is True + + def test_set_nfc_field_false(self): + """set_nfc_field(False) clears NFC_FIELD_OK but keeps NFC_BOOT_OK.""" + tag = self._make_tag() + tag.set_nfc_field(True) + tag.set_nfc_field(False) + assert tag.nfc_field_ok is False + # Boot flag stays set once set (POR clears it) + assert tag.nfc_boot_ok is True + + def test_set_vcc_supply_true(self): + """set_vcc_supply(True) sets VCC_SUPPLY_OK and VCC_BOOT_OK.""" + tag = self._make_tag() + tag.set_vcc_supply(True) + assert tag.vcc_supply_ok is True + assert tag.vcc_boot_ok is True + + def test_set_vcc_supply_false(self): + """set_vcc_supply(False) clears VCC_SUPPLY_OK but keeps VCC_BOOT_OK.""" + tag = self._make_tag() + tag.set_vcc_supply(True) + tag.set_vcc_supply(False) + assert tag.vcc_supply_ok is False + assert tag.vcc_boot_ok is True + + def test_set_i2c_locked(self): + """set_i2c_locked(True) sets I2C_IF_LOCKED bit.""" + tag = self._make_tag() + tag.set_i2c_locked(True) + assert tag.i2c_if_locked is True + tag.set_i2c_locked(False) + assert tag.i2c_if_locked is False + + def test_set_nfc_locked(self): + """set_nfc_locked(True) sets NFC_IF_LOCKED bit.""" + tag = self._make_tag() + tag.set_nfc_locked(True) + assert tag.nfc_if_locked is True + tag.set_nfc_locked(False) + assert tag.nfc_if_locked is False + + def test_read_config_session_register(self): + """READ CONFIG at block 0xA0 returns status register bytes.""" + tag = self._make_tag() + tag.set_nfc_field(True) + tag.set_vcc_supply(True) + # READ CONFIG: flags=0x02 cmd=0xC0 mfg=0x04 block=0xA0 count=0x00 (1 block) + cmd = bytes([0x02, 0xC0, 0x04, 0xA0, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 # no error + # Byte 0 of status reg: NFC_FIELD_OK (bit 0) + VCC_SUPPLY_OK (bit 1) = 0x03 + assert resp.data[1] & 0x03 == 0x03 + # Byte 1 of status reg: VCC_BOOT_OK (bit 7) + NFC_BOOT_OK (bit 6) = 0xC0 + assert resp.data[2] & 0xC0 == 0xC0 + + def test_read_config_session_config_reg(self): + """READ CONFIG at block 0xA1 returns config register bytes.""" + tag = self._make_tag() + cmd = bytes([0x02, 0xC0, 0x04, 0xA1, 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(1) + 4 bytes + + def test_clear_writable_bits(self): + """Writable status bits can be set and cleared, reflected in _status_reg.""" + tag = self._make_tag() + # Set writable bits — must reflect in underlying _status_reg byte + tag.eeprom_wr_error = True + assert tag.eeprom_wr_error is True + assert tag._status_reg[0] & 0x40 # bit 6 of byte 0 + tag.eeprom_wr_error = False + assert tag.eeprom_wr_error is False + assert not (tag._status_reg[0] & 0x40) + + tag.synch_block_write = True + assert tag.synch_block_write is True + assert tag._status_reg[0] & 0x10 # bit 4 of byte 0 + tag.synch_block_write = False + assert tag.synch_block_write is False + + tag.synch_block_read = True + assert tag.synch_block_read is True + assert tag._status_reg[0] & 0x08 # bit 3 of byte 0 + tag.synch_block_read = False + assert tag.synch_block_read is False + + +# --------------------------------------------------------------------------- +# NTAG 5 AES authentication (TAM1 / MAM1 / MAM2) +# --------------------------------------------------------------------------- + +class TestNtag5Aes: + """AES TAM/MAM authentication wired into NTAG 5 platform.""" + + def test_link_5332_tam1(self): + """TAM1 (CHALLENGE + READBUFFER) works on Link NTP5332 (aes_capable=True).""" + from pm3py.sim.ntag5_link import Ntag5LinkTag + from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED + from Crypto.Cipher import AES + + key = b"\xCC" * 16 + tag = Ntag5LinkTag(aes_capable=True) + tag._aes_keys[0] = key + tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED + run(tag.power_on()) + + # CHALLENGE: flags=0x02 cmd=0x39 CSI=0x00 AuthMethod=0x00 KeyID=0x00 + IChallenge(10) + ichallenge = bytes(range(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=0x02 cmd=0x3A + cmd = bytes([0x02, 0x3A]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert len(resp.data) == 17 # flags(1) + TResponse(16) + assert resp.data[0] == 0x00 + + # Verify crypto: decrypt reversed TResponse, check C_TAM1 prefix + tresponse_reversed = resp.data[1:17] + tresponse = tresponse_reversed[::-1] + cipher = AES.new(key, AES.MODE_ECB) + decrypted = cipher.decrypt(tresponse) + assert decrypted[0:2] == bytes([0x96, 0xC5]) # C_TAM1 + assert decrypted[6:16] == ichallenge # IChallenge echoed back + + def test_link_5332_tam1_readbuffer_one_shot(self): + """READBUFFER is one-shot — second read returns None.""" + from pm3py.sim.ntag5_link import Ntag5LinkTag + from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED + + key = b"\xCC" * 16 + tag = Ntag5LinkTag(aes_capable=True) + tag._aes_keys[0] = key + tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED + run(tag.power_on()) + + ichallenge = bytes(10) + cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + ichallenge + run(tag.handle_frame(RFFrame.from_bytes(cmd))) + + cmd = bytes([0x02, 0x3A]) + resp1 = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp1 is not None + resp2 = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp2 is None + + def test_switch_no_aes_challenge_rejected(self): + """CHALLENGE on Switch (no AES) does not produce a valid response.""" + from pm3py.sim.ntag5_switch import Ntag5SwitchTag + + tag = Ntag5SwitchTag() + run(tag.power_on()) + + # CHALLENGE command + cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + bytes(10) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + # Falls through to base handler — should return None or error + assert resp is None or resp.data[0] & 0x01 + + def test_link_no_aes_challenge_rejected(self): + """CHALLENGE on Link NTP5312 (aes_capable=False) does not work.""" + from pm3py.sim.ntag5_link import Ntag5LinkTag + + tag = Ntag5LinkTag(aes_capable=False) + 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 or resp.data[0] & 0x01 + + def test_boost_mam1(self): + """MAM1 (AUTHENTICATE with AuthMethod=0x02) works on Boost.""" + from pm3py.sim.ntag5_boost import Ntag5BoostTag + from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED + from Crypto.Cipher import AES + + key = b"\xDD" * 16 + tag = Ntag5BoostTag() + tag._aes_keys[0] = key + tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED + run(tag.power_on()) + + ichallenge = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A" + # MAM1: flags=0x02 cmd=0x35 CSI=0x00 AuthMethod=0x02 KeyID=0x00 + IChallenge_reversed(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 resp.data[0] == 0x04 # extension flag + assert resp.data[1] == 0xA7 # MAM1 header + assert len(resp.data) == 24 # flags(1) + header(1) + tc_high(6) + encrypted(16) + + # Verify: decrypt the encrypted block + tc_high_reversed = resp.data[2:8] + encrypted_reversed = resp.data[8:24] + encrypted = encrypted_reversed[::-1] + cipher = AES.new(key, AES.MODE_ECB) + decrypted = cipher.decrypt(encrypted) + assert decrypted[0:2] == bytes([0xDA, 0x83]) # C_MAM1 + + # IChallenge should match (reversed back to original) + assert decrypted[6:16] == ichallenge + + def test_boost_mam2(self): + """Full MAM1 + MAM2 mutual authentication on Boost.""" + from pm3py.sim.ntag5_boost import Ntag5BoostTag + from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED + from Crypto.Cipher import AES + + key = b"\xEE" * 16 + tag = Ntag5BoostTag() + tag._aes_keys[0] = key + tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED + run(tag.power_on()) + + ichallenge = b"\xA1\xA2\xA3\xA4\xA5\xA6\xA7\xA8\xA9\xAA" + + # MAM1 + cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x00]) + ichallenge[::-1] + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x04 + + # Extract TChallenge from MAM1 response + tc_high_reversed = resp.data[2:8] + tc_high = tc_high_reversed[::-1] + encrypted_reversed = resp.data[8:24] + encrypted = encrypted_reversed[::-1] + cipher = AES.new(key, AES.MODE_ECB) + decrypted = cipher.decrypt(encrypted) + tc_low = decrypted[2:6] # TChallenge[31:0] + tchallenge = tc_high + tc_low # full 10-byte TChallenge + + # Build MAM2 IResponse: tag verifies ENC(key, iresponse) == expected + # So iresponse = DEC(key, C_MAM2_PURPOSE || IChallenge[31:0] || TChallenge) + c_mam2 = bytes([0xDA, 0x80]) + ich_31_0 = ichallenge[6:10] # last 4 bytes + expected = c_mam2 + ich_31_0 + tchallenge + iresponse = cipher.decrypt(expected) + iresponse_reversed = iresponse[::-1] + + # MAM2: flags=0x02 cmd=0x35 CSI=0x00 AuthMethod=0x06 + IResponse_reversed(16) + cmd = bytes([0x02, 0x35, 0x00, 0x06]) + iresponse_reversed + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 # success + assert 0 in tag._aes_authenticated # key 0 now authenticated + + def test_boost_mam2_wrong_key_fails(self): + """MAM2 with wrong key is rejected.""" + from pm3py.sim.ntag5_boost import Ntag5BoostTag + from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED + from Crypto.Cipher import AES + + key = b"\xEE" * 16 + wrong_key = b"\xFF" * 16 + tag = Ntag5BoostTag() + tag._aes_keys[0] = key + tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED + run(tag.power_on()) + + ichallenge = bytes(10) + + # MAM1 + cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x00]) + ichallenge[::-1] + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + + # MAM2 with wrong key + cipher = AES.new(wrong_key, AES.MODE_ECB) + fake_iresponse = cipher.encrypt(bytes(16)) + cmd = bytes([0x02, 0x35, 0x00, 0x06]) + fake_iresponse[::-1] + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] & 0x01 # error + + def test_link_no_aes_authenticate_rejected(self): + """AUTHENTICATE on Link NTP5312 (no AES) returns error.""" + from pm3py.sim.ntag5_link import Ntag5LinkTag + + tag = Ntag5LinkTag(aes_capable=False) + run(tag.power_on()) + + ichallenge = bytes(10) + cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x00]) + ichallenge[::-1] + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + # Falls through to base — should return None or error + assert resp is None or resp.data[0] & 0x01 + + def test_boost_inherits_slix2_commands(self): + """AES-capable Boost still handles SLIX2 commands (e.g. inventory, read).""" + from pm3py.sim.ntag5_boost import Ntag5BoostTag + + tag = Ntag5BoostTag() + run(tag.power_on()) + + # Inventory should still work + resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x26, 0x01, 0x00])))) + assert resp is not None + + # READ SINGLE should still work + cmd = bytes([0x02, 0x20, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 + + +# --------------------------------------------------------------------------- +# CONFIG_REG enforcement +# --------------------------------------------------------------------------- + +class TestConfigReg: + """CONFIG_REG (session register block 0xA1) bit-field accessors and enforcement.""" + + def _make_platform_tag(self): + from pm3py.sim.ntag5_platform import Ntag5PlatformTag + tag = Ntag5PlatformTag(num_blocks=128) + run(tag.power_on()) + return tag + + def _make_link_tag(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + run(tag.power_on()) + return tag + + def test_default_config_reg_all_zeros(self): + """Default CONFIG_REG is all zeros.""" + tag = self._make_platform_tag() + assert tag._config_reg == bytearray(4) + assert tag.disable_nfc is False + assert tag.arbiter_mode == 0 + assert tag.sram_enable is False + assert tag.config_pt_transfer_dir is False + + def test_disable_nfc_blocks_read_single(self): + """DISABLE_NFC=True → READ SINGLE BLOCK returns None.""" + tag = self._make_platform_tag() + tag.disable_nfc = True + cmd = bytes([0x02, 0x20, 0x00]) # unaddressed READ SINGLE BLOCK 0 + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is None + + def test_disable_nfc_allows_inventory(self): + """DISABLE_NFC=True → INVENTORY still responds.""" + tag = self._make_platform_tag() + tag.disable_nfc = True + cmd = bytes([0x26, 0x01, 0x00]) # INVENTORY + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + + def test_disable_nfc_false_allows_read(self): + """DISABLE_NFC=False → READ SINGLE BLOCK responds normally.""" + tag = self._make_platform_tag() + tag.disable_nfc = False + cmd = bytes([0x02, 0x20, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 + + def test_sram_enable_syncs_flag(self): + """Setting sram_enable via config reg syncs _sram_enabled on Link tag.""" + tag = self._make_link_tag() + assert tag._sram_enabled is False + tag.sram_enable = True + assert tag._sram_enabled is True + tag.sram_enable = False + assert tag._sram_enabled is False + + def test_arbiter_mode_via_config_reg(self): + """Setting arbiter_mode via config reg syncs _arbiter_mode on Link tag.""" + tag = self._make_link_tag() + assert tag.arbiter_mode == 0 + tag.arbiter_mode = 2 # pass-through + assert tag._arbiter_mode == 2 + assert tag.arbiter_mode == 2 + + def test_write_config_0xa1_updates_config_reg(self): + """WRITE CONFIG at block 0xA1 updates config_reg bytes.""" + tag = self._make_platform_tag() + # WRITE CONFIG: flags=0x02 cmd=0xC1 mfg=0x04 block=0xA1 data=0x04,0x00,0x00,0x00 + # 0x04 = bit 2 set → arbiter_mode = 1 + cmd = bytes([0x02, 0xC1, 0x04, 0xA1, 0x04, 0x00, 0x00, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 + assert tag._config_reg[0] == 0x04 + assert tag.arbiter_mode == 1 + + def test_write_config_0xa1_disable_nfc_stops_responding(self): + """WRITE CONFIG at 0xA1 with DISABLE_NFC bit → tag stops responding.""" + tag = self._make_platform_tag() + # Write DISABLE_NFC (bit 5) = 0x20 + cmd = bytes([0x02, 0xC1, 0x04, 0xA1, 0x20, 0x00, 0x00, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 + assert tag.disable_nfc is True + # Now READ SINGLE should be silenced + cmd = bytes([0x02, 0x20, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is None + + +# --------------------------------------------------------------------------- +# Pass-Through Mode +# --------------------------------------------------------------------------- + +class TestPassThrough: + """SRAM pass-through mode (arbiter_mode == 2).""" + + def _make_tag(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag, ARBITER_PASSTHROUGH + tag = Ntag5LinkTag() + run(tag.power_on()) + tag.sram_enable = True + tag.arbiter_mode = ARBITER_PASSTHROUGH + return tag + + def test_i2c_write_sets_sram_data_ready(self): + """I2C write to SRAM in pass-through I2C→NFC sets sram_data_ready.""" + tag = self._make_tag() + tag.config_pt_transfer_dir = False # I2C→NFC + assert tag.sram_data_ready is False + tag.i2c_write(0x2000, b"\x11\x22\x33\x44") + assert tag.sram_data_ready is True + + def test_nfc_read_sram_clears_data_ready(self): + """NFC read from SRAM in I2C→NFC clears sram_data_ready.""" + tag = self._make_tag() + tag.config_pt_transfer_dir = False # I2C→NFC + tag.i2c_write(0x2000, b"\xDE\xAD\xBE\xEF") + assert tag.sram_data_ready is True + # NFC reads block 0 + cmd = bytes([0x02, 0x20, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + assert resp.data[1:5] == b"\xDE\xAD\xBE\xEF" + assert tag.sram_data_ready is False + + def test_nfc_write_sram_sets_data_ready(self): + """NFC write to SRAM in NFC→I2C sets sram_data_ready.""" + tag = self._make_tag() + tag.config_pt_transfer_dir = True # NFC→I2C + assert tag.sram_data_ready is False + # NFC writes block 0: flags=0x02, cmd=0x21, block=0, data=4 bytes + cmd = bytes([0x02, 0x21, 0x00, 0xAA, 0xBB, 0xCC, 0xDD]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + assert tag.sram_data_ready is True + assert tag._sram[0:4] == bytearray(b"\xAA\xBB\xCC\xDD") + + def test_i2c_read_sram_clears_data_ready(self): + """I2C read from SRAM in NFC→I2C clears sram_data_ready.""" + tag = self._make_tag() + tag.config_pt_transfer_dir = True # NFC→I2C + # NFC writes block 5 + cmd = bytes([0x02, 0x21, 0x05, 0x11, 0x22, 0x33, 0x44]) + run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert tag.sram_data_ready is True + # I2C reads block 5 (address 0x2005) + data = tag.i2c_read(0x2005, 4) + assert data == b"\x11\x22\x33\x44" + assert tag.sram_data_ready is False + + def test_callback_fires_on_i2c_write(self): + """on_sram_write callback fires with correct args on I2C write.""" + tag = self._make_tag() + tag.config_pt_transfer_dir = False # I2C→NFC + calls = [] + tag.on_sram_write = lambda src, off, data: calls.append((src, off, data)) + tag.i2c_write(0x2003, b"\xCA\xFE\xBA\xBE") + assert len(calls) == 1 + assert calls[0] == ("i2c", 12, b"\xCA\xFE\xBA\xBE") + + def test_callback_fires_on_nfc_write(self): + """on_sram_write callback fires with correct args on NFC write.""" + tag = self._make_tag() + tag.config_pt_transfer_dir = True # NFC→I2C + calls = [] + tag.on_sram_write = lambda src, off, data: calls.append((src, off, data)) + # Write to block 2 (offset 8) + cmd = bytes([0x02, 0x21, 0x02, 0xDE, 0xAD, 0xBE, 0xEF]) + run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert len(calls) == 1 + assert calls[0] == ("nfc", 8, b"\xDE\xAD\xBE\xEF") + + def test_passthrough_not_active_in_normal_mode(self): + """Pass-through behavior inactive when arbiter_mode is NORMAL.""" + from pm3py.sim.ntag5_link import Ntag5LinkTag, ARBITER_NORMAL + tag = Ntag5LinkTag() + run(tag.power_on()) + tag.sram_enable = True + tag.arbiter_mode = ARBITER_NORMAL + tag.config_pt_transfer_dir = False + tag.i2c_write(0x2000, b"\x11\x22\x33\x44") + # sram_data_ready should NOT be set in normal mode + assert tag.sram_data_ready is False + + def test_passthrough_not_active_in_mirror_mode(self): + """Pass-through behavior inactive when arbiter_mode is MIRROR.""" + from pm3py.sim.ntag5_link import Ntag5LinkTag, ARBITER_MIRROR + tag = Ntag5LinkTag() + run(tag.power_on()) + tag.sram_enable = True + tag.arbiter_mode = ARBITER_MIRROR + tag.config_pt_transfer_dir = False + tag.i2c_write(0x2000, b"\x11\x22\x33\x44") + # sram_data_ready should NOT be set in mirror mode + assert tag.sram_data_ready is False + + def test_nfc_write_non_sram_block_goes_to_eeprom(self): + """NFC write to block >= 64 in pass-through still goes to EEPROM.""" + tag = self._make_tag() + tag.config_pt_transfer_dir = True + # Write to block 100 (not SRAM range) + cmd = bytes([0x02, 0x21, 0x64, 0xAA, 0xBB, 0xCC, 0xDD]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + # Should be in EEPROM, not SRAM + offset = 100 * 4 + assert tag._memory[offset:offset + 4] == bytearray(b"\xAA\xBB\xCC\xDD") + assert tag.sram_data_ready is False + + def test_no_callback_when_none(self): + """No error when on_sram_write is None (default).""" + tag = self._make_tag() + tag.config_pt_transfer_dir = False + assert tag.on_sram_write is None + # Should not raise + tag.i2c_write(0x2000, b"\x11\x22\x33\x44") + + +# --------------------------------------------------------------------------- +# SYNCH_DATA_BLOCK +# --------------------------------------------------------------------------- + +class TestSynchDataBlock: + """Synch data block triggers status register flags on access.""" + + def _make_tag(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + run(tag.power_on()) + return tag + + def test_write_synch_block_sets_flag(self): + """NFC write to configured synch block sets synch_block_write.""" + tag = self._make_tag() + tag._synch_data_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 + assert tag.synch_block_write is True + assert tag.synch_block_read is False + + def test_read_synch_block_sets_flag(self): + """NFC read from configured synch block sets synch_block_read.""" + tag = self._make_tag() + tag._synch_data_block = 5 + cmd = bytes([0x02, 0x20, 0x05]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + assert tag.synch_block_read is True + assert tag.synch_block_write is False + + def test_write_non_synch_block_no_flag(self): + """NFC write to block != synch block does not set flag.""" + tag = self._make_tag() + tag._synch_data_block = 5 + cmd = bytes([0x02, 0x21, 0x06, 0xAA, 0xBB, 0xCC, 0xDD]) + run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert tag.synch_block_write is False + + def test_read_non_synch_block_no_flag(self): + """NFC read from block != synch block does not set flag.""" + tag = self._make_tag() + tag._synch_data_block = 5 + cmd = bytes([0x02, 0x20, 0x06]) + run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert tag.synch_block_read is False + + def test_synch_disabled_no_flags(self): + """Synch disabled (None) does not set flags on any access.""" + tag = self._make_tag() + assert tag._synch_data_block is None + # Write to block 5 + cmd = bytes([0x02, 0x21, 0x05, 0xAA, 0xBB, 0xCC, 0xDD]) + run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert tag.synch_block_write is False + # Read from block 5 + cmd = bytes([0x02, 0x20, 0x05]) + run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert tag.synch_block_read is False + + def test_flags_are_sticky(self): + """Synch flags are sticky: persist after accessing other blocks.""" + tag = self._make_tag() + tag._synch_data_block = 5 + # Write to synch block + cmd = bytes([0x02, 0x21, 0x05, 0xAA, 0xBB, 0xCC, 0xDD]) + run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert tag.synch_block_write is True + # Read a different block + cmd = bytes([0x02, 0x20, 0x06]) + run(tag.handle_frame(RFFrame.from_bytes(cmd))) + # Write flag should still be set + assert tag.synch_block_write is True + + +# --------------------------------------------------------------------------- +# Event Detection (ED Pin) +# --------------------------------------------------------------------------- + +class TestEventDetection: + """ED pin callbacks and trigger logic for NTAG 5 platform.""" + + def _make_tag(self): + from pm3py.sim.ntag5_platform import Ntag5PlatformTag + tag = Ntag5PlatformTag(num_blocks=128) + run(tag.power_on()) + return tag + + def test_default_ed_released_no_callback(self): + """Default: ED pin released, no callback.""" + tag = self._make_tag() + assert tag._ed_pin_state is False + assert tag.ed_pin_callback is None + + def test_assert_ed_sets_pin_and_fires_callback(self): + """assert_ed() sets pin state True and fires callback with True.""" + tag = self._make_tag() + calls = [] + tag.ed_pin_callback = lambda state: calls.append(state) + tag.assert_ed() + assert tag._ed_pin_state is True + assert calls == [True] + + def test_release_ed_clears_pin_and_fires_callback(self): + """release_ed() sets pin state False and fires callback with False.""" + tag = self._make_tag() + calls = [] + tag.ed_pin_callback = lambda state: calls.append(state) + tag.assert_ed() + tag.release_ed() + assert tag._ed_pin_state is False + assert calls == [True, False] + + def test_ed_trigger_nfc_field(self): + """Configure ED for NFC field detect (bit 0), trigger fires.""" + tag = self._make_tag() + tag._ed_config = 0x01 # bit 0: NFC field detect + calls = [] + tag.ed_pin_callback = lambda state: calls.append(state) + tag._check_ed_trigger("nfc_field") + assert tag._ed_pin_state is True + assert calls == [True] + + def test_ed_trigger_synch_write(self): + """Configure ED for SYNCH_BLOCK write (bit 5), trigger fires.""" + tag = self._make_tag() + tag._ed_config = 0x20 # bit 5: SYNCH_BLOCK write + calls = [] + tag.ed_pin_callback = lambda state: calls.append(state) + tag._check_ed_trigger("synch_write") + assert tag._ed_pin_state is True + assert calls == [True] + + def test_ed_trigger_synch_read(self): + """Configure ED for SYNCH_BLOCK read (bit 4), trigger fires.""" + tag = self._make_tag() + tag._ed_config = 0x10 # bit 4: SYNCH_BLOCK read + calls = [] + tag.ed_pin_callback = lambda state: calls.append(state) + tag._check_ed_trigger("synch_read") + assert tag._ed_pin_state is True + assert calls == [True] + + def test_ed_trigger_not_in_config_stays_released(self): + """Trigger event not in config does not assert ED.""" + tag = self._make_tag() + tag._ed_config = 0x01 # only NFC field + calls = [] + tag.ed_pin_callback = lambda state: calls.append(state) + tag._check_ed_trigger("synch_write") + assert tag._ed_pin_state is False + assert calls == [] + + def test_ed_clear_via_write_config_0xab(self): + """Clear ED via WRITE CONFIG at block 0xAB (ED_INTR_CLEAR_REG).""" + tag = self._make_tag() + calls = [] + tag.ed_pin_callback = lambda state: calls.append(state) + tag.assert_ed() + assert tag._ed_pin_state is True + # WRITE CONFIG: flags=0x02 cmd=0xC1 mfg=0x04 block=0xAB data=0x00,0x00,0x00,0x00 + cmd = bytes([0x02, 0xC1, 0x04, 0xAB, 0x00, 0x00, 0x00, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 # success + assert tag._ed_pin_state is False + assert calls == [True, False] + + def test_programmatic_assert_release_from_repl(self): + """Programmatic assert_ed() / release_ed() works without callback.""" + tag = self._make_tag() + # No callback set — should not raise + tag.assert_ed() + assert tag._ed_pin_state is True + tag.release_ed() + assert tag._ed_pin_state is False + + +# --------------------------------------------------------------------------- +# READ SRAM (0xD2) +# --------------------------------------------------------------------------- + +class TestReadSram: + """Direct SRAM read via NXP custom command 0xD2.""" + + def _make_tag(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + run(tag.power_on()) + tag.sram_enable = True + return tag + + def test_read_single_block(self): + """Write data to SRAM, read via 0xD2 returns correct data.""" + tag = self._make_tag() + tag.sram[0:4] = b"\xDE\xAD\xBE\xEF" + # READ SRAM: flags=0x02, cmd=0xD2, mfg=0x04, block=0, count=1 + cmd = bytes([0x02, 0xD2, 0x04, 0x00, 0x01]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + assert resp.data[1:5] == b"\xDE\xAD\xBE\xEF" + + def test_read_multiple_blocks(self): + """Read multiple SRAM blocks via 0xD2.""" + tag = self._make_tag() + tag.sram[0:8] = b"\x11\x22\x33\x44\x55\x66\x77\x88" + # Read 2 blocks starting at block 0 + cmd = bytes([0x02, 0xD2, 0x04, 0x00, 0x02]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + assert resp.data[1:9] == b"\x11\x22\x33\x44\x55\x66\x77\x88" + + def test_read_from_offset(self): + """Read SRAM blocks starting at a non-zero block address.""" + tag = self._make_tag() + tag.sram[8:12] = b"\xAA\xBB\xCC\xDD" + # Read 1 block starting at block 2 + cmd = bytes([0x02, 0xD2, 0x04, 0x02, 0x01]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + assert resp.data[1:5] == b"\xAA\xBB\xCC\xDD" + + def test_error_when_sram_disabled(self): + """Error (0x01, 0x0F) when SRAM is not enabled.""" + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + run(tag.power_on()) + # sram_enable defaults to False + cmd = bytes([0x02, 0xD2, 0x04, 0x00, 0x01]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x01 + assert resp.data[1] == 0x0F + + def test_error_when_i2c_locked(self): + """Error (0x01, 0x0F) when I2C holds the arbitration lock.""" + tag = self._make_tag() + tag.set_i2c_locked(True) + cmd = bytes([0x02, 0xD2, 0x04, 0x00, 0x01]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x01 + assert resp.data[1] == 0x0F + + def test_error_when_block_range_exceeds_64(self): + """Error (0x01, 0x10) when block_addr + num_blocks > 64.""" + tag = self._make_tag() + # block 60 + 5 blocks = 65 > 64 + cmd = bytes([0x02, 0xD2, 0x04, 0x3C, 0x05]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x01 + assert resp.data[1] == 0x10 + + +class TestWriteSram: + """Direct SRAM write via NXP custom command 0xD3.""" + + def _make_tag(self): + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + run(tag.power_on()) + tag.sram_enable = True + return tag + + def test_write_single_block(self): + """Write a single block to SRAM via 0xD3, verify SRAM contents.""" + tag = self._make_tag() + # WRITE SRAM: flags=0x02, cmd=0xD3, mfg=0x04, block=0, count=1, data=4 bytes + cmd = bytes([0x02, 0xD3, 0x04, 0x00, 0x01, 0xDE, 0xAD, 0xBE, 0xEF]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + assert tag.sram[0:4] == bytearray(b"\xDE\xAD\xBE\xEF") + + def test_write_multiple_blocks(self): + """Write multiple blocks to SRAM via 0xD3.""" + tag = self._make_tag() + # Write 2 blocks starting at block 1 + data = b"\x11\x22\x33\x44\x55\x66\x77\x88" + cmd = bytes([0x02, 0xD3, 0x04, 0x01, 0x02]) + data + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + assert tag.sram[4:12] == bytearray(data) + + def test_error_when_sram_disabled(self): + """Error (0x01, 0x0F) when SRAM is not enabled.""" + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + run(tag.power_on()) + cmd = bytes([0x02, 0xD3, 0x04, 0x00, 0x01, 0xDE, 0xAD, 0xBE, 0xEF]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x01 + assert resp.data[1] == 0x0F + + def test_error_when_i2c_locked(self): + """Error (0x01, 0x0F) when I2C holds the arbitration lock.""" + tag = self._make_tag() + tag.set_i2c_locked(True) + cmd = bytes([0x02, 0xD3, 0x04, 0x00, 0x01, 0xDE, 0xAD, 0xBE, 0xEF]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x01 + assert resp.data[1] == 0x0F + + +# --------------------------------------------------------------------------- +# Dual-Interface Integration +# --------------------------------------------------------------------------- + +class TestDualInterfaceIntegration: + """End-to-end tests of the complete NFC<->I2C flow (mock MCU).""" + + def _make_tag(self, pt_dir_nfc_to_i2c: bool = True): + """Create an Ntag5LinkTag configured for pass-through mode.""" + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + run(tag.power_on()) + tag.sram_enable = True + tag.arbiter_mode = 2 # PASSTHROUGH + tag.config_pt_transfer_dir = pt_dir_nfc_to_i2c + return tag + + def test_nfc_to_i2c_data_flow(self): + """NFC reader writes NDEF data -> mock MCU reads via I2C.""" + tag = self._make_tag(pt_dir_nfc_to_i2c=True) + + # Install callback + cb_log = [] + tag.on_sram_write = lambda src, off, data: cb_log.append((src, off, data)) + + # NFC writes to SRAM block 0 via WRITE SINGLE BLOCK + write_data = b'\x01\x02\x03\x04' + cmd = bytes([0x02, 0x21, 0x00]) + write_data + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 # success + + # Callback fired with correct args + assert len(cb_log) == 1 + assert cb_log[0] == ("nfc", 0, write_data) + + # sram_data_ready set + assert tag.sram_data_ready is True + + # MCU reads via I2C + result = tag.i2c_read(0x2000, 4) + assert result == write_data + + # sram_data_ready cleared after I2C read (NFC->I2C direction) + assert tag.sram_data_ready is False + + def test_i2c_to_nfc_data_flow(self): + """MCU writes sensor data via I2C -> NFC reader reads it.""" + tag = self._make_tag(pt_dir_nfc_to_i2c=False) + + # Install callback + cb_log = [] + tag.on_sram_write = lambda src, off, data: cb_log.append((src, off, data)) + + # MCU writes via I2C + sensor_data = b'\xDE\xAD\xBE\xEF' + ok = tag.i2c_write(0x2000, sensor_data) + assert ok is True + + # Callback fired + assert len(cb_log) == 1 + assert cb_log[0] == ("i2c", 0, sensor_data) + + # sram_data_ready set + assert tag.sram_data_ready is True + + # NFC reads SRAM block 0 via READ SINGLE BLOCK + cmd = bytes([0x02, 0x20, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 # success + assert resp.data[1:5] == sensor_data + + # sram_data_ready cleared after NFC read (I2C->NFC direction) + assert tag.sram_data_ready is False + + def test_arbitration_conflict_resolution(self): + """Both sides try to access simultaneously — arbiter resolves.""" + tag = self._make_tag(pt_dir_nfc_to_i2c=False) + + # NFC acquires lock + assert tag.acquire_nfc() is True + + # I2C cannot acquire while NFC holds lock + assert tag.acquire_i2c() is False + + # NFC reads SRAM succeeds (NFC holds lock, no I2C lock) + cmd = bytes([0x02, 0x20, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 + + # I2C write fails (NFC holds lock) + ok = tag.i2c_write(0x2000, b'\xAA\xBB\xCC\xDD') + assert ok is False + + # Release NFC + tag.release_nfc() + + # Now I2C can acquire + assert tag.acquire_i2c() is True + + # I2C write succeeds + ok = tag.i2c_write(0x2000, b'\xAA\xBB\xCC\xDD') + assert ok is True + + # NFC read in mirror mode fails (I2C holds lock) + cmd = bytes([0x02, 0x20, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x01 # error + assert resp.data[1] == 0x0F + + # Release I2C + tag.release_i2c() + + def test_session_register_consistency(self): + """STATUS_REG reflects complete state after a cycle.""" + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + run(tag.power_on()) + tag.sram_enable = True + tag.arbiter_mode = 2 # PASSTHROUGH + tag.config_pt_transfer_dir = False # I2C->NFC + + # Set NFC field active + tag.set_nfc_field(True) + assert tag.nfc_field_ok is True + assert tag.nfc_boot_ok is True + + # Set VCC supply + tag.set_vcc_supply(True) + assert tag.vcc_supply_ok is True + assert tag.vcc_boot_ok is True + + # Acquire I2C + assert tag.acquire_i2c() is True + assert tag.i2c_if_locked is True + + # I2C writes to SRAM + tag.i2c_write(0x2000, b'\x11\x22\x33\x44') + assert tag.sram_data_ready is True + + # READ CONFIG at 0xA0 (STATUS_REG) -> verify all bits + cmd = bytes([0x02, 0xC0, 0x04, 0xA0, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 # success + status_byte0 = resp.data[1] + status_byte1 = resp.data[2] + + # Byte 0: nfc_field_ok(0), vcc_supply_ok(1), sram_data_ready(5) + assert status_byte0 & 0x01 # nfc_field_ok + assert status_byte0 & 0x02 # vcc_supply_ok + assert status_byte0 & 0x20 # sram_data_ready + + # Byte 1: nfc_if_locked(0)=0, i2c_if_locked(1)=1, nfc_boot_ok(6), vcc_boot_ok(7) + assert not (status_byte1 & 0x01) # nfc NOT locked + assert status_byte1 & 0x02 # i2c locked + assert status_byte1 & 0x40 # nfc_boot_ok + assert status_byte1 & 0x80 # vcc_boot_ok + + # Release I2C, clear field + tag.release_i2c() + tag.set_nfc_field(False) + + # Read STATUS_REG again + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + status_byte0 = resp.data[1] + status_byte1 = resp.data[2] + + assert not (status_byte0 & 0x01) # nfc_field_ok cleared + assert status_byte0 & 0x02 # vcc still on + assert not (status_byte1 & 0x02) # i2c no longer locked + # boot bits persist (latched) + assert status_byte1 & 0x40 # nfc_boot_ok still set + assert status_byte1 & 0x80 # vcc_boot_ok still set + + def test_ed_pin_fires_on_synch_data_block_access(self): + """ED pin fires when NFC writes to the configured synch data block.""" + from pm3py.sim.ntag5_link import Ntag5LinkTag + tag = Ntag5LinkTag() + run(tag.power_on()) + + # Configure synch data block = 5 + tag._synch_data_block = 5 + + # Enable ED for synch_write (bit 5) + tag._ed_config = (1 << 5) + + # Install ED callback + ed_log = [] + tag.ed_pin_callback = lambda state: ed_log.append(state) + + # NFC writes to block 5 via WRITE SINGLE BLOCK + cmd = bytes([0x02, 0x21, 0x05, 0xAA, 0xBB, 0xCC, 0xDD]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 # success + + # synch_block_write flag set + assert tag.synch_block_write is True + + # ED pin asserted and callback fired + assert tag._ed_pin_state is True + assert len(ed_log) == 1 + assert ed_log[0] is True + + # Clear ED via WRITE CONFIG at 0xAB + cmd = bytes([0x02, 0xC1, 0x04, 0xAB, 0x00, 0x00, 0x00, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + + # ED released + assert tag._ed_pin_state is False + assert len(ed_log) == 2 + assert ed_log[1] is False + + +class TestNtag5Area1Passwords: + """AREA_1 password IDs (0x40/0x80) per NTP5210/NTA5332 datasheets.""" + + def test_area1_password_ids_exist_on_switch(self): + from pm3py.sim.ntag5_switch import Ntag5SwitchTag + from pm3py.sim.ntag5_platform import PWD_AREA1_READ, PWD_AREA1_WRITE + tag = Ntag5SwitchTag() + assert PWD_AREA1_READ == 0x40 + assert PWD_AREA1_WRITE == 0x80 + assert PWD_AREA1_READ in tag._passwords + assert PWD_AREA1_WRITE in tag._passwords + # Initially None (not set) + assert tag._passwords[PWD_AREA1_READ] is None + assert tag._passwords[PWD_AREA1_WRITE] is None + + +class TestNtag5PickRandomUid: + """PICK RANDOM UID (0xC2) per NTA5332 datasheet 8.2.4.9.15.""" + + def test_pick_random_uid_in_privacy_mode(self): + from pm3py.sim.ntag5_boost import Ntag5BoostTag + tag = Ntag5BoostTag() + run(tag.power_on()) + tag._privacy_mode = True + # Send PICK RANDOM UID: flags=0x02, cmd=0xC2, mfg=0x04 + cmd = bytes([0x02, 0xC2, 0x04]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 # success + assert tag._random_uid is not None + assert len(tag._random_uid) == 8 + assert tag._random_uid[:2] == b"\xE0\x04" + + def test_pick_random_uid_without_privacy_mode_returns_error(self): + from pm3py.sim.ntag5_boost import Ntag5BoostTag + tag = Ntag5BoostTag() + run(tag.power_on()) + assert tag._privacy_mode is False + cmd = bytes([0x02, 0xC2, 0x04]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x01 # error + assert resp.data[1] == 0x0F + assert tag._random_uid is None + + def test_switch_rejects_pick_random_uid(self): + """NTP5210 does not support PICK RANDOM UID per datasheet rev 3.2.""" + from pm3py.sim.ntag5_switch import Ntag5SwitchTag + tag = Ntag5SwitchTag() + run(tag.power_on()) + # Test outside privacy mode — should get error via custom command dispatch + cmd = bytes([0x02, 0xC2, 0x04]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] & 0x01 # error flag + assert tag._random_uid is None + + +# --------------------------------------------------------------------------- +# Extended ISO 15693 commands (0x30-0x33) — 2-byte block addressing +# --------------------------------------------------------------------------- + +class TestExtendedCommands: + """Extended read/write commands with 2-byte block addresses for NTAG 5 Boost (512 blocks).""" + + def test_extended_read_single_block(self): + """Read block 256 (beyond 1-byte range) using extended read single.""" + import struct + from pm3py.sim.ntag5_boost import Ntag5BoostTag + tag = Ntag5BoostTag() + run(tag.power_on()) + # Write known data to block 256 + offset = 256 * tag._block_size + tag._memory[offset:offset + 4] = b"\xDE\xAD\xBE\xEF" + # Extended Read Single Block (0x30), unaddressed: flags(02) cmd(30) block_le16 + block_le = struct.pack("= 3 # flags + 2 random bytes + + def test_get_nxp_system_info(self): + """GET NXP SYSTEM INFO (0xAB) returns PP + feature data on SLIX2.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) + 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 # success flags + assert len(resp.data) >= 8 # flags + PP + PP_cond + lock + feature_flags + + def test_read_signature(self): + """READ SIGNATURE (0xBD) returns 32-byte ECC signature on SLIX2.""" + sig = bytes(range(32)) + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), signature=sig) + 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 # success + assert resp.data[1:] == sig + + def test_read_signature_default_zeros(self): + """READ SIGNATURE returns 32 zero bytes when no signature configured on SLIX2.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) + run(tag.power_on()) + + cmd = bytes([0x02, 0xBD, 0x04]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert len(resp.data) == 33 # flags + 32 bytes + assert resp.data[1:] == bytes(32) + + def test_wrong_mfg_code_ignored(self): + """Commands with wrong manufacturer code return None.""" + tag = NxpIcodeTag(uid=bytes(8)) + run(tag.power_on()) + + cmd = bytes([0x02, 0xB2, 0x05]) # mfg=0x05, not NXP + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + # Should fall through (not an NXP custom command match) + # Depending on base class behavior, may return None or error + # The key is it shouldn't return a valid GET_RANDOM response + + def test_slix_rejects_get_nxp_system_info(self): + """SLIX (SL2S2002) does NOT support GET NXP SYSTEM INFO (0xAB).""" + tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4)) + run(tag.power_on()) + + cmd = bytes([0x02, 0xAB, 0x04]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is None + + def test_slix_rejects_read_signature(self): + """SLIX (SL2S2002) does NOT support READ SIGNATURE (0xBD).""" + tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4)) + run(tag.power_on()) + + cmd = bytes([0x02, 0xBD, 0x04]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is None + + +# --------------------------------------------------------------------------- +# ICODE SLIX2 +# --------------------------------------------------------------------------- + +class TestIcodeSlix2Tag: + def test_inherits_nxp_icode(self): + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) + 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_privacy_mode_hides_uid(self): + """In privacy mode, tag doesn't respond to inventory.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), + privacy_password=0xDEADBEEF) + run(tag.power_on()) + + tag.enter_privacy_mode() + assert tag.privacy_mode + + inv = bytes([0x26, 0x01, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(inv))) + assert resp is None # hidden + + def test_privacy_mode_allows_get_random(self): + """In privacy mode, GET_RANDOM still works (needed for password XOR).""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), + privacy_password=0xDEADBEEF) + run(tag.power_on()) + tag.enter_privacy_mode() + + cmd = bytes([0x02, 0xB2, 0x04]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert len(resp.data) >= 3 + + def test_privacy_mode_unlock_with_xor(self): + """SET_PASSWORD with correct XOR'd privacy password disables privacy mode.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), + privacy_password=0xDEADBEEF) + run(tag.power_on()) + tag.enter_privacy_mode() + + # First get random for XOR + cmd = bytes([0x02, 0xB2, 0x04]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + r = resp.data[1:3] + + # XOR the password + pwd = struct.pack(" 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("= 33 # flags + 32-byte EAS sequence + + def test_eas_alarm_when_disabled(self): + """EAS ALARM returns no response when EAS is disabled.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) + run(tag.power_on()) + assert not tag.eas_enabled + + cmd = bytes([0x02, CMD_EAS_ALARM, 0x04]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is None + + def test_eas_alarm_custom_id(self): + """EAS ALARM includes EAS ID when set.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) + run(tag.power_on()) + tag.set_eas(True) + tag._eas_id = 0x1234 + + 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 + + +# --------------------------------------------------------------------------- +# PASSWORD PROTECT EAS/AFI (0xA6) +# --------------------------------------------------------------------------- + +class TestPasswordProtectEasAfi: + def test_enable_eas_afi_protection(self): + """PASSWORD PROTECT EAS/AFI (0xA6) enables password protection.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), + eas_afi_password=0xAABBCCDD) + run(tag.power_on()) + assert not tag._eas_password_protected + + _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 is not None + assert resp.data[0] == 0x00 + assert tag._eas_password_protected + + def test_eas_afi_protection_requires_auth(self): + """PASSWORD PROTECT EAS/AFI fails without auth.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), + eas_afi_password=0xAABBCCDD) + run(tag.power_on()) + + cmd = bytes([0x02, CMD_PASSWORD_PROTECT_EAS_AFI, 0x04]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] & 0x01 + + +# --------------------------------------------------------------------------- +# WRITE EAS ID (0xA7) +# --------------------------------------------------------------------------- + +class TestWriteEasId: + def test_write_eas_id(self): + """WRITE EAS ID (0xA7) sets 16-bit EAS identifier.""" + 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 is not None + assert resp.data[0] == 0x00 + assert tag._eas_id == 0x1234 + + def test_write_eas_id_requires_auth_when_protected(self): + """WRITE EAS ID fails without auth when protected.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), + eas_afi_password=0xAABBCCDD) + run(tag.power_on()) + tag._eas_password_protected = True + + cmd = bytes([0x02, CMD_WRITE_EAS_ID, 0x04, 0x34, 0x12]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] & 0x01 + + +# --------------------------------------------------------------------------- +# STAY QUIET PERSISTENT (0xBC) +# --------------------------------------------------------------------------- + +class TestStayQuietPersistent: + @staticmethod + def _sqp_cmd(uid: bytes) -> bytes: + """Build addressed STAY QUIET PERSISTENT command.""" + # flags=0x22 (addressed + high data rate), cmd=0xBC, UID reversed, mfg=0x04 + return bytes([0x22, 0xBC]) + uid[::-1] + bytes([0x04]) + + def test_stay_quiet_persistent(self): + """STAY QUIET PERSISTENT (0xBC) silences tag persistently.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) + run(tag.power_on()) + + cmd = self._sqp_cmd(tag._uid) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 + + # Tag should not respond to inventory + inv = bytes([0x26, 0x01, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(inv))) + assert resp is None + + def test_stay_quiet_persistent_survives_power_cycle(self): + """STAY QUIET PERSISTENT survives power-off/on.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) + run(tag.power_on()) + + cmd = self._sqp_cmd(tag._uid) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp.data[0] == 0x00 + + # Simulate power cycle + run(tag.power_off()) + run(tag.power_on()) + + # Still quiet + inv = bytes([0x26, 0x01, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(inv))) + assert resp is None + + def test_stay_quiet_persistent_reset_by_password(self): + """SET_PASSWORD still works even in persistent quiet (addressed).""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), + privacy_password=0xDEADBEEF) + run(tag.power_on()) + + cmd = self._sqp_cmd(tag._uid) + run(tag.handle_frame(RFFrame.from_bytes(cmd))) + + # SET_PASSWORD should still work (unaddressed custom cmds still handled) + xored = _xor_password(tag, 0xDEADBEEF) + cmd = bytes([0x02, 0xB3, 0x04, PWD_PRIVACY]) + xored + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 + + +# --------------------------------------------------------------------------- +# INVENTORY READ (0xA0) / FAST INVENTORY READ (0xA1) +# --------------------------------------------------------------------------- + +class TestInventoryRead: + def test_inventory_read_returns_data(self): + """INVENTORY READ (0xA0) returns UID + block data.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06", + block_size=4, num_blocks=28) + run(tag.power_on()) + + # Write known data to block 0 + write_cmd = bytes([0x02, 0x21, 0x00, 0xAA, 0xBB, 0xCC, 0xDD]) + run(tag.handle_frame(RFFrame.from_bytes(write_cmd))) + + # INVENTORY READ: flags(1) + cmd(1) + mfg(1) + mask_len(1) + first_block(1) + num_blocks(1) + 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 # success flags + # Response contains DSFID + UID + block data + assert len(resp.data) > 10 + + def test_fast_inventory_read(self): + """FAST INVENTORY READ (0xA1) same behavior as INVENTORY READ.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06", + block_size=4, num_blocks=28) + 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 + + def test_inventory_read_specific_blocks(self): + """INVENTORY READ reads specific block range.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06", + block_size=4, num_blocks=28) + run(tag.power_on()) + + # Write known data + write_cmd = bytes([0x02, 0x21, 0x02, 0x11, 0x22, 0x33, 0x44]) + run(tag.handle_frame(RFFrame.from_bytes(write_cmd))) + + # Read block 2, 1 block + cmd = bytes([0x06, CMD_INVENTORY_READ, 0x04, 0x00, 0x02, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + # Response should contain the written data + assert b"\x11\x22\x33\x44" in resp.data + + +# --------------------------------------------------------------------------- +# Block 79 counter (increment-only write semantics) +# --------------------------------------------------------------------------- + +class TestBlock79Counter: + def test_counter_increments(self): + """WRITE to block 79 increments the 16-bit counter, not overwrites.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), + block_size=4, num_blocks=80) + run(tag.power_on()) + + # Write increment value of 1 + cmd = bytes([0x02, 0x21, 79, 0x01, 0x00, 0x00, 0x00]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + assert resp is not None + assert resp.data[0] == 0x00 + + # Read back — counter should be 1 + cmd = bytes([0x02, 0x20, 79]) + resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) + counter = struct.unpack_from("= 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)) diff --git a/tests/test_sim_reader.py b/tests/test_sim_reader.py new file mode 100644 index 0000000..d73cb80 --- /dev/null +++ b/tests/test_sim_reader.py @@ -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 diff --git a/tests/test_sim_table_compiler.py b/tests/test_sim_table_compiler.py new file mode 100644 index 0000000..bfbff07 --- /dev/null +++ b/tests/test_sim_table_compiler.py @@ -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("= 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("= 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("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("