feat: migrate sim framework from worktree to master

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

751 tests passing (686 sim + 65 core).

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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