refactor: reorganize transponder models into pm3py/transponders/

Moves 23 IC-specific files (tag + reader models) from sim/ into
transponders/ organized by standard/manufacturer:
- transponders/hf/iso14443a/ — 14443-A base, MIFARE Classic, DESFire, NDEF
- transponders/hf/iso15693/ — 15693 base, Type5, NXP ICODE/NTAG5 hierarchy
- transponders/lf/ — LF base, EM4100, HID, T5577
- transponders/implants.py — xEM, xNT, xM1, FlexDF, NExT

sim/ retains infrastructure (Medium, RFFrame, SimSession, TableCompiler,
etc.) and backward-compat shims for each moved file. sim/__init__.py now
imports transponder classes from their canonical transponders/ locations.

751 tests passing, zero test changes needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
michael
2026-03-18 20:32:47 -07:00
parent a7b24dc215
commit 18a992f3c7
54 changed files with 5103 additions and 5036 deletions

View File

@@ -1,32 +1,11 @@
"""pm3py.sim — Software-defined transponder/reader simulation framework."""
# --- Sim infrastructure (stays here) ---
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
@@ -36,6 +15,31 @@ from .sim_session import SimSession
from .mcu_bridge import McuBridge
from .dual_session import DualInterfaceSession
# --- Transponder models (canonical home: pm3py.transponders) ---
from pm3py.transponders.hf.iso14443a.base import (
Tag14443A, Tag14443A_3, Tag14443A_4, Reader14443A, State14443A,
)
from pm3py.transponders.hf.iso14443a.nxp.crypto1 import Crypto1
from pm3py.transponders.hf.iso14443a.nxp.mifare_classic import MifareClassicTag, MifareClassicReader
from pm3py.transponders.hf.iso14443a.nxp.desfire import DesfireTag, DesfireReader
from pm3py.transponders.hf.iso14443a.ndef import NfcType2Tag, NfcType4Tag
from pm3py.transponders.hf.iso15693.base import Tag15693, Reader15693, State15693
from pm3py.transponders.hf.iso15693.type5 import NfcType5Tag, ndef_text, ndef_uri, ndef_mime
from pm3py.transponders.hf.iso15693.nxp.nxp_icode import NxpIcodeTag
from pm3py.transponders.hf.iso15693.nxp.icode_slix import IcodeSlixTag
from pm3py.transponders.hf.iso15693.nxp.icode_slix2 import IcodeSlix2Tag
from pm3py.transponders.hf.iso15693.nxp.icode3 import Icode3Tag
from pm3py.transponders.hf.iso15693.nxp.icode_dna import IcodeDnaTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_platform import Ntag5PlatformTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_switch import Ntag5SwitchTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_link import Ntag5LinkTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_boost import Ntag5BoostTag
from pm3py.transponders.lf.base import TagLF, ReaderLF, Modulation
from pm3py.transponders.lf.em.em4100 import EM4100Tag, EM4100Reader
from pm3py.transponders.lf.hid.hid import HIDProxTag, HIDReader
from pm3py.transponders.lf.atmel.t5577 import T5577Tag, T5577Reader
from pm3py.transponders.implants import xEM, xNT, xM1, FlexDF, NExT, MagicMifareClassicTag
__all__ = [
"RFFrame",
"DirtyByteArray", "MemoryRegion", "BlockAccess",

View File

@@ -1,348 +1,2 @@
"""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]))
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.auth_aes import * # noqa: F401,F403

View File

@@ -1,116 +1,2 @@
"""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]))
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.auth_password import * # noqa: F401,F403

View File

@@ -1,103 +1,2 @@
"""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)
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso14443a.nxp.crypto1 import * # noqa: F401,F403

View File

@@ -1,276 +1,2 @@
"""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}
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso14443a.nxp.desfire import * # noqa: F401,F403

View File

@@ -1,99 +1,2 @@
"""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}
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.lf.em.em4100 import * # noqa: F401,F403

View File

@@ -1,121 +1,2 @@
"""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)
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.lf.hid.hid import * # noqa: F401,F403

View File

@@ -1,414 +1,2 @@
"""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)
)
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.icode3 import * # noqa: F401,F403

View File

@@ -1,276 +1,2 @@
"""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)
)
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.icode_dna import * # noqa: F401,F403

View File

@@ -1,169 +1,2 @@
"""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))
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.icode_slix import * # noqa: F401,F403

View File

@@ -1,257 +1,2 @@
"""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)
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.icode_slix2 import * # noqa: F401,F403

View File

@@ -1,66 +1,2 @@
"""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
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.implants import * # noqa: F401,F403

View File

@@ -1,387 +1,2 @@
"""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:]
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso14443a.base import * # noqa: F401,F403

View File

@@ -1,518 +1,2 @@
"""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,
}
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.base import * # noqa: F401,F403

View File

@@ -1,69 +1,2 @@
"""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}
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.lf.base import * # noqa: F401,F403

View File

@@ -1,358 +1,2 @@
"""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}
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso14443a.nxp.mifare_classic import * # noqa: F401,F403

View File

@@ -1,282 +1,2 @@
"""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
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso14443a.ndef import * # noqa: F401,F403

View File

@@ -1,23 +1,2 @@
"""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)
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.ntag5_boost import * # noqa: F401,F403

View File

@@ -1,261 +1,2 @@
"""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]))
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.ntag5_link import * # noqa: F401,F403

View File

@@ -1,446 +1,2 @@
"""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
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.ntag5_platform import * # noqa: F401,F403

View File

@@ -1,21 +1,2 @@
"""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)
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.ntag5_switch import * # noqa: F401,F403

View File

@@ -1,86 +1,2 @@
"""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
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.nxp.nxp_icode import * # noqa: F401,F403

View File

@@ -1,163 +1,2 @@
"""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}
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.lf.atmel.t5577 import * # noqa: F401,F403

View File

@@ -1,150 +1,2 @@
"""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)
"""Backward compat shim — moved to pm3py.transponders."""
from pm3py.transponders.hf.iso15693.type5 import * # noqa: F401,F403

View File

@@ -0,0 +1 @@
"""ISO 14443-A transponders (Layer 3 + Layer 4)."""

View File

@@ -0,0 +1,394 @@
"""ISO 14443-A transponder and reader state machines."""
from __future__ import annotations
from enum import IntEnum
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import Medium
from pm3py.sim.trace_fmt import decode_14443a
from pm3py.sim.transponder import Transponder
from pm3py.sim.reader import Reader
__all__ = [
"REQA", "WUPA", "HLTA", "CL1", "CL2", "CL3", "CT", "RATS_CMD",
"NVB_ANTICOL", "NVB_SELECT", "CL_MAP",
"State14443A", "Tag14443A", "Tag14443A_3", "Tag14443A_4", "Reader14443A",
"_compute_bcc", "_split_uid_cascades",
]
# ---- 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:]

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 pm3py.sim.frame import RFFrame
from .base 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

View File

@@ -0,0 +1 @@
"""NXP ISO 14443-A transponders (MIFARE Classic, DESFire)."""

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)

View File

@@ -0,0 +1,276 @@
"""DESFire EV1/EV2 transponder and reader models."""
from __future__ import annotations
import os
import struct
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import Medium
from ..base 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}

View File

@@ -0,0 +1,358 @@
"""MIFARE Classic transponder and reader models with Crypto-1 authentication."""
from __future__ import annotations
import struct
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import Medium
from ..base 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 pm3py.sim.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}

View File

@@ -1 +0,0 @@
"""ISO 14443-A Layer 3 transponders (MIFARE Classic, Ultralight, NTAG)."""

View File

@@ -1 +0,0 @@
"""NXP ISO 14443-A Layer 3 transponders."""

View File

@@ -1 +0,0 @@
"""ISO 14443-A Layer 4 (ISO-DEP) transponders (DESFire, NTAG 5 via -4)."""

View File

@@ -1 +0,0 @@
"""NXP ISO 14443-A Layer 4 transponders."""

View File

@@ -0,0 +1,529 @@
"""ISO 15693 transponder and reader state machines."""
from __future__ import annotations
import os
import struct
from enum import IntEnum
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import Medium
from pm3py.sim.trace_fmt import decode_15693
from pm3py.sim.transponder import Transponder
from pm3py.sim.reader import Reader
__all__ = [
"FLAG_SUBCARRIER", "FLAG_HIGH_DATA_RATE", "FLAG_INVENTORY", "FLAG_PROTOCOL_EXT",
"FLAG_SELECT", "FLAG_ADDRESS", "FLAG_INV_SLOTS1", "FLAG_OPTION", "FLAG_RFU",
"RESP_ERROR",
"CMD_INVENTORY", "CMD_STAY_QUIET", "CMD_READ_SINGLE", "CMD_WRITE_SINGLE",
"CMD_READ_MULTIPLE", "CMD_RESET_TO_READY", "CMD_SYSTEM_INFO",
"CMD_EXT_READ_SINGLE", "CMD_EXT_WRITE_SINGLE", "CMD_EXT_READ_MULTIPLE",
"SYSINFO_DSFID", "SYSINFO_AFI", "SYSINFO_MEMSIZE", "SYSINFO_ICREF",
"State15693", "Tag15693", "Reader15693",
]
# ---- 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 pm3py.sim.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 pm3py.sim.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 pm3py.core.transport import encode_ng_frame
from pm3py.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,
}

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 pm3py.sim.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 pm3py.sim.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 pm3py.sim.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 pm3py.sim.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]))

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 pm3py.sim.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]))

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 pm3py.sim.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 pm3py.sim.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)
)

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 pm3py.sim.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 pm3py.sim.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)
)

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 pm3py.sim.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))

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 pm3py.sim.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 pm3py.sim.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 ..base 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 ..base 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)

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)

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 pm3py.sim.frame import RFFrame
from ..base 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]))

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 pm3py.sim.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 pm3py.sim.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

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)

View File

@@ -0,0 +1,86 @@
"""NXP ICODE/NTAG5 platform — shared NXP custom commands."""
from __future__ import annotations
import os
import struct
from pm3py.sim.frame import RFFrame
from pm3py.sim.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

View File

@@ -0,0 +1,150 @@
"""NFC Forum Type 5 Tag — extends ISO 15693 with NDEF support."""
from __future__ import annotations
from .base 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)

View File

@@ -0,0 +1,66 @@
"""Implant preset profiles — pre-configured transponder instances."""
from __future__ import annotations
import os
from .lf.atmel.t5577 import T5577Tag
from .hf.iso14443a.ndef import NfcType2Tag
from .hf.iso14443a.nxp.mifare_classic import MifareClassicTag
from .hf.iso14443a.nxp.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

View File

@@ -0,0 +1,163 @@
"""T5577/ATA5577 — programmable LF emulator transponder."""
from __future__ import annotations
import struct
from pm3py.sim.frame import RFFrame
from ..base import TagLF, ReaderLF, Modulation
from ..em.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}

View File

@@ -0,0 +1,69 @@
"""LF transponder and reader base classes."""
from __future__ import annotations
from enum import Enum
from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import Medium
from pm3py.sim.transponder import Transponder
from pm3py.sim.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}

View File

@@ -0,0 +1,99 @@
"""EM4100/EM4102 — read-only 64-bit Manchester ASK transponder."""
from __future__ import annotations
from pm3py.sim.frame import RFFrame
from ..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}

View File

@@ -0,0 +1 @@
"""HID Proximity transponders."""

View File

@@ -0,0 +1,121 @@
"""HID Proximity — FSK modulated Wiegand credential transponder."""
from __future__ import annotations
from pm3py.sim.frame import RFFrame
from ..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)