From 18a992f3c759374121af605cad41f7fee0ae3f52 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 18 Mar 2026 20:32:47 -0700 Subject: [PATCH] refactor: reorganize transponder models into pm3py/transponders/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pm3py/sim/__init__.py | 50 +- pm3py/sim/auth_aes.py | 350 +----------- pm3py/sim/auth_password.py | 118 +--- pm3py/sim/crypto1.py | 105 +--- pm3py/sim/desfire.py | 278 +-------- pm3py/sim/em4100.py | 101 +--- pm3py/sim/hid.py | 123 +--- pm3py/sim/icode3.py | 416 +------------- pm3py/sim/icode_dna.py | 278 +-------- pm3py/sim/icode_slix.py | 171 +----- pm3py/sim/icode_slix2.py | 259 +-------- pm3py/sim/implants.py | 68 +-- pm3py/sim/iso14443a.py | 389 +------------ pm3py/sim/iso15693.py | 520 +---------------- pm3py/sim/lf_base.py | 71 +-- pm3py/sim/mifare.py | 360 +----------- pm3py/sim/ndef.py | 284 +--------- pm3py/sim/ntag5_boost.py | 25 +- pm3py/sim/ntag5_link.py | 263 +-------- pm3py/sim/ntag5_platform.py | 448 +-------------- pm3py/sim/ntag5_switch.py | 23 +- pm3py/sim/nxp_icode.py | 88 +-- pm3py/sim/t5577.py | 165 +----- pm3py/sim/type5.py | 152 +---- pm3py/transponders/hf/iso14443a/__init__.py | 1 + pm3py/transponders/hf/iso14443a/base.py | 394 +++++++++++++ pm3py/transponders/hf/iso14443a/ndef.py | 282 ++++++++++ .../transponders/hf/iso14443a/nxp/__init__.py | 1 + .../transponders/hf/iso14443a/nxp/crypto1.py | 103 ++++ .../transponders/hf/iso14443a/nxp/desfire.py | 276 +++++++++ .../hf/iso14443a/nxp/mifare_classic.py | 358 ++++++++++++ pm3py/transponders/hf/iso14443a3/__init__.py | 1 - .../hf/iso14443a3/nxp/__init__.py | 1 - pm3py/transponders/hf/iso14443a4/__init__.py | 1 - .../hf/iso14443a4/nxp/__init__.py | 1 - pm3py/transponders/hf/iso15693/base.py | 529 ++++++++++++++++++ .../transponders/hf/iso15693/nxp/auth_aes.py | 348 ++++++++++++ .../hf/iso15693/nxp/auth_password.py | 116 ++++ pm3py/transponders/hf/iso15693/nxp/icode3.py | 414 ++++++++++++++ .../transponders/hf/iso15693/nxp/icode_dna.py | 276 +++++++++ .../hf/iso15693/nxp/icode_slix.py | 169 ++++++ .../hf/iso15693/nxp/icode_slix2.py | 257 +++++++++ .../hf/iso15693/nxp/ntag5_boost.py | 23 + .../hf/iso15693/nxp/ntag5_link.py | 261 +++++++++ .../hf/iso15693/nxp/ntag5_platform.py | 446 +++++++++++++++ .../hf/iso15693/nxp/ntag5_switch.py | 21 + .../transponders/hf/iso15693/nxp/nxp_icode.py | 86 +++ pm3py/transponders/hf/iso15693/type5.py | 150 +++++ pm3py/transponders/implants.py | 66 +++ pm3py/transponders/lf/atmel/t5577.py | 163 ++++++ pm3py/transponders/lf/base.py | 69 +++ pm3py/transponders/lf/em/em4100.py | 99 ++++ pm3py/transponders/lf/hid/__init__.py | 1 + pm3py/transponders/lf/hid/hid.py | 121 ++++ 54 files changed, 5103 insertions(+), 5036 deletions(-) create mode 100644 pm3py/transponders/hf/iso14443a/__init__.py create mode 100644 pm3py/transponders/hf/iso14443a/base.py create mode 100644 pm3py/transponders/hf/iso14443a/ndef.py create mode 100644 pm3py/transponders/hf/iso14443a/nxp/__init__.py create mode 100644 pm3py/transponders/hf/iso14443a/nxp/crypto1.py create mode 100644 pm3py/transponders/hf/iso14443a/nxp/desfire.py create mode 100644 pm3py/transponders/hf/iso14443a/nxp/mifare_classic.py delete mode 100644 pm3py/transponders/hf/iso14443a3/__init__.py delete mode 100644 pm3py/transponders/hf/iso14443a3/nxp/__init__.py delete mode 100644 pm3py/transponders/hf/iso14443a4/__init__.py delete mode 100644 pm3py/transponders/hf/iso14443a4/nxp/__init__.py create mode 100644 pm3py/transponders/hf/iso15693/base.py create mode 100644 pm3py/transponders/hf/iso15693/nxp/auth_aes.py create mode 100644 pm3py/transponders/hf/iso15693/nxp/auth_password.py create mode 100644 pm3py/transponders/hf/iso15693/nxp/icode3.py create mode 100644 pm3py/transponders/hf/iso15693/nxp/icode_dna.py create mode 100644 pm3py/transponders/hf/iso15693/nxp/icode_slix.py create mode 100644 pm3py/transponders/hf/iso15693/nxp/icode_slix2.py create mode 100644 pm3py/transponders/hf/iso15693/nxp/ntag5_boost.py create mode 100644 pm3py/transponders/hf/iso15693/nxp/ntag5_link.py create mode 100644 pm3py/transponders/hf/iso15693/nxp/ntag5_platform.py create mode 100644 pm3py/transponders/hf/iso15693/nxp/ntag5_switch.py create mode 100644 pm3py/transponders/hf/iso15693/nxp/nxp_icode.py create mode 100644 pm3py/transponders/hf/iso15693/type5.py create mode 100644 pm3py/transponders/implants.py create mode 100644 pm3py/transponders/lf/atmel/t5577.py create mode 100644 pm3py/transponders/lf/base.py create mode 100644 pm3py/transponders/lf/em/em4100.py create mode 100644 pm3py/transponders/lf/hid/__init__.py create mode 100644 pm3py/transponders/lf/hid/hid.py diff --git a/pm3py/sim/__init__.py b/pm3py/sim/__init__.py index cd0739c..0ba922f 100644 --- a/pm3py/sim/__init__.py +++ b/pm3py/sim/__init__.py @@ -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", diff --git a/pm3py/sim/auth_aes.py b/pm3py/sim/auth_aes.py index ccfbb33..40753f7 100644 --- a/pm3py/sim/auth_aes.py +++ b/pm3py/sim/auth_aes.py @@ -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 diff --git a/pm3py/sim/auth_password.py b/pm3py/sim/auth_password.py index 47d71ec..2fab4a6 100644 --- a/pm3py/sim/auth_password.py +++ b/pm3py/sim/auth_password.py @@ -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(" int: - """Decode XOR'd password bytes to integer value.""" - r = self._last_random - pwd_bytes = bytes([ - xored_bytes[0] ^ r[0], xored_bytes[1] ^ r[1], - xored_bytes[2] ^ r[0], xored_bytes[3] ^ r[1], - ]) - return struct.unpack(" RFFrame | None: - """SET_PASSWORD (0xB3): authenticate with XOR'd password.""" - if len(frame.data) < 8: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - pwd_id = frame.data[3] - pwd_xored = frame.data[4:8] - - if not self._verify_xor_password(pwd_id, pwd_xored): - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - self._authenticated_passwords.add(pwd_id) - return RFFrame.from_bytes(bytes([0x00])) - - # ----- WRITE PASSWORD ----- - - def _handle_write_password(self, frame: RFFrame) -> RFFrame | None: - """WRITE PASSWORD (0xB4): change password value. - - Requires prior SET_PASSWORD for the same pwd_id. - """ - if len(frame.data) < 8: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - pwd_id = frame.data[3] - - if pwd_id not in self._authenticated_passwords: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - if pwd_id in self._locked_passwords: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - if self._passwords.get(pwd_id) is None: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - new_value = self._decode_xor_password(frame.data[4:8]) - self._passwords[pwd_id] = new_value - self._authenticated_passwords.discard(pwd_id) - return RFFrame.from_bytes(bytes([0x00])) - - # ----- LOCK PASSWORD ----- - - def _handle_lock_password(self, frame: RFFrame) -> RFFrame | None: - """LOCK PASSWORD (0xB5): permanently lock a password.""" - if len(frame.data) < 4: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - pwd_id = frame.data[3] - if pwd_id not in self._authenticated_passwords: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - self._locked_passwords.add(pwd_id) - return RFFrame.from_bytes(bytes([0x00])) +"""Backward compat shim — moved to pm3py.transponders.""" +from pm3py.transponders.hf.iso15693.nxp.auth_password import * # noqa: F401,F403 diff --git a/pm3py/sim/crypto1.py b/pm3py/sim/crypto1.py index 0430f87..b394bb8 100644 --- a/pm3py/sim/crypto1.py +++ b/pm3py/sim/crypto1.py @@ -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 diff --git a/pm3py/sim/desfire.py b/pm3py/sim/desfire.py index c2dc0d7..0d284b7 100644 --- a/pm3py/sim/desfire.py +++ b/pm3py/sim/desfire.py @@ -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 diff --git a/pm3py/sim/em4100.py b/pm3py/sim/em4100.py index 4a80f95..93f85be 100644 --- a/pm3py/sim/em4100.py +++ b/pm3py/sim/em4100.py @@ -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 diff --git a/pm3py/sim/hid.py b/pm3py/sim/hid.py index 48e63a3..ae7d253 100644 --- a/pm3py/sim/hid.py +++ b/pm3py/sim/hid.py @@ -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 diff --git a/pm3py/sim/icode3.py b/pm3py/sim/icode3.py index a02d116..d21c4b4 100644 --- a/pm3py/sim/icode3.py +++ b/pm3py/sim/icode3.py @@ -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(" None: - """Override: block 75 = 24-bit counter (not block 79 = 16-bit).""" - has_pp = self._protection_pointer is not None - from .memory import BlockAccess - user = self.regions["user"] - access_map = [] - for block in range(self._num_blocks): - ba = BlockAccess() - if has_pp and block >= self._protection_pointer: - ba = BlockAccess(read="password", read_key=PWD_READ) - if block == 75: - ba = BlockAccess(read=ba.read, read_key=ba.read_key, - write_mode="counter24") - access_map.append(ba) - user.access_map = access_map - - # ----- 48-byte originality signature (SL2S3003 8.5.3.20) ----- - - def set_signature_mode_48(self, enabled: bool) -> None: - """Enable/disable 48-byte originality signature mode (OS_CFG_MODE).""" - self._signature_48 = enabled - - def _handle_read_signature(self, frame: RFFrame) -> RFFrame | None: - """READ SIGNATURE: returns 32 or 48 bytes depending on OS_CFG_MODE.""" - if self._signature_48: - # Pad/extend signature to 48 bytes - sig = (self._signature + bytes(48))[:48] - return RFFrame.from_bytes(bytes([0x00]) + sig) - # Default 32-byte mode: truncate to 32 bytes - sig = (self._signature + bytes(32))[:32] - return RFFrame.from_bytes(bytes([0x00]) + sig) - - # ----- Command dispatch (ICODE 3-specific) ----- - - def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None: - frame = self._normalize_custom_frame(frame) - if len(frame.data) >= 3 and frame.data[2] == NXP_MFG: - match cmd: - case 0xC0: # READ CONFIG - return self._handle_read_config(frame) - case 0xC1: # WRITE CONFIG - return self._handle_write_config(frame) - case 0xC2: # PICK RANDOM ID - return self._handle_pick_random_id(frame) - case 0xC4: # READ TT - return self._handle_read_tt(frame) - case 0xAB: # GET NXP SYSTEM INFO — override with ICODE 3 data - return self._handle_get_nxp_system_info(frame) - return super()._handle_custom_command(cmd, frame) - - # ----- PICK RANDOM ID (0xC2) ----- - - def _handle_pick_random_id(self, frame: RFFrame) -> RFFrame | None: - """PICK RANDOM ID (0xC2): generate random UID for privacy mode 2. - - Random ID format: E0 04 00 00 CID1 CID0 RID1 RID0 - CID = customer ID from config, RID = random 16-bit. - """ - if not self._privacy_mode: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - # CID from config memory block 19 (CID0, CID1) - cid0 = self._config_memory[19 * 4] - cid1 = self._config_memory[19 * 4 + 1] - - rid = os.urandom(2) - self._random_id = bytes([0xE0, 0x04, 0x00, 0x00, cid1, cid0, rid[0], rid[1]]) - return RFFrame.from_bytes(bytes([0x00])) - - # ----- READ CONFIG (0xC0) ----- - - def _handle_read_config(self, frame: RFFrame) -> RFFrame | None: - """READ CONFIG (0xC0): read config memory blocks. - - Format: flags(1) + cmd(1) + mfg(1) + block_addr(1) + num_blocks(1) - Response: flags(1) + data(4 * (num_blocks+1)) - Passwords (blocks 41-46) are masked with 0x00. - """ - if len(frame.data) < 5: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - block_addr = frame.data[3] - num_blocks = frame.data[4] + 1 # 0 = 1 block - - if block_addr + num_blocks > 128: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - resp = bytearray([0x00]) # flags - for blk in range(block_addr, block_addr + num_blocks): - offset = blk * 4 - if blk in _CONFIG_PWD_BLOCKS: - resp.extend(bytes(4)) # masked - else: - resp.extend(self._config_memory[offset:offset + 4]) - - return RFFrame.from_bytes(bytes(resp)) - - # ----- WRITE CONFIG (0xC1) ----- - - def _handle_write_config(self, frame: RFFrame) -> RFFrame | None: - """WRITE CONFIG (0xC1): write 4 bytes to config memory block. - - Format: flags(1) + cmd(1) + mfg(1) + block_addr(1) + data(4) - """ - if len(frame.data) < 8: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - if self._config_locked: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - if self._config_password_protected: - if PWD_CONFIG not in self._authenticated_passwords: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - block_addr = frame.data[3] - if block_addr >= 128: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - data = frame.data[4:8] - offset = block_addr * 4 - self._config_memory[offset:offset + 4] = data - - return RFFrame.from_bytes(bytes([0x00])) - - # ----- READ TT (0xC4) ----- - - def _handle_read_tt(self, frame: RFFrame) -> RFFrame | None: - """READ TT (0xC4): TagTamper status. - - Response: flags(1) + actual_status(1) + stored_status(1) - """ - if not self._tag_tamper_supported: - return None - - return RFFrame.from_bytes(bytes([ - 0x00, self._tt_status_actual, self._tt_status_stored - ])) - - # ----- GET NXP SYSTEM INFO override ----- - - def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None: - """GET NXP SYSTEM INFO with ICODE 3 feature flags.""" - pp = self._protection_pointer if self._protection_pointer is not None else 0 - pp_cond = 0x00 - if self._protection_pointer is not None: - pp_cond = 0x01 # read protection - - lock_bits = 0x00 - feature_flags = ( - _FEATURE_UM_PP | _FEATURE_COUNTER | _FEATURE_EAS_ID | - _FEATURE_ORIGINALITY_SIG | _FEATURE_P_QUIET | - _FEATURE_PRIVACY | _FEATURE_DESTROY | _FEATURE_HIGH_DATA_RATES - ) - if self._tag_tamper_supported: - feature_flags |= _FEATURE_TAGTAMPER - - return RFFrame.from_bytes( - bytes([0x00, pp, pp_cond, lock_bits]) + - struct.pack(" None: - """Override: counter on last block.""" - has_pp = self._protection_pointer is not None - last_block = self._num_blocks - 1 - - if not has_pp and last_block < 0: - self.regions["user"].access_map = None - return - - from .memory import BlockAccess - user = self.regions["user"] - access_map = [] - for block in range(self._num_blocks): - ba = BlockAccess() - if has_pp and block >= self._protection_pointer: - ba = BlockAccess(read="password", read_key=PWD_READ) - if block == last_block: - ba = BlockAccess(read=ba.read, read_key=ba.read_key, - write_mode="counter") - access_map.append(ba) - user.access_map = access_map - - # ----- Command dispatch ----- - - async def handle_frame(self, frame: RFFrame) -> RFFrame | None: - """Override to intercept ISO 29167-10 CHALLENGE/READBUFFER/AUTHENTICATE. - - Commands 0x35 (AUTHENTICATE), 0x39 (CHALLENGE) and 0x3A (READBUFFER) - are standard ISO commands, not NXP custom (>= 0xA0), so they must be - caught here before the base class returns "not supported". - """ - if len(frame.data) >= 2: - cmd = frame.data[1] - if cmd == 0x35: # AUTHENTICATE (MAM1/MAM2) - return self._handle_authenticate(frame) - if cmd == 0x39: # CHALLENGE - self._handle_challenge(frame) - return None # no RF response - if cmd == 0x3A: # READBUFFER - return self._handle_readbuffer(frame) - return await super().handle_frame(frame) - - def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None: - frame = self._normalize_custom_frame(frame) - if len(frame.data) >= 3 and frame.data[2] == NXP_MFG: - match cmd: - case 0xC0: # READ CONFIG - return self._handle_read_config(frame) - case 0xC1: # WRITE CONFIG - return self._handle_write_config(frame) - return super()._handle_custom_command(cmd, frame) - - # ----- READ CONFIG ----- - - def _handle_read_config(self, frame: RFFrame) -> RFFrame | None: - """READ CONFIG (0xC0): read config memory blocks. - - Key blocks (0x20-0x2F) masked with 0x00 when key header is active (0xE7). - """ - if len(frame.data) < 5: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - block_addr = frame.data[3] - num_blocks = frame.data[4] + 1 - - if block_addr + num_blocks > 48: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - resp = bytearray([0x00]) - for blk in range(block_addr, block_addr + num_blocks): - offset = blk * 4 - # Mask key storage when header is active/locked - if blk in _CONFIG_KEY_BLOCKS: - key_idx = (blk - 0x20) // 4 - if key_idx < 4 and self._key_headers[key_idx] == KEY_HEADER_ACTIVE_LOCKED: - resp.extend(bytes(4)) - continue - resp.extend(self._config_memory[offset:offset + 4]) - - return RFFrame.from_bytes(bytes(resp)) - - # ----- WRITE CONFIG ----- - - def _handle_write_config(self, frame: RFFrame) -> RFFrame | None: - """WRITE CONFIG (0xC1): write 4 bytes to config block.""" - if len(frame.data) < 8: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - block_addr = frame.data[3] - if block_addr >= 48: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - data = frame.data[4:8] - - # Key storage: reject writes when header is active - if block_addr in _CONFIG_KEY_BLOCKS: - key_idx = (block_addr - 0x20) // 4 - if key_idx < 4 and self._key_headers[key_idx] == KEY_HEADER_ACTIVE_LOCKED: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - offset = block_addr * 4 - self._config_memory[offset:offset + 4] = data - - # Track key header changes - kh_blocks = {0x10: 0, 0x12: 1, 0x14: 2, 0x16: 3} - if block_addr in kh_blocks: - key_idx = kh_blocks[block_addr] - new_header = data[1] # Key header is byte 1 - # One-way: only allow lower → higher values - if new_header >= self._key_headers[key_idx]: - self._key_headers[key_idx] = new_header - - # Track key privilege changes - kp_blocks = {0x11: 0, 0x13: 1, 0x15: 2, 0x17: 3} - if block_addr in kp_blocks: - key_idx = kp_blocks[block_addr] - self._key_privileges[key_idx] = data[0] - - # Track GCH changes (block 0x0C, byte 1) - if block_addr == 0x0C: - new_gch = data[1] - if new_gch >= self._nfc_gch: # one-way - self._nfc_gch = new_gch - - # Track config header changes (block 0x08, byte 0) - if block_addr == 0x08: - new_ch = data[0] - if new_ch >= self._config_header: # one-way - self._config_header = new_ch - - return RFFrame.from_bytes(bytes([0x00])) - - # ----- GET NXP SYSTEM INFO override ----- - - def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None: - """GET NXP SYSTEM INFO with DNA feature flags.""" - pp = self._protection_pointer if self._protection_pointer is not None else 0 - pp_cond = 0x01 if self._protection_pointer is not None else 0x00 - lock_bits = 0x00 - - # DNA feature flags (similar to ICODE 3 Table 113 but without TagTamper/mirror) - feature_flags = ( - (1 << 0) | # UM_PP - (1 << 1) | # COUNTER - (1 << 2) | # EAS_ID - (1 << 3) | # EAS_PP - (1 << 4) | # AFI_PP - (1 << 8) | # ORIGINALITY_SIG - (1 << 10) | # P_QUIET - (1 << 12) | # PRIVACY - (1 << 13) | # DESTROY - (1 << 15) # HIGH_DATA_RATES - ) - - return RFFrame.from_bytes( - bytes([0x00, pp, pp_cond, lock_bits]) + - struct.pack(" bool: - return self._eas_enabled - - def set_eas(self, enabled: bool) -> None: - self._eas_enabled = enabled - - # ----- Frame normalization ----- - - def _normalize_custom_frame(self, frame: RFFrame) -> RFFrame: - """Normalize addressed custom commands by stripping UID. - - Addressed: flags(1) + cmd(1) + uid(8) + mfg(1) + data... - Unaddressed: flags(1) + cmd(1) + mfg(1) + data... - Returns unaddressed-format frame for uniform processing. - """ - flags = frame.data[0] - if (flags & 0x20) and not (flags & 0x04): # addressed, not inventory - normalized = bytes([flags & ~0x20]) + frame.data[1:2] + frame.data[10:] - return RFFrame.from_bytes(normalized) - return frame - - # ----- Custom command dispatch ----- - - def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None: - """Handle SLIX custom commands, fall through to NXP base.""" - frame = self._normalize_custom_frame(frame) - if len(frame.data) >= 3 and frame.data[2] == NXP_MFG: - match cmd: - case 0xA0 | 0xA1: # INVENTORY READ / FAST INVENTORY READ - return self._handle_inventory_read(frame) - case 0xA2: # SET EAS - return self._handle_set_eas(frame) - case 0xA3: # RESET EAS - return self._handle_reset_eas(frame) - case 0xA4: # LOCK EAS - return self._handle_lock_eas(frame) - case 0xA5: # EAS ALARM - return self._handle_eas_alarm(frame) - case 0xA6: # PASSWORD PROTECT EAS/AFI - return self._handle_password_protect_eas_afi(frame) - case 0xB2: # GET RANDOM — override to store random - return self._handle_get_random(frame) - case 0xB3: # SET_PASSWORD - return self._handle_set_password(frame) - case 0xB4: # WRITE PASSWORD - return self._handle_write_password(frame) - case 0xB5: # LOCK PASSWORD - return self._handle_lock_password(frame) - return super()._handle_custom_command(cmd, frame) - - # ----- EAS auth check ----- - - def _check_eas_auth(self) -> bool: - """Check if EAS/AFI operations are allowed.""" - if not self._eas_password_protected: - return True - return PWD_EAS_AFI in self._authenticated_passwords - - # ----- EAS subsystem ----- - - def _handle_set_eas(self, frame: RFFrame) -> RFFrame | None: - if self._eas_locked: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - if not self._check_eas_auth(): - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - self._eas_enabled = True - return RFFrame.from_bytes(bytes([0x00])) - - def _handle_reset_eas(self, frame: RFFrame) -> RFFrame | None: - if self._eas_locked: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - if not self._check_eas_auth(): - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - self._eas_enabled = False - return RFFrame.from_bytes(bytes([0x00])) - - def _handle_lock_eas(self, frame: RFFrame) -> RFFrame | None: - if not self._check_eas_auth(): - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - self._eas_locked = True - return RFFrame.from_bytes(bytes([0x00])) - - def _handle_eas_alarm(self, frame: RFFrame) -> RFFrame | None: - if not self._eas_enabled: - return None - return RFFrame.from_bytes(bytes([0x00]) + self._eas_sequence) - - def _handle_password_protect_eas_afi(self, frame: RFFrame) -> RFFrame | None: - if PWD_EAS_AFI not in self._authenticated_passwords: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - self._eas_password_protected = True - return RFFrame.from_bytes(bytes([0x00])) - - # ----- INVENTORY READ ----- - - def _handle_inventory_read(self, frame: RFFrame) -> RFFrame | None: - """INVENTORY READ / FAST INVENTORY READ (0xA0/0xA1).""" - if len(frame.data) < 6: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - first_block = frame.data[4] - num_blocks = frame.data[5] + 1 - - resp = bytearray([0x00]) - resp.append(self._dsfid) - resp.extend(self._uid) - - for blk in range(first_block, first_block + num_blocks): - if blk >= self._num_blocks: - break - offset = blk * self._block_size - resp.extend(self._memory[offset:offset + self._block_size]) - - return RFFrame.from_bytes(bytes(resp)) +"""Backward compat shim — moved to pm3py.transponders.""" +from pm3py.transponders.hf.iso15693.nxp.icode_slix import * # noqa: F401,F403 diff --git a/pm3py/sim/icode_slix2.py b/pm3py/sim/icode_slix2.py index cb10003..abcd7ae 100644 --- a/pm3py/sim/icode_slix2.py +++ b/pm3py/sim/icode_slix2.py @@ -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(" RFFrame | None: - """ENABLE PRIVACY (0xBA): enter privacy mode using XOR'd password.""" - if len(frame.data) < 7: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - if not self._verify_xor_password(PWD_PRIVACY, frame.data[3:7]): - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - self._privacy_mode = True - return RFFrame.from_bytes(bytes([0x00])) - - def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None: - """GET NXP SYSTEM INFO (0xAB): SLIX2-specific response.""" - pp = self._protection_pointer if self._protection_pointer is not None else 0 - pp_cond = 0x00 - if self._protection_pointer is not None: - pp_cond = PP_COND_READ_PROTECTION - lock_bits = 0x00 - feature_flags = struct.pack(" RFFrame | None: - """PROTECT PAGE (0xB6): set protection pointer and conditions.""" - if len(frame.data) < 5: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - if self._page_protection_locked: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - if PWD_WRITE not in self._authenticated_passwords: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - self._protection_pointer = frame.data[3] - self._build_access_map() - return RFFrame.from_bytes(bytes([0x00])) - - def _handle_lock_page_protection(self, frame: RFFrame) -> RFFrame | None: - """LOCK PAGE PROTECTION (0xB7): permanently lock protection config.""" - if PWD_WRITE not in self._authenticated_passwords: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - self._page_protection_locked = True - return RFFrame.from_bytes(bytes([0x00])) - - def _handle_destroy(self, frame: RFFrame) -> RFFrame | None: - """DESTROY (0xB9): irreversibly kill tag.""" - if len(frame.data) < 7: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - if not self._verify_xor_password(PWD_DESTROY, frame.data[3:7]): - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - - self._destroyed = True - return RFFrame.from_bytes(bytes([0x00])) - - def _handle_64bit_password_protection(self, frame: RFFrame) -> RFFrame | None: - """64-BIT PASSWORD PROTECTION (0xBB): requires both read + write auth.""" - if PWD_READ not in self._authenticated_passwords: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - if PWD_WRITE not in self._authenticated_passwords: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - self._extended_protection = True - return RFFrame.from_bytes(bytes([0x00])) - - def _handle_stay_quiet_persistent(self, frame: RFFrame) -> RFFrame | None: - """STAY QUIET PERSISTENT (0xBC): persistent quiet survives power cycles.""" - from .iso15693 import State15693 - self._persistent_quiet = True - self._state = State15693.QUIET - return RFFrame.from_bytes(bytes([0x00])) - - def _handle_read_single(self, frame: RFFrame) -> RFFrame | None: - """Override to enforce read protection.""" - block = self._extract_block_number(frame) - if block is None: - return None - if self._protection_pointer is not None and block >= self._protection_pointer: - if PWD_READ not in self._authenticated_passwords: - return RFFrame.from_bytes(bytes([0x01, 0x0F])) - return super()._handle_read_single(frame) +"""Backward compat shim — moved to pm3py.transponders.""" +from pm3py.transponders.hf.iso15693.nxp.icode_slix2 import * # noqa: F401,F403 diff --git a/pm3py/sim/implants.py b/pm3py/sim/implants.py index 0c62b94..32ac4bd 100644 --- a/pm3py/sim/implants.py +++ b/pm3py/sim/implants.py @@ -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 diff --git a/pm3py/sim/iso14443a.py b/pm3py/sim/iso14443a.py index 2e546f1..19ba79f 100644 --- a/pm3py/sim/iso14443a.py +++ b/pm3py/sim/iso14443a.py @@ -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 diff --git a/pm3py/sim/iso15693.py b/pm3py/sim/iso15693.py index 1831ba1..254dfaf 100644 --- a/pm3py/sim/iso15693.py +++ b/pm3py/sim/iso15693.py @@ -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(" None: - self._state = State15693.READY - - async def power_off(self) -> None: - self._state = State15693.POWER_OFF - - @property - def state(self) -> str: - return self._state.name - - async def handle_frame(self, frame: RFFrame) -> RFFrame | None: - if len(frame.data) < 2: - return None - - flags = frame.data[0] - cmd = frame.data[1] - - # Check if this is an addressed command and verify UID - is_inventory = bool(flags & FLAG_INVENTORY) - if not is_inventory and (flags & FLAG_ADDRESS): - if len(frame.data) < 10: - return None - frame_uid = bytes(reversed(frame.data[2:10])) - if frame_uid != self._uid: - return None - - match cmd: - case 0x01: # Inventory - if self._state == State15693.QUIET: - return None - return self._handle_inventory(frame) - case 0x02: # Stay Quiet - self._state = State15693.QUIET - return None # no response per spec - case 0x20: # Read Single Block - return self._handle_read_single(frame) - case 0x21: # Write Single Block - return self._handle_write_single(frame) - case 0x26: # Reset to Ready - self._state = State15693.READY - return self._make_response(b"") - case 0x2B: # Get System Information - return self._handle_system_info(frame) - case 0x30: # Extended Read Single Block - return self._handle_extended_read_single(frame) - case 0x31: # Extended Write Single Block - return self._handle_extended_write_single(frame) - case 0x33: # Extended Read Multiple Blocks - return self._handle_extended_read_multiple(frame) - case _ if cmd >= 0xA0: - return self._handle_custom_command(cmd, frame) - case _: - return self._make_error(0x01) # not supported - - def _handle_inventory(self, frame: RFFrame) -> RFFrame | None: - """Handle inventory command. Returns flags + DSFID + UID. - - Supports mask filtering: if mask_len > 0, only respond if our UID - matches the mask bits (compared LSB-first from UID byte 0). - """ - if len(frame.data) < 3: - return None - - mask_len = frame.data[2] # number of mask bits - if mask_len > 0: - # Mask bytes follow mask_len - mask_bytes_count = (mask_len + 7) // 8 - if len(frame.data) < 3 + mask_bytes_count: - return None - mask_data = frame.data[3:3 + mask_bytes_count] - uid_lsb = self._uid[::-1] # UID in LSB-first order - - # Compare mask bytes against UID (byte-level comparison) - # Full bytes must match exactly, partial last byte checked bit-by-bit - full_bytes = mask_len // 8 - for i in range(full_bytes): - if i < len(mask_data) and i < len(uid_lsb): - if mask_data[i] != uid_lsb[i]: - return None - # Check remaining bits in partial byte (MSB-first within byte - # to match bitarray ordering used by collision resolution) - remaining_bits = mask_len % 8 - if remaining_bits > 0 and full_bytes < len(mask_data) and full_bytes < len(uid_lsb): - # Mask the relevant bits (MSB-first: top N bits of byte) - shift = 8 - remaining_bits - mask_val = mask_data[full_bytes] >> shift - uid_val = uid_lsb[full_bytes] >> shift - if mask_val != uid_val: - return None - - resp = bytes([0x00, self._dsfid]) + self._uid[::-1] - return RFFrame.from_bytes(resp) - - def _handle_read_single(self, frame: RFFrame) -> RFFrame | None: - """Handle Read Single Block.""" - block = self._extract_block_number(frame) - if block is None: - return None - if block >= self._num_blocks: - return self._make_error(0x10) # block not available - - offset = block * self._block_size - data = self._memory[offset:offset + self._block_size] - return self._make_response(bytes(data)) - - def _handle_write_single(self, frame: RFFrame) -> RFFrame | None: - """Handle Write Single Block.""" - flags = frame.data[0] - is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS) - - if is_addressed: - block_offset = 10 # flags + cmd + UID(8) - else: - block_offset = 2 # flags + cmd - - if len(frame.data) <= block_offset: - return None - block = frame.data[block_offset] - if block >= self._num_blocks: - return self._make_error(0x10) - - data_offset = block_offset + 1 - data = frame.data[data_offset:data_offset + self._block_size] - if len(data) < self._block_size: - return self._make_error(0x0F) # unknown error - - offset = block * self._block_size - - # Check for special write modes (e.g. counter) - access = self.regions["user"].access_for_block(block) - if access.write_mode == "counter": - import struct as _st - increment = _st.unpack_from(" RFFrame | None: - """Handle Get System Information.""" - info_flags = SYSINFO_DSFID | SYSINFO_AFI | SYSINFO_MEMSIZE | SYSINFO_ICREF - resp = bytes([0x00, info_flags]) - resp += self._uid[::-1] # UID LSB first - resp += bytes([self._dsfid]) # DSFID - resp += bytes([self._afi]) # AFI - resp += bytes([self._num_blocks - 1, self._block_size - 1]) # mem size - resp += bytes([self._ic_reference]) # IC reference - return RFFrame.from_bytes(resp) - - def _extract_block_number(self, frame: RFFrame) -> int | None: - """Extract block number from frame, accounting for addressed mode.""" - flags = frame.data[0] - is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS) - if is_addressed: - if len(frame.data) < 11: - return None - return frame.data[10] - else: - if len(frame.data) < 3: - return None - return frame.data[2] - - def _extract_block_number_ext(self, frame: RFFrame) -> int | None: - """Extract 2-byte (LE16) block number for extended commands.""" - flags = frame.data[0] - is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS) - offset = 10 if is_addressed else 2 - if len(frame.data) < offset + 2: - return None - return struct.unpack_from(" RFFrame | None: - """Handle Extended Read Single Block (0x30) — 2-byte block address.""" - block = self._extract_block_number_ext(frame) - if block is None: - return None - if block >= self._num_blocks: - return self._make_error(0x10) # block not available - - offset = block * self._block_size - data = self._memory[offset:offset + self._block_size] - return self._make_response(bytes(data)) - - def _handle_extended_write_single(self, frame: RFFrame) -> RFFrame | None: - """Handle Extended Write Single Block (0x31) — 2-byte block address.""" - flags = frame.data[0] - is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS) - block_offset = 10 if is_addressed else 2 - - if len(frame.data) < block_offset + 2: - return None - block = struct.unpack_from("= self._num_blocks: - return self._make_error(0x10) - - data_offset = block_offset + 2 - data = frame.data[data_offset:data_offset + self._block_size] - if len(data) < self._block_size: - return self._make_error(0x0F) - - mem_offset = block * self._block_size - self._memory[mem_offset:mem_offset + self._block_size] = data - return self._make_response(b"") - - def _handle_extended_read_multiple(self, frame: RFFrame) -> RFFrame | None: - """Handle Extended Read Multiple Blocks (0x33) — 2-byte block address + count.""" - flags = frame.data[0] - is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS) - offset = 10 if is_addressed else 2 - - if len(frame.data) < offset + 4: - return None - block = struct.unpack_from(" self._num_blocks: - return self._make_error(0x10) - - result = bytearray() - for i in range(num_blocks): - b = block + i - mem_off = b * self._block_size - result.extend(self._memory[mem_off:mem_off + self._block_size]) - return self._make_response(bytes(result)) - - def _make_response(self, data: bytes) -> RFFrame: - """Build a success response frame.""" - return RFFrame.from_bytes(bytes([0x00]) + data) - - def _make_error(self, error_code: int) -> RFFrame: - """Build an error response frame.""" - return RFFrame.from_bytes(bytes([RESP_ERROR, error_code])) - - def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None: - """Extension point for vendor-specific commands (0xA0-0xDF). - Override in subclasses (e.g., NxpIcodeTag). - """ - return self._make_error(0x01) # not supported - - -# ---- Reader15693 ---- - -class Reader15693(Reader): - """ISO 15693 reader with inventory and block operations.""" - - async def run(self) -> dict: - tags = await self.inventory() - return {"tags": tags} - - async def inventory(self, slots: int = 1) -> list[dict]: - """Perform inventory. Returns list of {uid, dsfid}. - - Uses 1-slot inventory with mask-based collision resolution: - when collision is detected, narrows the mask to isolate tags. - """ - tags: list[dict] = [] - await self._inventory_recursive(tags, mask=b"", mask_bits=0) - return tags - - async def _inventory_recursive(self, tags: list[dict], - mask: bytes, mask_bits: int, - depth: int = 0) -> None: - """Recursive inventory with mask narrowing on collision.""" - if depth > 64: # max 64 UID bits - return - - inv_flags = 0x26 # high data rate + inventory + 1 slot - mask_bytes_count = (mask_bits + 7) // 8 - inv = bytes([inv_flags, CMD_INVENTORY, mask_bits]) + mask[:mask_bytes_count] - await self._medium.transmit_reader(RFFrame.from_bytes(inv)) - resp = await self._medium.receive_reader() - - if resp is None: - return - - if resp.has_collision and resp.collision_positions: - # Collision: find first collision bit in UID portion - # UID starts at byte 2 of response (bit 16 in MSB bitarray) - uid_bit_offset = 16 # flags(8) + dsfid(8) - col_in_uid = None - for pos in resp.collision_positions: - if pos >= uid_bit_offset: - col_in_uid = pos - uid_bit_offset - break - - if col_in_uid is not None: - # Try both values at the collision bit - # Mask includes bits 0..col_in_uid (inclusive) - new_mask_bits = col_in_uid + 1 - mask_byte_count = (new_mask_bits + 7) // 8 - from bitarray import bitarray as ba - resp_bits = ba(resp.bits[:resp.bit_count]) - - for bit_val in (0, 1): - bits_copy = ba(resp_bits) - bits_copy[uid_bit_offset + col_in_uid] = bit_val - uid_bytes = bits_copy[uid_bit_offset:uid_bit_offset + mask_byte_count * 8].tobytes() - await self._inventory_recursive(tags, uid_bytes, new_mask_bits, depth + 1) - return - - if len(resp.data) >= 10 and resp.data[0] == 0x00: - uid = bytes(reversed(resp.data[2:10])) - dsfid = resp.data[1] - # Avoid duplicates - if not any(t["uid"] == uid for t in tags): - tags.append({"uid": uid, "dsfid": dsfid}) - - async def read_block(self, uid: bytes, block: int) -> dict: - """Read a single block (addressed mode).""" - cmd = bytes([0x22, CMD_READ_SINGLE]) + uid[::-1] + bytes([block]) - await self._medium.transmit_reader(RFFrame.from_bytes(cmd)) - resp = await self._medium.receive_reader() - if resp is None or resp.data[0] & RESP_ERROR: - return {"success": False, "data": None} - return {"success": True, "data": resp.data[1:]} - - async def write_block(self, uid: bytes, block: int, data: bytes) -> dict: - """Write a single block (addressed mode).""" - cmd = bytes([0x22, CMD_WRITE_SINGLE]) + uid[::-1] + bytes([block]) + data - await self._medium.transmit_reader(RFFrame.from_bytes(cmd)) - resp = await self._medium.receive_reader() - if resp is None or resp.data[0] & RESP_ERROR: - return {"success": False} - return {"success": True} - - async def system_info(self, uid: bytes) -> dict: - """Get System Information (addressed mode).""" - cmd = bytes([0x22, CMD_SYSTEM_INFO]) + uid[::-1] - await self._medium.transmit_reader(RFFrame.from_bytes(cmd)) - resp = await self._medium.receive_reader() - if resp is None or resp.data[0] & RESP_ERROR: - return {"uid": None} - - data = resp.data - info_flags = data[1] - resp_uid = bytes(reversed(data[2:10])) - idx = 10 - dsfid = data[idx] if info_flags & SYSINFO_DSFID else 0 - idx += 1 if info_flags & SYSINFO_DSFID else 0 - afi = data[idx] if info_flags & SYSINFO_AFI else 0 - idx += 1 if info_flags & SYSINFO_AFI else 0 - num_blocks = (data[idx] + 1) if info_flags & SYSINFO_MEMSIZE else 0 - block_size = (data[idx + 1] + 1) if info_flags & SYSINFO_MEMSIZE else 0 - idx += 2 if info_flags & SYSINFO_MEMSIZE else 0 - ic_ref = data[idx] if info_flags & SYSINFO_ICREF else 0 - - return { - "uid": resp_uid, - "dsfid": dsfid, - "afi": afi, - "num_blocks": num_blocks, - "block_size": block_size, - "ic_reference": ic_ref, - } +"""Backward compat shim — moved to pm3py.transponders.""" +from pm3py.transponders.hf.iso15693.base import * # noqa: F401,F403 diff --git a/pm3py/sim/lf_base.py b/pm3py/sim/lf_base.py index c83115f..b6de2ff 100644 --- a/pm3py/sim/lf_base.py +++ b/pm3py/sim/lf_base.py @@ -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 diff --git a/pm3py/sim/mifare.py b/pm3py/sim/mifare.py index 488014f..a7d06db 100644 --- a/pm3py/sim/mifare.py +++ b/pm3py/sim/mifare.py @@ -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 diff --git a/pm3py/sim/ndef.py b/pm3py/sim/ndef.py index c78caa7..c1d6b09 100644 --- a/pm3py/sim/ndef.py +++ b/pm3py/sim/ndef.py @@ -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 diff --git a/pm3py/sim/ntag5_boost.py b/pm3py/sim/ntag5_boost.py index abc130c..6fd6ca7 100644 --- a/pm3py/sim/ntag5_boost.py +++ b/pm3py/sim/ntag5_boost.py @@ -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 diff --git a/pm3py/sim/ntag5_link.py b/pm3py/sim/ntag5_link.py index c69bf2a..6a42ca9 100644 --- a/pm3py/sim/ntag5_link.py +++ b/pm3py/sim/ntag5_link.py @@ -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 diff --git a/pm3py/sim/ntag5_platform.py b/pm3py/sim/ntag5_platform.py index 90113e2..12b5e23 100644 --- a/pm3py/sim/ntag5_platform.py +++ b/pm3py/sim/ntag5_platform.py @@ -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 diff --git a/pm3py/sim/ntag5_switch.py b/pm3py/sim/ntag5_switch.py index 771caab..cf8f3af 100644 --- a/pm3py/sim/ntag5_switch.py +++ b/pm3py/sim/ntag5_switch.py @@ -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 diff --git a/pm3py/sim/nxp_icode.py b/pm3py/sim/nxp_icode.py index cb45901..1c04a18 100644 --- a/pm3py/sim/nxp_icode.py +++ b/pm3py/sim/nxp_icode.py @@ -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 diff --git a/pm3py/sim/t5577.py b/pm3py/sim/t5577.py index 2c7985b..e183134 100644 --- a/pm3py/sim/t5577.py +++ b/pm3py/sim/t5577.py @@ -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 diff --git a/pm3py/sim/type5.py b/pm3py/sim/type5.py index 4daee9f..df143b0 100644 --- a/pm3py/sim/type5.py +++ b/pm3py/sim/type5.py @@ -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 diff --git a/pm3py/transponders/hf/iso14443a/__init__.py b/pm3py/transponders/hf/iso14443a/__init__.py new file mode 100644 index 0000000..583494e --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/__init__.py @@ -0,0 +1 @@ +"""ISO 14443-A transponders (Layer 3 + Layer 4).""" diff --git a/pm3py/transponders/hf/iso14443a/base.py b/pm3py/transponders/hf/iso14443a/base.py new file mode 100644 index 0000000..1e95286 --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/base.py @@ -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:] diff --git a/pm3py/transponders/hf/iso14443a/ndef.py b/pm3py/transponders/hf/iso14443a/ndef.py new file mode 100644 index 0000000..55a27ef --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/ndef.py @@ -0,0 +1,282 @@ +"""NFC Forum Type 2 and Type 4 tag models with NDEF support.""" +from __future__ import annotations + +import struct + +from 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 diff --git a/pm3py/transponders/hf/iso14443a/nxp/__init__.py b/pm3py/transponders/hf/iso14443a/nxp/__init__.py new file mode 100644 index 0000000..c6c8211 --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/nxp/__init__.py @@ -0,0 +1 @@ +"""NXP ISO 14443-A transponders (MIFARE Classic, DESFire).""" diff --git a/pm3py/transponders/hf/iso14443a/nxp/crypto1.py b/pm3py/transponders/hf/iso14443a/nxp/crypto1.py new file mode 100644 index 0000000..0430f87 --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/nxp/crypto1.py @@ -0,0 +1,103 @@ +"""Pure-Python Crypto-1 stream cipher for MIFARE Classic. + +48-bit LFSR with non-linear filter function. +Feedback polynomial: x^48 + x^43 + x^39 + x^38 + x^36 + x^34 + x^33 + x^31 + +x^29 + x^24 + x^23 + x^21 + x^19 + x^13 + x^9 + x^7 + x^6 + x^5 + 1 +""" +from __future__ import annotations + +import struct +import os + +# Feedback taps (bit positions, 0-indexed from LSB) +FEEDBACK_TAPS = (0, 5, 6, 7, 9, 13, 19, 21, 23, 24, 29, 31, 33, 34, 36, 38, 39, 43) + +# Filter function lookup table (20-bit input → 1-bit output) +# The filter function f(x0..x3, x4..x7, ..., x16..x19) is built from +# layers of 4-input boolean functions. +_FA = 0x9E98 # f_a lookup +_FB = 0xB48E # f_b lookup +_FC = 0xEC57E80A # f_c lookup + + +def _filter_bit(lfsr: int) -> int: + """Compute the non-linear filter function output.""" + # Extract 20 bits from specific LFSR positions + # Positions: 9,11,13,15,17,19,21,23,25,27,29,31,33,35,37,39,41,43,45,47 + x = 0 + for i, pos in enumerate((9, 11, 13, 15, 17, 19, 21, 23, 25, 27, + 29, 31, 33, 35, 37, 39, 41, 43, 45, 47)): + x |= ((lfsr >> pos) & 1) << i + + # Layer 1: four 4-input functions using _FA + a = (_FA >> (x & 0xF)) & 1 + b = (_FA >> ((x >> 4) & 0xF)) & 1 + c = (_FB >> ((x >> 8) & 0xF)) & 1 + d = (_FB >> ((x >> 12) & 0xF)) & 1 + e = (_FA >> ((x >> 16) & 0xF)) & 1 + + # Layer 2: combine with _FC + idx = a | (b << 1) | (c << 2) | (d << 3) | (e << 4) + return (_FC >> idx) & 1 + + +def _feedback_bit(lfsr: int) -> int: + """Compute LFSR feedback from tapped positions.""" + fb = 0 + for tap in FEEDBACK_TAPS: + fb ^= (lfsr >> tap) & 1 + return fb + + +class Crypto1: + """MIFARE Classic Crypto-1 stream cipher.""" + + def __init__(self, key: bytes): + self._lfsr: int = 0 + self._load_key(key) + + def _load_key(self, key: bytes) -> None: + """Load 48-bit key into LFSR.""" + # Key is loaded LSB first into the LFSR + key_int = int.from_bytes(key, "little") + self._lfsr = key_int & ((1 << 48) - 1) + + def _clock(self, input_bit: int = 0, feedback: bool = True) -> int: + """Clock the LFSR once. Returns filter output before clocking.""" + out = _filter_bit(self._lfsr) + fb = _feedback_bit(self._lfsr) if feedback else 0 + new_bit = fb ^ input_bit + self._lfsr = ((self._lfsr << 1) | new_bit) & ((1 << 48) - 1) + return out + + def generate_bit(self) -> int: + """Generate one keystream bit.""" + return self._clock(0, True) + + def generate_byte(self) -> int: + """Generate one keystream byte (MSB first).""" + val = 0 + for i in range(8): + val = (val << 1) | self.generate_bit() + return val + + def encrypt_bytes(self, data: bytes) -> bytes: + """XOR data with keystream (encryption = decryption for stream cipher).""" + result = bytearray(len(data)) + for i, b in enumerate(data): + result[i] = b ^ self.generate_byte() + return bytes(result) + + def generate_nonce(self) -> bytes: + """Generate a 4-byte tag nonce from LFSR state.""" + # Use PRNG based on LFSR state + nt = struct.pack(">I", self._lfsr & 0xFFFFFFFF) + return nt + + def init_auth(self, uid: int, nt: int) -> None: + """Initialize cipher for authentication with uid XOR nt fed back.""" + # Feed uid ^ nt into LFSR bit by bit + xor_val = uid ^ nt + for i in range(32): + bit = (xor_val >> (31 - i)) & 1 + self._clock(bit, True) diff --git a/pm3py/transponders/hf/iso14443a/nxp/desfire.py b/pm3py/transponders/hf/iso14443a/nxp/desfire.py new file mode 100644 index 0000000..f0cbae7 --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/nxp/desfire.py @@ -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} diff --git a/pm3py/transponders/hf/iso14443a/nxp/mifare_classic.py b/pm3py/transponders/hf/iso14443a/nxp/mifare_classic.py new file mode 100644 index 0000000..6217fdd --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/nxp/mifare_classic.py @@ -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} diff --git a/pm3py/transponders/hf/iso14443a3/__init__.py b/pm3py/transponders/hf/iso14443a3/__init__.py deleted file mode 100644 index fa4c38d..0000000 --- a/pm3py/transponders/hf/iso14443a3/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""ISO 14443-A Layer 3 transponders (MIFARE Classic, Ultralight, NTAG).""" diff --git a/pm3py/transponders/hf/iso14443a3/nxp/__init__.py b/pm3py/transponders/hf/iso14443a3/nxp/__init__.py deleted file mode 100644 index afd4290..0000000 --- a/pm3py/transponders/hf/iso14443a3/nxp/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""NXP ISO 14443-A Layer 3 transponders.""" diff --git a/pm3py/transponders/hf/iso14443a4/__init__.py b/pm3py/transponders/hf/iso14443a4/__init__.py deleted file mode 100644 index 06e8bf8..0000000 --- a/pm3py/transponders/hf/iso14443a4/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""ISO 14443-A Layer 4 (ISO-DEP) transponders (DESFire, NTAG 5 via -4).""" diff --git a/pm3py/transponders/hf/iso14443a4/nxp/__init__.py b/pm3py/transponders/hf/iso14443a4/nxp/__init__.py deleted file mode 100644 index ce960da..0000000 --- a/pm3py/transponders/hf/iso14443a4/nxp/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""NXP ISO 14443-A Layer 4 transponders.""" diff --git a/pm3py/transponders/hf/iso15693/base.py b/pm3py/transponders/hf/iso15693/base.py new file mode 100644 index 0000000..9d3b5c4 --- /dev/null +++ b/pm3py/transponders/hf/iso15693/base.py @@ -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(" None: + self._state = State15693.READY + + async def power_off(self) -> None: + self._state = State15693.POWER_OFF + + @property + def state(self) -> str: + return self._state.name + + async def handle_frame(self, frame: RFFrame) -> RFFrame | None: + if len(frame.data) < 2: + return None + + flags = frame.data[0] + cmd = frame.data[1] + + # Check if this is an addressed command and verify UID + is_inventory = bool(flags & FLAG_INVENTORY) + if not is_inventory and (flags & FLAG_ADDRESS): + if len(frame.data) < 10: + return None + frame_uid = bytes(reversed(frame.data[2:10])) + if frame_uid != self._uid: + return None + + match cmd: + case 0x01: # Inventory + if self._state == State15693.QUIET: + return None + return self._handle_inventory(frame) + case 0x02: # Stay Quiet + self._state = State15693.QUIET + return None # no response per spec + case 0x20: # Read Single Block + return self._handle_read_single(frame) + case 0x21: # Write Single Block + return self._handle_write_single(frame) + case 0x26: # Reset to Ready + self._state = State15693.READY + return self._make_response(b"") + case 0x2B: # Get System Information + return self._handle_system_info(frame) + case 0x30: # Extended Read Single Block + return self._handle_extended_read_single(frame) + case 0x31: # Extended Write Single Block + return self._handle_extended_write_single(frame) + case 0x33: # Extended Read Multiple Blocks + return self._handle_extended_read_multiple(frame) + case _ if cmd >= 0xA0: + return self._handle_custom_command(cmd, frame) + case _: + return self._make_error(0x01) # not supported + + def _handle_inventory(self, frame: RFFrame) -> RFFrame | None: + """Handle inventory command. Returns flags + DSFID + UID. + + Supports mask filtering: if mask_len > 0, only respond if our UID + matches the mask bits (compared LSB-first from UID byte 0). + """ + if len(frame.data) < 3: + return None + + mask_len = frame.data[2] # number of mask bits + if mask_len > 0: + # Mask bytes follow mask_len + mask_bytes_count = (mask_len + 7) // 8 + if len(frame.data) < 3 + mask_bytes_count: + return None + mask_data = frame.data[3:3 + mask_bytes_count] + uid_lsb = self._uid[::-1] # UID in LSB-first order + + # Compare mask bytes against UID (byte-level comparison) + # Full bytes must match exactly, partial last byte checked bit-by-bit + full_bytes = mask_len // 8 + for i in range(full_bytes): + if i < len(mask_data) and i < len(uid_lsb): + if mask_data[i] != uid_lsb[i]: + return None + # Check remaining bits in partial byte (MSB-first within byte + # to match bitarray ordering used by collision resolution) + remaining_bits = mask_len % 8 + if remaining_bits > 0 and full_bytes < len(mask_data) and full_bytes < len(uid_lsb): + # Mask the relevant bits (MSB-first: top N bits of byte) + shift = 8 - remaining_bits + mask_val = mask_data[full_bytes] >> shift + uid_val = uid_lsb[full_bytes] >> shift + if mask_val != uid_val: + return None + + resp = bytes([0x00, self._dsfid]) + self._uid[::-1] + return RFFrame.from_bytes(resp) + + def _handle_read_single(self, frame: RFFrame) -> RFFrame | None: + """Handle Read Single Block.""" + block = self._extract_block_number(frame) + if block is None: + return None + if block >= self._num_blocks: + return self._make_error(0x10) # block not available + + offset = block * self._block_size + data = self._memory[offset:offset + self._block_size] + return self._make_response(bytes(data)) + + def _handle_write_single(self, frame: RFFrame) -> RFFrame | None: + """Handle Write Single Block.""" + flags = frame.data[0] + is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS) + + if is_addressed: + block_offset = 10 # flags + cmd + UID(8) + else: + block_offset = 2 # flags + cmd + + if len(frame.data) <= block_offset: + return None + block = frame.data[block_offset] + if block >= self._num_blocks: + return self._make_error(0x10) + + data_offset = block_offset + 1 + data = frame.data[data_offset:data_offset + self._block_size] + if len(data) < self._block_size: + return self._make_error(0x0F) # unknown error + + offset = block * self._block_size + + # Check for special write modes (e.g. counter) + access = self.regions["user"].access_for_block(block) + if access.write_mode == "counter": + import struct as _st + increment = _st.unpack_from(" RFFrame | None: + """Handle Get System Information.""" + info_flags = SYSINFO_DSFID | SYSINFO_AFI | SYSINFO_MEMSIZE | SYSINFO_ICREF + resp = bytes([0x00, info_flags]) + resp += self._uid[::-1] # UID LSB first + resp += bytes([self._dsfid]) # DSFID + resp += bytes([self._afi]) # AFI + resp += bytes([self._num_blocks - 1, self._block_size - 1]) # mem size + resp += bytes([self._ic_reference]) # IC reference + return RFFrame.from_bytes(resp) + + def _extract_block_number(self, frame: RFFrame) -> int | None: + """Extract block number from frame, accounting for addressed mode.""" + flags = frame.data[0] + is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS) + if is_addressed: + if len(frame.data) < 11: + return None + return frame.data[10] + else: + if len(frame.data) < 3: + return None + return frame.data[2] + + def _extract_block_number_ext(self, frame: RFFrame) -> int | None: + """Extract 2-byte (LE16) block number for extended commands.""" + flags = frame.data[0] + is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS) + offset = 10 if is_addressed else 2 + if len(frame.data) < offset + 2: + return None + return struct.unpack_from(" RFFrame | None: + """Handle Extended Read Single Block (0x30) — 2-byte block address.""" + block = self._extract_block_number_ext(frame) + if block is None: + return None + if block >= self._num_blocks: + return self._make_error(0x10) # block not available + + offset = block * self._block_size + data = self._memory[offset:offset + self._block_size] + return self._make_response(bytes(data)) + + def _handle_extended_write_single(self, frame: RFFrame) -> RFFrame | None: + """Handle Extended Write Single Block (0x31) — 2-byte block address.""" + flags = frame.data[0] + is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS) + block_offset = 10 if is_addressed else 2 + + if len(frame.data) < block_offset + 2: + return None + block = struct.unpack_from("= self._num_blocks: + return self._make_error(0x10) + + data_offset = block_offset + 2 + data = frame.data[data_offset:data_offset + self._block_size] + if len(data) < self._block_size: + return self._make_error(0x0F) + + mem_offset = block * self._block_size + self._memory[mem_offset:mem_offset + self._block_size] = data + return self._make_response(b"") + + def _handle_extended_read_multiple(self, frame: RFFrame) -> RFFrame | None: + """Handle Extended Read Multiple Blocks (0x33) — 2-byte block address + count.""" + flags = frame.data[0] + is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS) + offset = 10 if is_addressed else 2 + + if len(frame.data) < offset + 4: + return None + block = struct.unpack_from(" self._num_blocks: + return self._make_error(0x10) + + result = bytearray() + for i in range(num_blocks): + b = block + i + mem_off = b * self._block_size + result.extend(self._memory[mem_off:mem_off + self._block_size]) + return self._make_response(bytes(result)) + + def _make_response(self, data: bytes) -> RFFrame: + """Build a success response frame.""" + return RFFrame.from_bytes(bytes([0x00]) + data) + + def _make_error(self, error_code: int) -> RFFrame: + """Build an error response frame.""" + return RFFrame.from_bytes(bytes([RESP_ERROR, error_code])) + + def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None: + """Extension point for vendor-specific commands (0xA0-0xDF). + Override in subclasses (e.g., NxpIcodeTag). + """ + return self._make_error(0x01) # not supported + + +# ---- Reader15693 ---- + +class Reader15693(Reader): + """ISO 15693 reader with inventory and block operations.""" + + async def run(self) -> dict: + tags = await self.inventory() + return {"tags": tags} + + async def inventory(self, slots: int = 1) -> list[dict]: + """Perform inventory. Returns list of {uid, dsfid}. + + Uses 1-slot inventory with mask-based collision resolution: + when collision is detected, narrows the mask to isolate tags. + """ + tags: list[dict] = [] + await self._inventory_recursive(tags, mask=b"", mask_bits=0) + return tags + + async def _inventory_recursive(self, tags: list[dict], + mask: bytes, mask_bits: int, + depth: int = 0) -> None: + """Recursive inventory with mask narrowing on collision.""" + if depth > 64: # max 64 UID bits + return + + inv_flags = 0x26 # high data rate + inventory + 1 slot + mask_bytes_count = (mask_bits + 7) // 8 + inv = bytes([inv_flags, CMD_INVENTORY, mask_bits]) + mask[:mask_bytes_count] + await self._medium.transmit_reader(RFFrame.from_bytes(inv)) + resp = await self._medium.receive_reader() + + if resp is None: + return + + if resp.has_collision and resp.collision_positions: + # Collision: find first collision bit in UID portion + # UID starts at byte 2 of response (bit 16 in MSB bitarray) + uid_bit_offset = 16 # flags(8) + dsfid(8) + col_in_uid = None + for pos in resp.collision_positions: + if pos >= uid_bit_offset: + col_in_uid = pos - uid_bit_offset + break + + if col_in_uid is not None: + # Try both values at the collision bit + # Mask includes bits 0..col_in_uid (inclusive) + new_mask_bits = col_in_uid + 1 + mask_byte_count = (new_mask_bits + 7) // 8 + from bitarray import bitarray as ba + resp_bits = ba(resp.bits[:resp.bit_count]) + + for bit_val in (0, 1): + bits_copy = ba(resp_bits) + bits_copy[uid_bit_offset + col_in_uid] = bit_val + uid_bytes = bits_copy[uid_bit_offset:uid_bit_offset + mask_byte_count * 8].tobytes() + await self._inventory_recursive(tags, uid_bytes, new_mask_bits, depth + 1) + return + + if len(resp.data) >= 10 and resp.data[0] == 0x00: + uid = bytes(reversed(resp.data[2:10])) + dsfid = resp.data[1] + # Avoid duplicates + if not any(t["uid"] == uid for t in tags): + tags.append({"uid": uid, "dsfid": dsfid}) + + async def read_block(self, uid: bytes, block: int) -> dict: + """Read a single block (addressed mode).""" + cmd = bytes([0x22, CMD_READ_SINGLE]) + uid[::-1] + bytes([block]) + await self._medium.transmit_reader(RFFrame.from_bytes(cmd)) + resp = await self._medium.receive_reader() + if resp is None or resp.data[0] & RESP_ERROR: + return {"success": False, "data": None} + return {"success": True, "data": resp.data[1:]} + + async def write_block(self, uid: bytes, block: int, data: bytes) -> dict: + """Write a single block (addressed mode).""" + cmd = bytes([0x22, CMD_WRITE_SINGLE]) + uid[::-1] + bytes([block]) + data + await self._medium.transmit_reader(RFFrame.from_bytes(cmd)) + resp = await self._medium.receive_reader() + if resp is None or resp.data[0] & RESP_ERROR: + return {"success": False} + return {"success": True} + + async def system_info(self, uid: bytes) -> dict: + """Get System Information (addressed mode).""" + cmd = bytes([0x22, CMD_SYSTEM_INFO]) + uid[::-1] + await self._medium.transmit_reader(RFFrame.from_bytes(cmd)) + resp = await self._medium.receive_reader() + if resp is None or resp.data[0] & RESP_ERROR: + return {"uid": None} + + data = resp.data + info_flags = data[1] + resp_uid = bytes(reversed(data[2:10])) + idx = 10 + dsfid = data[idx] if info_flags & SYSINFO_DSFID else 0 + idx += 1 if info_flags & SYSINFO_DSFID else 0 + afi = data[idx] if info_flags & SYSINFO_AFI else 0 + idx += 1 if info_flags & SYSINFO_AFI else 0 + num_blocks = (data[idx] + 1) if info_flags & SYSINFO_MEMSIZE else 0 + block_size = (data[idx + 1] + 1) if info_flags & SYSINFO_MEMSIZE else 0 + idx += 2 if info_flags & SYSINFO_MEMSIZE else 0 + ic_ref = data[idx] if info_flags & SYSINFO_ICREF else 0 + + return { + "uid": resp_uid, + "dsfid": dsfid, + "afi": afi, + "num_blocks": num_blocks, + "block_size": block_size, + "ic_reference": ic_ref, + } diff --git a/pm3py/transponders/hf/iso15693/nxp/auth_aes.py b/pm3py/transponders/hf/iso15693/nxp/auth_aes.py new file mode 100644 index 0000000..120122e --- /dev/null +++ b/pm3py/transponders/hf/iso15693/nxp/auth_aes.py @@ -0,0 +1,348 @@ +"""NXP AES-128 key management mixin. + +Reusable mixin for ICODE DNA and NTAG 5 Link/Boost AES auth: +- 4 key slots with key headers and privilege bitmasks +- Global Crypto Header (GCH) for access enforcement +- Authentication limit with failure counting +- TAM1 (Tag Authentication Method 1) — one-way tag authentication +- MAM1/MAM2 (Mutual Authentication Method) — two-phase mutual auth +- Helper methods for key state and privilege checks +""" +from __future__ import annotations + +import os + +try: + from Crypto.Cipher import AES as _AES + _HAS_AES = True +except ImportError: + _HAS_AES = False + + +# Key header values +KEY_HEADER_NOT_ACTIVE = 0x81 +KEY_HEADER_ACTIVE_LOCKED = 0xE7 +KEY_HEADER_DISABLED = 0xFF + +# Key privilege bits (Table 5) +PRIV_READ = 0x01 +PRIV_WRITE = 0x02 +PRIV_PRIVACY = 0x04 +PRIV_DESTROY = 0x08 +PRIV_EAS_AFI = 0x10 +PRIV_CRYPTO_CONFIG = 0x20 +PRIV_AREA1_READ = 0x40 +PRIV_AREA1_WRITE = 0x80 + +# NFC Global Crypto Header values +GCH_DEACTIVATED = 0x81 +GCH_DEACTIVATED_PRIV_LOCKED = 0x87 +GCH_ACTIVATED = 0xC1 +GCH_ACTIVATED_PRIV_LOCKED = 0xC7 +GCH_FINAL = 0xE7 + + +class NxpAesAuth: + """Mixin providing AES-128 key management for NXP ICODE/NTAG 5 tags. + + Call ``_init_aes_auth(aes_keys)`` from the concrete class's + ``__init__`` to set up AES key state. + """ + + def _init_aes_auth(self, aes_keys: list[bytes | None] | None = None) -> None: + """Initialize AES key management state. + + Args: + aes_keys: optional list of up to 4 AES-128 keys (or None per slot). + """ + self._aes_keys: list[bytes | None] = list(aes_keys) if aes_keys else [None] * 4 + self._aes_authenticated: set[int] = set() + + # Key headers: controls whether key is active for authentication + self._key_headers: list[int] = [KEY_HEADER_NOT_ACTIVE] * 4 + + # Key privileges: bitmask per key defining what auth grants + self._key_privileges: list[int] = [0x00] * 4 + + # Global Crypto Header — controls enforcement of access conditions + self._nfc_gch: int = GCH_DEACTIVATED + + # Authentication limit (0 = unlimited) + self._auth_limit: int = 0 + self._auth_fail_count: int = 0 + + def is_key_active(self, key_id: int) -> bool: + """Check if AES key slot is active (header == 0xE7).""" + return self._key_headers[key_id] == KEY_HEADER_ACTIVE_LOCKED + + def has_privilege(self, key_id: int, privilege: int) -> bool: + """Check if key_id is authenticated, active, and has the given privilege bit.""" + if key_id not in self._aes_authenticated: + return False + if not self.is_key_active(key_id): + return False + return bool(self._key_privileges[key_id] & privilege) + + def is_access_enforced(self) -> bool: + """Check if GCH is activated (>= 0xC1), meaning access conditions are enforced.""" + return self._nfc_gch >= GCH_ACTIVATED + + # ----- TAM1 (Tag Authentication Method 1) ----- + + # Crypto constants — first 2 bytes of plaintext block + _C_TAM1 = b"\x96\xC5" + _C_MAM1 = b"\xDA\x83" + _C_MAM2_PURPOSE = b"\xDA\x80" + + def _handle_challenge(self, frame) -> None: + """Handle CHALLENGE (0x39) — compute and store TResponse. + + Format: flags(1) cmd(1) CSI(1) AuthMethod(1) KeyID(1) IChallenge(10) + Tag computes: AES-ECB-ENC(key, C_TAM1 || TRnd || IChallenge) + Stores TResponse reversed (wire byte order). + Returns None — CHALLENGE has no RF response. + """ + self._tam1_response = None + + if not _HAS_AES: + return + + data = frame.data + if len(data) < 15: # flags + cmd + CSI + AuthMethod + KeyID + 10 IChallenge + return + + auth_method = data[3] + if auth_method != 0x00: # TAM1 only + return + + key_id = data[4] + if key_id > 3: + return + + if not self.is_key_active(key_id): + return + + key = self._aes_keys[key_id] + if key is None: + return + + ichallenge = data[5:15] + if len(ichallenge) != 10: + return + + # Build plaintext: C_TAM1(2) + TRnd(4) + IChallenge(10) = 16 bytes + trnd = os.urandom(4) + plaintext = self._C_TAM1 + trnd + ichallenge + + cipher = _AES.new(key, _AES.MODE_ECB) + tresponse = cipher.encrypt(plaintext) + + # Store reversed for wire format + self._tam1_response = tresponse[::-1] + + def _handle_readbuffer(self, frame): + """Handle READBUFFER (0x3A) — return stored TResponse. + + Format: flags(1) cmd(1) + Returns: flags(00) + TResponse_reversed(16 bytes) + Returns None if no TResponse is stored. + """ + from 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])) diff --git a/pm3py/transponders/hf/iso15693/nxp/auth_password.py b/pm3py/transponders/hf/iso15693/nxp/auth_password.py new file mode 100644 index 0000000..f1a3989 --- /dev/null +++ b/pm3py/transponders/hf/iso15693/nxp/auth_password.py @@ -0,0 +1,116 @@ +"""NXP XOR password authentication mixin. + +Reusable mixin for ICODE SLIX/SLIX2/ICODE 3 password auth: +- Password storage, XOR verification, GET_RANDOM override +- SET_PASSWORD, WRITE_PASSWORD, LOCK_PASSWORD handlers +""" +from __future__ import annotations + +import os +import struct + +from 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(" int: + """Decode XOR'd password bytes to integer value.""" + r = self._last_random + pwd_bytes = bytes([ + xored_bytes[0] ^ r[0], xored_bytes[1] ^ r[1], + xored_bytes[2] ^ r[0], xored_bytes[3] ^ r[1], + ]) + return struct.unpack(" RFFrame | None: + """SET_PASSWORD (0xB3): authenticate with XOR'd password.""" + if len(frame.data) < 8: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + + pwd_id = frame.data[3] + pwd_xored = frame.data[4:8] + + if not self._verify_xor_password(pwd_id, pwd_xored): + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + + self._authenticated_passwords.add(pwd_id) + return RFFrame.from_bytes(bytes([0x00])) + + # ----- WRITE PASSWORD ----- + + def _handle_write_password(self, frame: RFFrame) -> RFFrame | None: + """WRITE PASSWORD (0xB4): change password value. + + Requires prior SET_PASSWORD for the same pwd_id. + """ + if len(frame.data) < 8: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + + pwd_id = frame.data[3] + + if pwd_id not in self._authenticated_passwords: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + if pwd_id in self._locked_passwords: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + if self._passwords.get(pwd_id) is None: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + + new_value = self._decode_xor_password(frame.data[4:8]) + self._passwords[pwd_id] = new_value + self._authenticated_passwords.discard(pwd_id) + return RFFrame.from_bytes(bytes([0x00])) + + # ----- LOCK PASSWORD ----- + + def _handle_lock_password(self, frame: RFFrame) -> RFFrame | None: + """LOCK PASSWORD (0xB5): permanently lock a password.""" + if len(frame.data) < 4: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + + pwd_id = frame.data[3] + if pwd_id not in self._authenticated_passwords: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + + self._locked_passwords.add(pwd_id) + return RFFrame.from_bytes(bytes([0x00])) diff --git a/pm3py/transponders/hf/iso15693/nxp/icode3.py b/pm3py/transponders/hf/iso15693/nxp/icode3.py new file mode 100644 index 0000000..d1a8ade --- /dev/null +++ b/pm3py/transponders/hf/iso15693/nxp/icode3.py @@ -0,0 +1,414 @@ +"""ICODE 3 (SL2S3003) — NXP next-gen vicinity label IC. + +Extends IcodeSlix2Tag with: +- 6th password (configuration, pwd_id 0x20) +- 76 user blocks, block 75 = 24-bit counter +- Configuration memory (128 blocks) via READ CONFIG / WRITE CONFIG +- TagTamper status via READ TT (TT variant) +- NFC ASCII mirror (UID, counter, TagTamper into user memory as hex) +- Privacy mode 2 (responds to inventory with zeroed/random UID) +- PICK RANDOM ID (0xC2) for privacy mode 2 +- Richer feature flags in GET NXP SYSTEM INFO +""" +from __future__ import annotations + +import os +import struct + +from 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(" 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(" 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(" bool: + return self._eas_enabled + + def set_eas(self, enabled: bool) -> None: + self._eas_enabled = enabled + + # ----- Frame normalization ----- + + def _normalize_custom_frame(self, frame: RFFrame) -> RFFrame: + """Normalize addressed custom commands by stripping UID. + + Addressed: flags(1) + cmd(1) + uid(8) + mfg(1) + data... + Unaddressed: flags(1) + cmd(1) + mfg(1) + data... + Returns unaddressed-format frame for uniform processing. + """ + flags = frame.data[0] + if (flags & 0x20) and not (flags & 0x04): # addressed, not inventory + normalized = bytes([flags & ~0x20]) + frame.data[1:2] + frame.data[10:] + return RFFrame.from_bytes(normalized) + return frame + + # ----- Custom command dispatch ----- + + def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None: + """Handle SLIX custom commands, fall through to NXP base.""" + frame = self._normalize_custom_frame(frame) + if len(frame.data) >= 3 and frame.data[2] == NXP_MFG: + match cmd: + case 0xA0 | 0xA1: # INVENTORY READ / FAST INVENTORY READ + return self._handle_inventory_read(frame) + case 0xA2: # SET EAS + return self._handle_set_eas(frame) + case 0xA3: # RESET EAS + return self._handle_reset_eas(frame) + case 0xA4: # LOCK EAS + return self._handle_lock_eas(frame) + case 0xA5: # EAS ALARM + return self._handle_eas_alarm(frame) + case 0xA6: # PASSWORD PROTECT EAS/AFI + return self._handle_password_protect_eas_afi(frame) + case 0xB2: # GET RANDOM — override to store random + return self._handle_get_random(frame) + case 0xB3: # SET_PASSWORD + return self._handle_set_password(frame) + case 0xB4: # WRITE PASSWORD + return self._handle_write_password(frame) + case 0xB5: # LOCK PASSWORD + return self._handle_lock_password(frame) + return super()._handle_custom_command(cmd, frame) + + # ----- EAS auth check ----- + + def _check_eas_auth(self) -> bool: + """Check if EAS/AFI operations are allowed.""" + if not self._eas_password_protected: + return True + return PWD_EAS_AFI in self._authenticated_passwords + + # ----- EAS subsystem ----- + + def _handle_set_eas(self, frame: RFFrame) -> RFFrame | None: + if self._eas_locked: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + if not self._check_eas_auth(): + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + self._eas_enabled = True + return RFFrame.from_bytes(bytes([0x00])) + + def _handle_reset_eas(self, frame: RFFrame) -> RFFrame | None: + if self._eas_locked: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + if not self._check_eas_auth(): + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + self._eas_enabled = False + return RFFrame.from_bytes(bytes([0x00])) + + def _handle_lock_eas(self, frame: RFFrame) -> RFFrame | None: + if not self._check_eas_auth(): + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + self._eas_locked = True + return RFFrame.from_bytes(bytes([0x00])) + + def _handle_eas_alarm(self, frame: RFFrame) -> RFFrame | None: + if not self._eas_enabled: + return None + return RFFrame.from_bytes(bytes([0x00]) + self._eas_sequence) + + def _handle_password_protect_eas_afi(self, frame: RFFrame) -> RFFrame | None: + if PWD_EAS_AFI not in self._authenticated_passwords: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + self._eas_password_protected = True + return RFFrame.from_bytes(bytes([0x00])) + + # ----- INVENTORY READ ----- + + def _handle_inventory_read(self, frame: RFFrame) -> RFFrame | None: + """INVENTORY READ / FAST INVENTORY READ (0xA0/0xA1).""" + if len(frame.data) < 6: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + + first_block = frame.data[4] + num_blocks = frame.data[5] + 1 + + resp = bytearray([0x00]) + resp.append(self._dsfid) + resp.extend(self._uid) + + for blk in range(first_block, first_block + num_blocks): + if blk >= self._num_blocks: + break + offset = blk * self._block_size + resp.extend(self._memory[offset:offset + self._block_size]) + + return RFFrame.from_bytes(bytes(resp)) diff --git a/pm3py/transponders/hf/iso15693/nxp/icode_slix2.py b/pm3py/transponders/hf/iso15693/nxp/icode_slix2.py new file mode 100644 index 0000000..dc10541 --- /dev/null +++ b/pm3py/transponders/hf/iso15693/nxp/icode_slix2.py @@ -0,0 +1,257 @@ +"""ICODE SLIX2 — NXP IC with password protection and privacy mode. + +Extends IcodeSlixTag with: +- 4 additional passwords (read, write, privacy, destroy) +- Privacy mode + ENABLE PRIVACY command +- Protection pointer + PROTECT/LOCK PAGE PROTECTION +- DESTROY, 64-BIT PASSWORD PROTECTION, STAY QUIET PERSISTENT +- Block 79 counter +""" +from __future__ import annotations + +import struct + +from 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(" RFFrame | None: + """ENABLE PRIVACY (0xBA): enter privacy mode using XOR'd password.""" + if len(frame.data) < 7: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + + if not self._verify_xor_password(PWD_PRIVACY, frame.data[3:7]): + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + + self._privacy_mode = True + return RFFrame.from_bytes(bytes([0x00])) + + def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None: + """GET NXP SYSTEM INFO (0xAB): SLIX2-specific response.""" + pp = self._protection_pointer if self._protection_pointer is not None else 0 + pp_cond = 0x00 + if self._protection_pointer is not None: + pp_cond = PP_COND_READ_PROTECTION + lock_bits = 0x00 + feature_flags = struct.pack(" RFFrame | None: + """PROTECT PAGE (0xB6): set protection pointer and conditions.""" + if len(frame.data) < 5: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + if self._page_protection_locked: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + if PWD_WRITE not in self._authenticated_passwords: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + + self._protection_pointer = frame.data[3] + self._build_access_map() + return RFFrame.from_bytes(bytes([0x00])) + + def _handle_lock_page_protection(self, frame: RFFrame) -> RFFrame | None: + """LOCK PAGE PROTECTION (0xB7): permanently lock protection config.""" + if PWD_WRITE not in self._authenticated_passwords: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + self._page_protection_locked = True + return RFFrame.from_bytes(bytes([0x00])) + + def _handle_destroy(self, frame: RFFrame) -> RFFrame | None: + """DESTROY (0xB9): irreversibly kill tag.""" + if len(frame.data) < 7: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + + if not self._verify_xor_password(PWD_DESTROY, frame.data[3:7]): + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + + self._destroyed = True + return RFFrame.from_bytes(bytes([0x00])) + + def _handle_64bit_password_protection(self, frame: RFFrame) -> RFFrame | None: + """64-BIT PASSWORD PROTECTION (0xBB): requires both read + write auth.""" + if PWD_READ not in self._authenticated_passwords: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + if PWD_WRITE not in self._authenticated_passwords: + return RFFrame.from_bytes(bytes([0x01, 0x0F])) + self._extended_protection = True + return RFFrame.from_bytes(bytes([0x00])) + + def _handle_stay_quiet_persistent(self, frame: RFFrame) -> RFFrame | None: + """STAY QUIET PERSISTENT (0xBC): persistent quiet survives power cycles.""" + from ..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) diff --git a/pm3py/transponders/hf/iso15693/nxp/ntag5_boost.py b/pm3py/transponders/hf/iso15693/nxp/ntag5_boost.py new file mode 100644 index 0000000..abc130c --- /dev/null +++ b/pm3py/transponders/hf/iso15693/nxp/ntag5_boost.py @@ -0,0 +1,23 @@ +"""NTAG 5 Boost (NTA5332) — compact I2C bridge with ALM and AES. + +NTAG 5 Link + Active Load Modulation + AES always enabled. +Same memory layout and I2C interface as Link NTP5332. +512 user blocks (2048 bytes) + 256-byte SRAM + config memory. + +ALM (Active Load Modulation) is an RF modulation technique that +reduces antenna size without compromising read range. It has no +protocol-level impact — the sim model is identical to Link NTP5332. +""" +from __future__ import annotations + +from .ntag5_link import Ntag5LinkTag + + +class Ntag5BoostTag(Ntag5LinkTag): + """NTAG 5 Boost transponder (NTA5332). + + = NTAG 5 Link NTP5332 + ALM. AES always enabled. + """ + + def __init__(self, uid: bytes | str | None = None, **kwargs): + super().__init__(uid=uid, aes_capable=True, **kwargs) diff --git a/pm3py/transponders/hf/iso15693/nxp/ntag5_link.py b/pm3py/transponders/hf/iso15693/nxp/ntag5_link.py new file mode 100644 index 0000000..ab0601d --- /dev/null +++ b/pm3py/transponders/hf/iso15693/nxp/ntag5_link.py @@ -0,0 +1,261 @@ +"""NTAG 5 Link (NTP53x2) — NXP dual-interface NFC + I2C bridge. + +Extends Ntag5PlatformTag with: +- I2C slave interface (configurable address, default 0x54) +- 256-byte SRAM (shared between NFC and I2C) +- Arbiter for NFC/I2C memory access coordination +- SRAM modes: mirror, pass-through, PHDC +- Two variants: NTP5312 (password only), NTP5332 (+ AES + I2C master) + +TAM/MAM authentication uses the same protocol as ICODE DNA. +See docs/NTAG5_SECURITY.md for verified implementation details. +""" +from __future__ import annotations + +from collections.abc import Callable + +from 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])) diff --git a/pm3py/transponders/hf/iso15693/nxp/ntag5_platform.py b/pm3py/transponders/hf/iso15693/nxp/ntag5_platform.py new file mode 100644 index 0000000..8e2f6fe --- /dev/null +++ b/pm3py/transponders/hf/iso15693/nxp/ntag5_platform.py @@ -0,0 +1,446 @@ +"""NTAG 5 platform base — shared across Switch, Link, and Boost. + +All NTAG 5 variants share: +- Config memory with session registers (loaded from config on POR) +- Energy harvesting configuration +- Event detection pin (ED) +- PWM/GPIO configuration +- 16-bit counter on last user block +- READ/WRITE CONFIG commands +- Same NXP custom command set as SLIX2 + +The NTAG 5 platform extends SLIX2, not DNA. DNA adds AES crypto which +the Switch doesn't have. Link/Boost optionally add AES (and share the +same TAM/MAM protocol as DNA — see docs/NTAG5_SECURITY.md). +""" +from __future__ import annotations + +import struct +from typing import Callable + +from .auth_aes import NxpAesAuth +from 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 diff --git a/pm3py/transponders/hf/iso15693/nxp/ntag5_switch.py b/pm3py/transponders/hf/iso15693/nxp/ntag5_switch.py new file mode 100644 index 0000000..771caab --- /dev/null +++ b/pm3py/transponders/hf/iso15693/nxp/ntag5_switch.py @@ -0,0 +1,21 @@ +"""NTAG 5 Switch (NTP5210) — NFC PWM/GPIO bridge, no I2C. + +Simplest NTAG 5 variant: RF + PWM/GPIO outputs, no I2C, no SRAM, no AES. +128 user blocks (512 bytes), block 0x7F = 16-bit counter. +""" +from __future__ import annotations + +from .ntag5_platform import Ntag5PlatformTag + + +class Ntag5SwitchTag(Ntag5PlatformTag): + """NTAG 5 Switch (NTP5210). + + 128 blocks (512 bytes) + config memory. + PWM/GPIO outputs, energy harvesting, event detection. + No I2C, no SRAM, no AES authentication. + """ + + def __init__(self, uid: bytes | str | None = None, **kwargs): + kwargs.setdefault("num_blocks", 128) + super().__init__(uid=uid, aes_capable=False, **kwargs) diff --git a/pm3py/transponders/hf/iso15693/nxp/nxp_icode.py b/pm3py/transponders/hf/iso15693/nxp/nxp_icode.py new file mode 100644 index 0000000..91aa6c3 --- /dev/null +++ b/pm3py/transponders/hf/iso15693/nxp/nxp_icode.py @@ -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 diff --git a/pm3py/transponders/hf/iso15693/type5.py b/pm3py/transponders/hf/iso15693/type5.py new file mode 100644 index 0000000..f124bda --- /dev/null +++ b/pm3py/transponders/hf/iso15693/type5.py @@ -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) diff --git a/pm3py/transponders/implants.py b/pm3py/transponders/implants.py new file mode 100644 index 0000000..8ef3db7 --- /dev/null +++ b/pm3py/transponders/implants.py @@ -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 diff --git a/pm3py/transponders/lf/atmel/t5577.py b/pm3py/transponders/lf/atmel/t5577.py new file mode 100644 index 0000000..5458243 --- /dev/null +++ b/pm3py/transponders/lf/atmel/t5577.py @@ -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} diff --git a/pm3py/transponders/lf/base.py b/pm3py/transponders/lf/base.py new file mode 100644 index 0000000..a308cfe --- /dev/null +++ b/pm3py/transponders/lf/base.py @@ -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} diff --git a/pm3py/transponders/lf/em/em4100.py b/pm3py/transponders/lf/em/em4100.py new file mode 100644 index 0000000..4f6e380 --- /dev/null +++ b/pm3py/transponders/lf/em/em4100.py @@ -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} diff --git a/pm3py/transponders/lf/hid/__init__.py b/pm3py/transponders/lf/hid/__init__.py new file mode 100644 index 0000000..eb87ecb --- /dev/null +++ b/pm3py/transponders/lf/hid/__init__.py @@ -0,0 +1 @@ +"""HID Proximity transponders.""" diff --git a/pm3py/transponders/lf/hid/hid.py b/pm3py/transponders/lf/hid/hid.py new file mode 100644 index 0000000..6e0cbf2 --- /dev/null +++ b/pm3py/transponders/lf/hid/hid.py @@ -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)