refactor: reorganize transponder models into pm3py/transponders/
Moves 23 IC-specific files (tag + reader models) from sim/ into transponders/ organized by standard/manufacturer: - transponders/hf/iso14443a/ — 14443-A base, MIFARE Classic, DESFire, NDEF - transponders/hf/iso15693/ — 15693 base, Type5, NXP ICODE/NTAG5 hierarchy - transponders/lf/ — LF base, EM4100, HID, T5577 - transponders/implants.py — xEM, xNT, xM1, FlexDF, NExT sim/ retains infrastructure (Medium, RFFrame, SimSession, TableCompiler, etc.) and backward-compat shims for each moved file. sim/__init__.py now imports transponder classes from their canonical transponders/ locations. 751 tests passing, zero test changes needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,32 +1,11 @@
|
|||||||
"""pm3py.sim — Software-defined transponder/reader simulation framework."""
|
"""pm3py.sim — Software-defined transponder/reader simulation framework."""
|
||||||
|
|
||||||
|
# --- Sim infrastructure (stays here) ---
|
||||||
from .frame import RFFrame
|
from .frame import RFFrame
|
||||||
from .memory import DirtyByteArray, MemoryRegion, BlockAccess
|
from .memory import DirtyByteArray, MemoryRegion, BlockAccess
|
||||||
from .medium import Medium, SoftwareMedium
|
from .medium import Medium, SoftwareMedium
|
||||||
from .transponder import Transponder
|
from .transponder import Transponder
|
||||||
from .reader import Reader, ScriptedReader, InteractiveReader, ReaderStep, StepResult
|
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 .fuzzer import MutationFuzzer, GrammarFuzzer
|
||||||
from .relay import RelayTransponder, MitMProxy
|
from .relay import RelayTransponder, MitMProxy
|
||||||
from .replay import TraceRecorder, TraceReplayer, TraceEntry
|
from .replay import TraceRecorder, TraceReplayer, TraceEntry
|
||||||
@@ -36,6 +15,31 @@ from .sim_session import SimSession
|
|||||||
from .mcu_bridge import McuBridge
|
from .mcu_bridge import McuBridge
|
||||||
from .dual_session import DualInterfaceSession
|
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__ = [
|
__all__ = [
|
||||||
"RFFrame",
|
"RFFrame",
|
||||||
"DirtyByteArray", "MemoryRegion", "BlockAccess",
|
"DirtyByteArray", "MemoryRegion", "BlockAccess",
|
||||||
|
|||||||
@@ -1,348 +1,2 @@
|
|||||||
"""NXP AES-128 key management mixin.
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
|
from pm3py.transponders.hf.iso15693.nxp.auth_aes import * # noqa: F401,F403
|
||||||
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]))
|
|
||||||
|
|||||||
@@ -1,116 +1,2 @@
|
|||||||
"""NXP XOR password authentication mixin.
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
|
from pm3py.transponders.hf.iso15693.nxp.auth_password import * # noqa: F401,F403
|
||||||
Reusable mixin for ICODE SLIX/SLIX2/ICODE 3 password auth:
|
|
||||||
- Password storage, XOR verification, GET_RANDOM override
|
|
||||||
- SET_PASSWORD, WRITE_PASSWORD, LOCK_PASSWORD handlers
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from .frame import RFFrame
|
|
||||||
|
|
||||||
|
|
||||||
class NxpPasswordAuth:
|
|
||||||
"""Mixin providing XOR password authentication for NXP ICODE tags.
|
|
||||||
|
|
||||||
Call ``_init_password_auth(passwords)`` from the concrete class's
|
|
||||||
``__init__`` to set up password state.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _init_password_auth(self, passwords: dict[int, int | None]) -> None:
|
|
||||||
"""Initialize password authentication state.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
passwords: mapping of password ID → 32-bit value (or None).
|
|
||||||
"""
|
|
||||||
self._passwords: dict[int, int | None] = dict(passwords)
|
|
||||||
self._authenticated_passwords: set[int] = set()
|
|
||||||
self._locked_passwords: set[int] = set()
|
|
||||||
self._last_random: bytes = b"\x00\x00"
|
|
||||||
|
|
||||||
# ----- GET_RANDOM (override to store last random) -----
|
|
||||||
|
|
||||||
def _handle_get_random(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
random_bytes = os.urandom(2)
|
|
||||||
self._last_random = random_bytes
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]) + random_bytes)
|
|
||||||
|
|
||||||
# ----- Password XOR helpers -----
|
|
||||||
|
|
||||||
def _verify_xor_password(self, pwd_id: int, xored_bytes: bytes) -> bool:
|
|
||||||
"""Verify XOR'd password against stored value."""
|
|
||||||
expected = self._passwords.get(pwd_id)
|
|
||||||
if expected is None:
|
|
||||||
return False
|
|
||||||
r = self._last_random
|
|
||||||
pwd_bytes = bytes([
|
|
||||||
xored_bytes[0] ^ r[0], xored_bytes[1] ^ r[1],
|
|
||||||
xored_bytes[2] ^ r[0], xored_bytes[3] ^ r[1],
|
|
||||||
])
|
|
||||||
pwd_value = struct.unpack("<I", pwd_bytes)[0]
|
|
||||||
return pwd_value == expected
|
|
||||||
|
|
||||||
def _decode_xor_password(self, xored_bytes: bytes) -> int:
|
|
||||||
"""Decode XOR'd password bytes to integer value."""
|
|
||||||
r = self._last_random
|
|
||||||
pwd_bytes = bytes([
|
|
||||||
xored_bytes[0] ^ r[0], xored_bytes[1] ^ r[1],
|
|
||||||
xored_bytes[2] ^ r[0], xored_bytes[3] ^ r[1],
|
|
||||||
])
|
|
||||||
return struct.unpack("<I", pwd_bytes)[0]
|
|
||||||
|
|
||||||
# ----- SET_PASSWORD -----
|
|
||||||
|
|
||||||
def _handle_set_password(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""SET_PASSWORD (0xB3): authenticate with XOR'd password."""
|
|
||||||
if len(frame.data) < 8:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
pwd_id = frame.data[3]
|
|
||||||
pwd_xored = frame.data[4:8]
|
|
||||||
|
|
||||||
if not self._verify_xor_password(pwd_id, pwd_xored):
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
self._authenticated_passwords.add(pwd_id)
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
# ----- WRITE PASSWORD -----
|
|
||||||
|
|
||||||
def _handle_write_password(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""WRITE PASSWORD (0xB4): change password value.
|
|
||||||
|
|
||||||
Requires prior SET_PASSWORD for the same pwd_id.
|
|
||||||
"""
|
|
||||||
if len(frame.data) < 8:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
pwd_id = frame.data[3]
|
|
||||||
|
|
||||||
if pwd_id not in self._authenticated_passwords:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
if pwd_id in self._locked_passwords:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
if self._passwords.get(pwd_id) is None:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
new_value = self._decode_xor_password(frame.data[4:8])
|
|
||||||
self._passwords[pwd_id] = new_value
|
|
||||||
self._authenticated_passwords.discard(pwd_id)
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
# ----- LOCK PASSWORD -----
|
|
||||||
|
|
||||||
def _handle_lock_password(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""LOCK PASSWORD (0xB5): permanently lock a password."""
|
|
||||||
if len(frame.data) < 4:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
pwd_id = frame.data[3]
|
|
||||||
if pwd_id not in self._authenticated_passwords:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
self._locked_passwords.add(pwd_id)
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|||||||
@@ -1,103 +1,2 @@
|
|||||||
"""Pure-Python Crypto-1 stream cipher for MIFARE Classic.
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
|
from pm3py.transponders.hf.iso14443a.nxp.crypto1 import * # noqa: F401,F403
|
||||||
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)
|
|
||||||
|
|||||||
@@ -1,276 +1,2 @@
|
|||||||
"""DESFire EV1/EV2 transponder and reader models."""
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
from __future__ import annotations
|
from pm3py.transponders.hf.iso14443a.nxp.desfire import * # noqa: F401,F403
|
||||||
|
|
||||||
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}
|
|
||||||
|
|||||||
@@ -1,99 +1,2 @@
|
|||||||
"""EM4100/EM4102 — read-only 64-bit Manchester ASK transponder."""
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
from __future__ import annotations
|
from pm3py.transponders.lf.em.em4100 import * # noqa: F401,F403
|
||||||
|
|
||||||
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}
|
|
||||||
|
|||||||
123
pm3py/sim/hid.py
123
pm3py/sim/hid.py
@@ -1,121 +1,2 @@
|
|||||||
"""HID Proximity — FSK modulated Wiegand credential transponder."""
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
from __future__ import annotations
|
from pm3py.transponders.lf.hid.hid import * # noqa: F401,F403
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
@@ -1,414 +1,2 @@
|
|||||||
"""ICODE 3 (SL2S3003) — NXP next-gen vicinity label IC.
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
|
from pm3py.transponders.hf.iso15693.nxp.icode3 import * # noqa: F401,F403
|
||||||
Extends IcodeSlix2Tag with:
|
|
||||||
- 6th password (configuration, pwd_id 0x20)
|
|
||||||
- 76 user blocks, block 75 = 24-bit counter
|
|
||||||
- Configuration memory (128 blocks) via READ CONFIG / WRITE CONFIG
|
|
||||||
- TagTamper status via READ TT (TT variant)
|
|
||||||
- NFC ASCII mirror (UID, counter, TagTamper into user memory as hex)
|
|
||||||
- Privacy mode 2 (responds to inventory with zeroed/random UID)
|
|
||||||
- PICK RANDOM ID (0xC2) for privacy mode 2
|
|
||||||
- Richer feature flags in GET NXP SYSTEM INFO
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from .frame import RFFrame
|
|
||||||
from .icode_slix2 import IcodeSlix2Tag, PWD_READ, PWD_WRITE
|
|
||||||
from .nxp_icode import NXP_MFG
|
|
||||||
|
|
||||||
# ICODE 3 password ID
|
|
||||||
PWD_CONFIG = 0x20
|
|
||||||
|
|
||||||
# Config memory password block addresses (read masks with 0x00)
|
|
||||||
_CONFIG_PWD_BLOCKS = {41, 42, 43, 44, 45, 46}
|
|
||||||
|
|
||||||
# ICODE 3 feature flags (Table 113)
|
|
||||||
_FEATURE_UM_PP = 1 << 0
|
|
||||||
_FEATURE_COUNTER = 1 << 1
|
|
||||||
_FEATURE_EAS_ID = 1 << 2
|
|
||||||
_FEATURE_EAS_PP = 1 << 3
|
|
||||||
_FEATURE_AFI_PP = 1 << 4
|
|
||||||
_FEATURE_INVENTORY_READ_EXT = 1 << 5
|
|
||||||
_FEATURE_EAS_IR = 1 << 6
|
|
||||||
_FEATURE_CID = 1 << 7
|
|
||||||
_FEATURE_ORIGINALITY_SIG = 1 << 8
|
|
||||||
_FEATURE_TAGTAMPER = 1 << 9
|
|
||||||
_FEATURE_P_QUIET = 1 << 10
|
|
||||||
_FEATURE_NFC_MIRROR = 1 << 11
|
|
||||||
_FEATURE_PRIVACY = 1 << 12
|
|
||||||
_FEATURE_DESTROY = 1 << 13
|
|
||||||
_FEATURE_WRITE_CID = 1 << 14
|
|
||||||
_FEATURE_HIGH_DATA_RATES = 1 << 15
|
|
||||||
|
|
||||||
|
|
||||||
class Icode3Tag(IcodeSlix2Tag):
|
|
||||||
"""ICODE 3 transponder (SL2S3003 / SL2S3003TT).
|
|
||||||
|
|
||||||
Extends IcodeSlix2Tag with configuration memory, 6th password,
|
|
||||||
24-bit counter, and TagTamper support.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ICODE 3: TAG type 0x01, type indicator bits 39:36 = 0100 → byte bits 6:3 = 0100 → 0x20
|
|
||||||
_uid_prefix = b"\xE0\x04\x01\x20"
|
|
||||||
|
|
||||||
def __init__(self, uid: bytes | str | None = None,
|
|
||||||
config_password: int | None = None,
|
|
||||||
tag_tamper: bool = False,
|
|
||||||
signature: bytes | None = None,
|
|
||||||
**kwargs):
|
|
||||||
kwargs.setdefault("num_blocks", 76)
|
|
||||||
super().__init__(uid=uid, signature=signature, **kwargs)
|
|
||||||
|
|
||||||
# 48-byte signature mode (OS_CFG_MODE bit, SL2S3003 8.5.3.20)
|
|
||||||
self._signature_48 = False
|
|
||||||
|
|
||||||
# 6th password
|
|
||||||
self._passwords[PWD_CONFIG] = config_password
|
|
||||||
|
|
||||||
# Configuration memory: 128 blocks × 4 bytes
|
|
||||||
self._config_memory = bytearray(128 * 4)
|
|
||||||
self._config_password_protected = False
|
|
||||||
self._config_locked = False
|
|
||||||
|
|
||||||
# Initialize config memory with defaults
|
|
||||||
self._init_config_defaults()
|
|
||||||
|
|
||||||
# TagTamper (TT variant only)
|
|
||||||
self._tag_tamper_supported = tag_tamper
|
|
||||||
self._tt_status_actual = 0x43 # 'C' = Closed
|
|
||||||
self._tt_status_stored = 0x43
|
|
||||||
|
|
||||||
# NFC ASCII mirror
|
|
||||||
self._nfc_mirror_sel = 0 # 0=off, 1=UID, 2=UID+counter, 3=UID+counter+TT
|
|
||||||
self._nfc_mirror_block = 0
|
|
||||||
self._nfc_mirror_byte = 0
|
|
||||||
|
|
||||||
# Privacy mode 2 support
|
|
||||||
self._privacy_mode_sel = 1 # 1=mode 1 (strict), 2=mode 2 (permissive)
|
|
||||||
self._random_id: bytes | None = None # set by PICK RANDOM ID
|
|
||||||
|
|
||||||
# Rebuild access map for 24-bit counter on block 75
|
|
||||||
self._build_access_map()
|
|
||||||
|
|
||||||
def set_nfc_mirror(self, sel: int, block: int, byte_offset: int = 0) -> None:
|
|
||||||
"""Configure NFC ASCII mirror.
|
|
||||||
|
|
||||||
sel: 0=off, 1=UID, 2=UID+counter, 3=UID+counter+TT msg (TT only)
|
|
||||||
block: starting user memory block
|
|
||||||
byte_offset: starting byte within the block (0-3)
|
|
||||||
"""
|
|
||||||
self._nfc_mirror_sel = sel
|
|
||||||
self._nfc_mirror_block = block
|
|
||||||
self._nfc_mirror_byte = byte_offset
|
|
||||||
|
|
||||||
def _compute_mirror_data(self) -> bytes:
|
|
||||||
"""Compute the ASCII hex mirror data based on current settings."""
|
|
||||||
parts = []
|
|
||||||
|
|
||||||
if self._nfc_mirror_sel >= 1:
|
|
||||||
# UID: 8 bytes → 16 ASCII hex chars, MSB first
|
|
||||||
parts.append(self._uid.hex().upper().encode())
|
|
||||||
|
|
||||||
if self._nfc_mirror_sel >= 2:
|
|
||||||
# Separator + counter: 3 bytes → 6 ASCII hex chars, MSB first
|
|
||||||
parts.append(b"x")
|
|
||||||
offset = 75 * self._block_size
|
|
||||||
counter_le = self._memory[offset:offset + 3]
|
|
||||||
counter_val = int.from_bytes(counter_le, 'little')
|
|
||||||
parts.append(f"{counter_val:06X}".encode())
|
|
||||||
|
|
||||||
if self._nfc_mirror_sel >= 3 and self._tag_tamper_supported:
|
|
||||||
# Separator + TT open message (4 bytes from config block 47) as hex
|
|
||||||
parts.append(b"x")
|
|
||||||
tt_msg = self._config_memory[47 * 4:47 * 4 + 4]
|
|
||||||
parts.append(tt_msg.hex().upper().encode())
|
|
||||||
|
|
||||||
return b"".join(parts)
|
|
||||||
|
|
||||||
# ----- Read override for NFC mirror -----
|
|
||||||
|
|
||||||
def _handle_read_single(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Override to apply NFC mirror overlay on reads."""
|
|
||||||
if self._nfc_mirror_sel == 0:
|
|
||||||
return super()._handle_read_single(frame)
|
|
||||||
|
|
||||||
block = self._extract_block_number(frame)
|
|
||||||
if block is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check read protection (inherited)
|
|
||||||
if self._protection_pointer is not None and block >= self._protection_pointer:
|
|
||||||
if PWD_READ not in self._authenticated_passwords:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
# Get physical data
|
|
||||||
offset = block * self._block_size
|
|
||||||
phys = bytearray(self._memory[offset:offset + self._block_size])
|
|
||||||
|
|
||||||
# Apply mirror overlay
|
|
||||||
mirror_data = self._compute_mirror_data()
|
|
||||||
mirror_start_abs = self._nfc_mirror_block * self._block_size + self._nfc_mirror_byte
|
|
||||||
mirror_end_abs = mirror_start_abs + len(mirror_data)
|
|
||||||
|
|
||||||
block_start_abs = block * self._block_size
|
|
||||||
block_end_abs = block_start_abs + self._block_size
|
|
||||||
|
|
||||||
# Check overlap
|
|
||||||
overlap_start = max(mirror_start_abs, block_start_abs)
|
|
||||||
overlap_end = min(mirror_end_abs, block_end_abs)
|
|
||||||
|
|
||||||
if overlap_start < overlap_end:
|
|
||||||
mirror_offset = overlap_start - mirror_start_abs
|
|
||||||
block_offset = overlap_start - block_start_abs
|
|
||||||
length = overlap_end - overlap_start
|
|
||||||
phys[block_offset:block_offset + length] = mirror_data[mirror_offset:mirror_offset + length]
|
|
||||||
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]) + bytes(phys))
|
|
||||||
|
|
||||||
# ----- Privacy mode 2 override -----
|
|
||||||
|
|
||||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
if self._destroyed:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if len(frame.data) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
cmd = frame.data[1]
|
|
||||||
|
|
||||||
if self._privacy_mode:
|
|
||||||
if self._privacy_mode_sel == 2:
|
|
||||||
return await self._handle_privacy_mode2(frame, cmd)
|
|
||||||
else:
|
|
||||||
# Mode 1: strict — only GET_RANDOM + SET_PASSWORD
|
|
||||||
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
|
||||||
if cmd == 0xB2:
|
|
||||||
return self._handle_get_random(frame)
|
|
||||||
if cmd == 0xB3:
|
|
||||||
return self._handle_set_password(frame)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return await super(IcodeSlix2Tag, self).handle_frame(frame)
|
|
||||||
|
|
||||||
async def _handle_privacy_mode2(self, frame: RFFrame, cmd: int) -> RFFrame | None:
|
|
||||||
"""Privacy mode 2: allows inventory (zeroed/random UID), reads, system info, etc."""
|
|
||||||
# Swap UID temporarily for inventory/system info
|
|
||||||
real_uid = self._uid
|
|
||||||
display_uid = self._random_id if self._random_id else b"\xE0\x04\x00\x00\x00\x00\x00\x00"
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._uid = display_uid
|
|
||||||
|
|
||||||
# Custom commands that are allowed
|
|
||||||
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
|
||||||
if cmd == 0xB2: # GET_RANDOM
|
|
||||||
return self._handle_get_random(frame)
|
|
||||||
if cmd == 0xB3: # SET_PASSWORD (unlocks privacy)
|
|
||||||
result = self._handle_set_password(frame)
|
|
||||||
if result and result.data[0] == 0x00 and frame.data[3] == 0x04:
|
|
||||||
self._privacy_mode = False
|
|
||||||
self._random_id = None
|
|
||||||
return result
|
|
||||||
if cmd == 0xBC: # STAY QUIET PERSISTENT
|
|
||||||
frame_n = self._normalize_custom_frame(frame)
|
|
||||||
return self._handle_stay_quiet_persistent(frame_n)
|
|
||||||
if cmd == 0xC2: # PICK RANDOM ID
|
|
||||||
return self._handle_pick_random_id(frame)
|
|
||||||
|
|
||||||
# Standard ISO commands allowed in mode 2
|
|
||||||
allowed_std = {0x01, 0x02, 0x20, 0x23, 0x25, 0x26, 0x2B}
|
|
||||||
if cmd in allowed_std:
|
|
||||||
return await super(IcodeSlix2Tag, self).handle_frame(frame)
|
|
||||||
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
self._uid = real_uid
|
|
||||||
|
|
||||||
def _handle_set_password(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Override: in privacy mode 2, SET_PASSWORD with privacy pwd exits privacy."""
|
|
||||||
result = super()._handle_set_password(frame)
|
|
||||||
if (result is not None and result.data[0] == 0x00 and
|
|
||||||
self._privacy_mode and frame.data[3] == 0x04):
|
|
||||||
self._privacy_mode = False
|
|
||||||
self._random_id = None
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _init_config_defaults(self):
|
|
||||||
"""Initialize config memory with factory defaults."""
|
|
||||||
# Block 16: DSFID
|
|
||||||
self._config_memory[16 * 4] = self._dsfid
|
|
||||||
# Block 17: AFI
|
|
||||||
self._config_memory[17 * 4] = self._afi
|
|
||||||
|
|
||||||
# Store passwords in config memory (write-only, read-masked)
|
|
||||||
for pwd_id, cfg_block in [(PWD_CONFIG, 41), (PWD_READ, 42),
|
|
||||||
(PWD_WRITE, 43), (0x04, 44),
|
|
||||||
(0x08, 45), (0x10, 46)]:
|
|
||||||
pwd = self._passwords.get(pwd_id)
|
|
||||||
if pwd is not None:
|
|
||||||
offset = cfg_block * 4
|
|
||||||
struct.pack_into("<I", self._config_memory, offset, pwd)
|
|
||||||
|
|
||||||
def _build_access_map(self) -> None:
|
|
||||||
"""Override: block 75 = 24-bit counter (not block 79 = 16-bit)."""
|
|
||||||
has_pp = self._protection_pointer is not None
|
|
||||||
from .memory import BlockAccess
|
|
||||||
user = self.regions["user"]
|
|
||||||
access_map = []
|
|
||||||
for block in range(self._num_blocks):
|
|
||||||
ba = BlockAccess()
|
|
||||||
if has_pp and block >= self._protection_pointer:
|
|
||||||
ba = BlockAccess(read="password", read_key=PWD_READ)
|
|
||||||
if block == 75:
|
|
||||||
ba = BlockAccess(read=ba.read, read_key=ba.read_key,
|
|
||||||
write_mode="counter24")
|
|
||||||
access_map.append(ba)
|
|
||||||
user.access_map = access_map
|
|
||||||
|
|
||||||
# ----- 48-byte originality signature (SL2S3003 8.5.3.20) -----
|
|
||||||
|
|
||||||
def set_signature_mode_48(self, enabled: bool) -> None:
|
|
||||||
"""Enable/disable 48-byte originality signature mode (OS_CFG_MODE)."""
|
|
||||||
self._signature_48 = enabled
|
|
||||||
|
|
||||||
def _handle_read_signature(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""READ SIGNATURE: returns 32 or 48 bytes depending on OS_CFG_MODE."""
|
|
||||||
if self._signature_48:
|
|
||||||
# Pad/extend signature to 48 bytes
|
|
||||||
sig = (self._signature + bytes(48))[:48]
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]) + sig)
|
|
||||||
# Default 32-byte mode: truncate to 32 bytes
|
|
||||||
sig = (self._signature + bytes(32))[:32]
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]) + sig)
|
|
||||||
|
|
||||||
# ----- Command dispatch (ICODE 3-specific) -----
|
|
||||||
|
|
||||||
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
|
|
||||||
frame = self._normalize_custom_frame(frame)
|
|
||||||
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
|
||||||
match cmd:
|
|
||||||
case 0xC0: # READ CONFIG
|
|
||||||
return self._handle_read_config(frame)
|
|
||||||
case 0xC1: # WRITE CONFIG
|
|
||||||
return self._handle_write_config(frame)
|
|
||||||
case 0xC2: # PICK RANDOM ID
|
|
||||||
return self._handle_pick_random_id(frame)
|
|
||||||
case 0xC4: # READ TT
|
|
||||||
return self._handle_read_tt(frame)
|
|
||||||
case 0xAB: # GET NXP SYSTEM INFO — override with ICODE 3 data
|
|
||||||
return self._handle_get_nxp_system_info(frame)
|
|
||||||
return super()._handle_custom_command(cmd, frame)
|
|
||||||
|
|
||||||
# ----- PICK RANDOM ID (0xC2) -----
|
|
||||||
|
|
||||||
def _handle_pick_random_id(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""PICK RANDOM ID (0xC2): generate random UID for privacy mode 2.
|
|
||||||
|
|
||||||
Random ID format: E0 04 00 00 CID1 CID0 RID1 RID0
|
|
||||||
CID = customer ID from config, RID = random 16-bit.
|
|
||||||
"""
|
|
||||||
if not self._privacy_mode:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
# CID from config memory block 19 (CID0, CID1)
|
|
||||||
cid0 = self._config_memory[19 * 4]
|
|
||||||
cid1 = self._config_memory[19 * 4 + 1]
|
|
||||||
|
|
||||||
rid = os.urandom(2)
|
|
||||||
self._random_id = bytes([0xE0, 0x04, 0x00, 0x00, cid1, cid0, rid[0], rid[1]])
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
# ----- READ CONFIG (0xC0) -----
|
|
||||||
|
|
||||||
def _handle_read_config(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""READ CONFIG (0xC0): read config memory blocks.
|
|
||||||
|
|
||||||
Format: flags(1) + cmd(1) + mfg(1) + block_addr(1) + num_blocks(1)
|
|
||||||
Response: flags(1) + data(4 * (num_blocks+1))
|
|
||||||
Passwords (blocks 41-46) are masked with 0x00.
|
|
||||||
"""
|
|
||||||
if len(frame.data) < 5:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
block_addr = frame.data[3]
|
|
||||||
num_blocks = frame.data[4] + 1 # 0 = 1 block
|
|
||||||
|
|
||||||
if block_addr + num_blocks > 128:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
resp = bytearray([0x00]) # flags
|
|
||||||
for blk in range(block_addr, block_addr + num_blocks):
|
|
||||||
offset = blk * 4
|
|
||||||
if blk in _CONFIG_PWD_BLOCKS:
|
|
||||||
resp.extend(bytes(4)) # masked
|
|
||||||
else:
|
|
||||||
resp.extend(self._config_memory[offset:offset + 4])
|
|
||||||
|
|
||||||
return RFFrame.from_bytes(bytes(resp))
|
|
||||||
|
|
||||||
# ----- WRITE CONFIG (0xC1) -----
|
|
||||||
|
|
||||||
def _handle_write_config(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""WRITE CONFIG (0xC1): write 4 bytes to config memory block.
|
|
||||||
|
|
||||||
Format: flags(1) + cmd(1) + mfg(1) + block_addr(1) + data(4)
|
|
||||||
"""
|
|
||||||
if len(frame.data) < 8:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
if self._config_locked:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
if self._config_password_protected:
|
|
||||||
if PWD_CONFIG not in self._authenticated_passwords:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
block_addr = frame.data[3]
|
|
||||||
if block_addr >= 128:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
data = frame.data[4:8]
|
|
||||||
offset = block_addr * 4
|
|
||||||
self._config_memory[offset:offset + 4] = data
|
|
||||||
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
# ----- READ TT (0xC4) -----
|
|
||||||
|
|
||||||
def _handle_read_tt(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""READ TT (0xC4): TagTamper status.
|
|
||||||
|
|
||||||
Response: flags(1) + actual_status(1) + stored_status(1)
|
|
||||||
"""
|
|
||||||
if not self._tag_tamper_supported:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return RFFrame.from_bytes(bytes([
|
|
||||||
0x00, self._tt_status_actual, self._tt_status_stored
|
|
||||||
]))
|
|
||||||
|
|
||||||
# ----- GET NXP SYSTEM INFO override -----
|
|
||||||
|
|
||||||
def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""GET NXP SYSTEM INFO with ICODE 3 feature flags."""
|
|
||||||
pp = self._protection_pointer if self._protection_pointer is not None else 0
|
|
||||||
pp_cond = 0x00
|
|
||||||
if self._protection_pointer is not None:
|
|
||||||
pp_cond = 0x01 # read protection
|
|
||||||
|
|
||||||
lock_bits = 0x00
|
|
||||||
feature_flags = (
|
|
||||||
_FEATURE_UM_PP | _FEATURE_COUNTER | _FEATURE_EAS_ID |
|
|
||||||
_FEATURE_ORIGINALITY_SIG | _FEATURE_P_QUIET |
|
|
||||||
_FEATURE_PRIVACY | _FEATURE_DESTROY | _FEATURE_HIGH_DATA_RATES
|
|
||||||
)
|
|
||||||
if self._tag_tamper_supported:
|
|
||||||
feature_flags |= _FEATURE_TAGTAMPER
|
|
||||||
|
|
||||||
return RFFrame.from_bytes(
|
|
||||||
bytes([0x00, pp, pp_cond, lock_bits]) +
|
|
||||||
struct.pack("<I", feature_flags)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,276 +1,2 @@
|
|||||||
"""ICODE DNA (SL2S6002) — NXP IC with AES-128 mutual authentication.
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
|
from pm3py.transponders.hf.iso15693.nxp.icode_dna import * # noqa: F401,F403
|
||||||
Extends IcodeSlix2Tag with:
|
|
||||||
- AES-128 crypto coprocessor (4 keys: Key0-Key3, TAM/MAM)
|
|
||||||
- 64 blocks (63 user + block 63 = 16-bit counter)
|
|
||||||
- 48-block configuration memory (READ/WRITE CONFIG)
|
|
||||||
- Key headers, key privileges, authentication limit
|
|
||||||
- Flexible memory segmentation with AES-based access conditions
|
|
||||||
- No TagTamper, no NFC mirror (those are ICODE 3 features)
|
|
||||||
|
|
||||||
TAM/MAM authentication uses undocumented AuthMethod values:
|
|
||||||
0x00 = TAM1, 0x02 = MAM1, 0x06 = MAM2
|
|
||||||
Standard 0x80/0x90 NOT supported (Error 0x0F).
|
|
||||||
All data byte-reversed on wire. See docs/NTAG5_SECURITY.md.
|
|
||||||
|
|
||||||
The ICODE DNA shares the AES auth protocol with NTAG 5 Link (NTP5332)
|
|
||||||
and NTAG 5 Boost (NTA5332).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from .auth_aes import (
|
|
||||||
NxpAesAuth,
|
|
||||||
KEY_HEADER_NOT_ACTIVE,
|
|
||||||
KEY_HEADER_ACTIVE_LOCKED,
|
|
||||||
KEY_HEADER_DISABLED,
|
|
||||||
PRIV_READ,
|
|
||||||
PRIV_WRITE,
|
|
||||||
PRIV_PRIVACY,
|
|
||||||
PRIV_DESTROY,
|
|
||||||
PRIV_EAS_AFI,
|
|
||||||
PRIV_CRYPTO_CONFIG,
|
|
||||||
PRIV_AREA1_READ,
|
|
||||||
PRIV_AREA1_WRITE,
|
|
||||||
GCH_DEACTIVATED,
|
|
||||||
GCH_DEACTIVATED_PRIV_LOCKED,
|
|
||||||
GCH_ACTIVATED,
|
|
||||||
GCH_ACTIVATED_PRIV_LOCKED,
|
|
||||||
GCH_FINAL,
|
|
||||||
)
|
|
||||||
from .frame import RFFrame
|
|
||||||
from .icode_slix2 import IcodeSlix2Tag, PWD_READ
|
|
||||||
from .nxp_icode import NXP_MFG
|
|
||||||
|
|
||||||
# Config memory password/key block addresses (read-masked with 0x00)
|
|
||||||
_CONFIG_KEY_BLOCKS = set(range(0x20, 0x30)) # KEY0-KEY3 (4 blocks each)
|
|
||||||
|
|
||||||
# Config Header values
|
|
||||||
CONFIG_HEADER_WRITABLE = 0x81
|
|
||||||
CONFIG_HEADER_LOCKED = 0xE7
|
|
||||||
|
|
||||||
|
|
||||||
class IcodeDnaTag(NxpAesAuth, IcodeSlix2Tag):
|
|
||||||
"""ICODE DNA transponder (SL2S6002).
|
|
||||||
|
|
||||||
Features beyond IcodeSlix2Tag:
|
|
||||||
- AES-128 tag/mutual authentication (4 keys)
|
|
||||||
- 64 user blocks, block 63 = 16-bit counter
|
|
||||||
- 48-block configuration memory
|
|
||||||
- Key headers, privileges, and authentication limit
|
|
||||||
|
|
||||||
Hierarchy: NxpIcodeTag → IcodeSlixTag → IcodeSlix2Tag → IcodeDnaTag
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Verified: real DNA UID E0 04 01 18 00 9C 78 2B → TagInfo = "ICODE DNA (SL2S6002)"
|
|
||||||
_uid_prefix = b"\xE0\x04\x01\x18"
|
|
||||||
|
|
||||||
def __init__(self, uid: bytes | str | None = None,
|
|
||||||
aes_keys: list[bytes | None] | None = None,
|
|
||||||
**kwargs):
|
|
||||||
kwargs.setdefault("num_blocks", 64)
|
|
||||||
kwargs.setdefault("ic_reference", 0x01)
|
|
||||||
super().__init__(uid=uid, **kwargs)
|
|
||||||
|
|
||||||
# Configuration memory: 48 blocks × 4 bytes
|
|
||||||
self._config_memory = bytearray(48 * 4)
|
|
||||||
|
|
||||||
# AES key management (from NxpAesAuth mixin)
|
|
||||||
self._init_aes_auth(aes_keys=aes_keys)
|
|
||||||
|
|
||||||
# Config Header — controls lock state of signature + CID
|
|
||||||
self._config_header = CONFIG_HEADER_WRITABLE
|
|
||||||
|
|
||||||
# CID (Customer ID) — default 0xC000
|
|
||||||
self._cid = 0xC000
|
|
||||||
|
|
||||||
# Initialize config memory defaults
|
|
||||||
self._init_dna_config()
|
|
||||||
|
|
||||||
def _init_dna_config(self):
|
|
||||||
"""Initialize config memory with DNA factory defaults."""
|
|
||||||
# Blocks 0x00-0x07: Originality signature (32 bytes)
|
|
||||||
sig = getattr(self, '_signature', bytes(32))
|
|
||||||
self._config_memory[0:32] = sig
|
|
||||||
|
|
||||||
# Block 0x08: Config Header
|
|
||||||
self._config_memory[0x08 * 4] = self._config_header
|
|
||||||
|
|
||||||
# Block 0x09: CID
|
|
||||||
struct.pack_into("<H", self._config_memory, 0x09 * 4, self._cid)
|
|
||||||
|
|
||||||
# Blocks 0x10, 0x12, 0x14, 0x16: Key Headers (byte 1)
|
|
||||||
for i in range(4):
|
|
||||||
block = 0x10 + i * 2
|
|
||||||
self._config_memory[block * 4 + 1] = self._key_headers[i]
|
|
||||||
|
|
||||||
# Blocks 0x11, 0x13, 0x15, 0x17: Key Privileges (byte 0)
|
|
||||||
for i in range(4):
|
|
||||||
block = 0x11 + i * 2
|
|
||||||
self._config_memory[block * 4] = self._key_privileges[i]
|
|
||||||
|
|
||||||
# Blocks 0x20-0x2F: AES keys (4 × 16 bytes)
|
|
||||||
for i, key in enumerate(self._aes_keys):
|
|
||||||
if key is not None:
|
|
||||||
offset = (0x20 + i * 4) * 4
|
|
||||||
self._config_memory[offset:offset + 16] = key[:16]
|
|
||||||
|
|
||||||
def _build_access_map(self) -> None:
|
|
||||||
"""Override: counter on last block."""
|
|
||||||
has_pp = self._protection_pointer is not None
|
|
||||||
last_block = self._num_blocks - 1
|
|
||||||
|
|
||||||
if not has_pp and last_block < 0:
|
|
||||||
self.regions["user"].access_map = None
|
|
||||||
return
|
|
||||||
|
|
||||||
from .memory import BlockAccess
|
|
||||||
user = self.regions["user"]
|
|
||||||
access_map = []
|
|
||||||
for block in range(self._num_blocks):
|
|
||||||
ba = BlockAccess()
|
|
||||||
if has_pp and block >= self._protection_pointer:
|
|
||||||
ba = BlockAccess(read="password", read_key=PWD_READ)
|
|
||||||
if block == last_block:
|
|
||||||
ba = BlockAccess(read=ba.read, read_key=ba.read_key,
|
|
||||||
write_mode="counter")
|
|
||||||
access_map.append(ba)
|
|
||||||
user.access_map = access_map
|
|
||||||
|
|
||||||
# ----- Command dispatch -----
|
|
||||||
|
|
||||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Override to intercept ISO 29167-10 CHALLENGE/READBUFFER/AUTHENTICATE.
|
|
||||||
|
|
||||||
Commands 0x35 (AUTHENTICATE), 0x39 (CHALLENGE) and 0x3A (READBUFFER)
|
|
||||||
are standard ISO commands, not NXP custom (>= 0xA0), so they must be
|
|
||||||
caught here before the base class returns "not supported".
|
|
||||||
"""
|
|
||||||
if len(frame.data) >= 2:
|
|
||||||
cmd = frame.data[1]
|
|
||||||
if cmd == 0x35: # AUTHENTICATE (MAM1/MAM2)
|
|
||||||
return self._handle_authenticate(frame)
|
|
||||||
if cmd == 0x39: # CHALLENGE
|
|
||||||
self._handle_challenge(frame)
|
|
||||||
return None # no RF response
|
|
||||||
if cmd == 0x3A: # READBUFFER
|
|
||||||
return self._handle_readbuffer(frame)
|
|
||||||
return await super().handle_frame(frame)
|
|
||||||
|
|
||||||
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
|
|
||||||
frame = self._normalize_custom_frame(frame)
|
|
||||||
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
|
||||||
match cmd:
|
|
||||||
case 0xC0: # READ CONFIG
|
|
||||||
return self._handle_read_config(frame)
|
|
||||||
case 0xC1: # WRITE CONFIG
|
|
||||||
return self._handle_write_config(frame)
|
|
||||||
return super()._handle_custom_command(cmd, frame)
|
|
||||||
|
|
||||||
# ----- READ CONFIG -----
|
|
||||||
|
|
||||||
def _handle_read_config(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""READ CONFIG (0xC0): read config memory blocks.
|
|
||||||
|
|
||||||
Key blocks (0x20-0x2F) masked with 0x00 when key header is active (0xE7).
|
|
||||||
"""
|
|
||||||
if len(frame.data) < 5:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
block_addr = frame.data[3]
|
|
||||||
num_blocks = frame.data[4] + 1
|
|
||||||
|
|
||||||
if block_addr + num_blocks > 48:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
resp = bytearray([0x00])
|
|
||||||
for blk in range(block_addr, block_addr + num_blocks):
|
|
||||||
offset = blk * 4
|
|
||||||
# Mask key storage when header is active/locked
|
|
||||||
if blk in _CONFIG_KEY_BLOCKS:
|
|
||||||
key_idx = (blk - 0x20) // 4
|
|
||||||
if key_idx < 4 and self._key_headers[key_idx] == KEY_HEADER_ACTIVE_LOCKED:
|
|
||||||
resp.extend(bytes(4))
|
|
||||||
continue
|
|
||||||
resp.extend(self._config_memory[offset:offset + 4])
|
|
||||||
|
|
||||||
return RFFrame.from_bytes(bytes(resp))
|
|
||||||
|
|
||||||
# ----- WRITE CONFIG -----
|
|
||||||
|
|
||||||
def _handle_write_config(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""WRITE CONFIG (0xC1): write 4 bytes to config block."""
|
|
||||||
if len(frame.data) < 8:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
block_addr = frame.data[3]
|
|
||||||
if block_addr >= 48:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
data = frame.data[4:8]
|
|
||||||
|
|
||||||
# Key storage: reject writes when header is active
|
|
||||||
if block_addr in _CONFIG_KEY_BLOCKS:
|
|
||||||
key_idx = (block_addr - 0x20) // 4
|
|
||||||
if key_idx < 4 and self._key_headers[key_idx] == KEY_HEADER_ACTIVE_LOCKED:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
offset = block_addr * 4
|
|
||||||
self._config_memory[offset:offset + 4] = data
|
|
||||||
|
|
||||||
# Track key header changes
|
|
||||||
kh_blocks = {0x10: 0, 0x12: 1, 0x14: 2, 0x16: 3}
|
|
||||||
if block_addr in kh_blocks:
|
|
||||||
key_idx = kh_blocks[block_addr]
|
|
||||||
new_header = data[1] # Key header is byte 1
|
|
||||||
# One-way: only allow lower → higher values
|
|
||||||
if new_header >= self._key_headers[key_idx]:
|
|
||||||
self._key_headers[key_idx] = new_header
|
|
||||||
|
|
||||||
# Track key privilege changes
|
|
||||||
kp_blocks = {0x11: 0, 0x13: 1, 0x15: 2, 0x17: 3}
|
|
||||||
if block_addr in kp_blocks:
|
|
||||||
key_idx = kp_blocks[block_addr]
|
|
||||||
self._key_privileges[key_idx] = data[0]
|
|
||||||
|
|
||||||
# Track GCH changes (block 0x0C, byte 1)
|
|
||||||
if block_addr == 0x0C:
|
|
||||||
new_gch = data[1]
|
|
||||||
if new_gch >= self._nfc_gch: # one-way
|
|
||||||
self._nfc_gch = new_gch
|
|
||||||
|
|
||||||
# Track config header changes (block 0x08, byte 0)
|
|
||||||
if block_addr == 0x08:
|
|
||||||
new_ch = data[0]
|
|
||||||
if new_ch >= self._config_header: # one-way
|
|
||||||
self._config_header = new_ch
|
|
||||||
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
# ----- GET NXP SYSTEM INFO override -----
|
|
||||||
|
|
||||||
def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""GET NXP SYSTEM INFO with DNA feature flags."""
|
|
||||||
pp = self._protection_pointer if self._protection_pointer is not None else 0
|
|
||||||
pp_cond = 0x01 if self._protection_pointer is not None else 0x00
|
|
||||||
lock_bits = 0x00
|
|
||||||
|
|
||||||
# DNA feature flags (similar to ICODE 3 Table 113 but without TagTamper/mirror)
|
|
||||||
feature_flags = (
|
|
||||||
(1 << 0) | # UM_PP
|
|
||||||
(1 << 1) | # COUNTER
|
|
||||||
(1 << 2) | # EAS_ID
|
|
||||||
(1 << 3) | # EAS_PP
|
|
||||||
(1 << 4) | # AFI_PP
|
|
||||||
(1 << 8) | # ORIGINALITY_SIG
|
|
||||||
(1 << 10) | # P_QUIET
|
|
||||||
(1 << 12) | # PRIVACY
|
|
||||||
(1 << 13) | # DESTROY
|
|
||||||
(1 << 15) # HIGH_DATA_RATES
|
|
||||||
)
|
|
||||||
|
|
||||||
return RFFrame.from_bytes(
|
|
||||||
bytes([0x00, pp, pp_cond, lock_bits]) +
|
|
||||||
struct.pack("<I", feature_flags)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,169 +1,2 @@
|
|||||||
"""ICODE SLIX (SL2S2002) — NXP IC with EAS and single password.
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
|
from pm3py.transponders.hf.iso15693.nxp.icode_slix import * # noqa: F401,F403
|
||||||
This is the shared base for SLIX, SLIX2, and ICODE 3. Contains:
|
|
||||||
- EAS subsystem (SET/RESET/LOCK, ALARM, PROTECT EAS/AFI, WRITE EAS ID)
|
|
||||||
- INVENTORY READ / FAST INVENTORY READ
|
|
||||||
- Frame normalization for addressed custom commands
|
|
||||||
|
|
||||||
Password auth (SET_PASSWORD, WRITE_PASSWORD, LOCK_PASSWORD, GET_RANDOM,
|
|
||||||
XOR verification) is provided by the NxpPasswordAuth mixin.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from .auth_password import NxpPasswordAuth
|
|
||||||
from .frame import RFFrame
|
|
||||||
from .nxp_icode import NxpIcodeTag, NXP_MFG
|
|
||||||
|
|
||||||
# Password ID shared across all SLIX variants
|
|
||||||
PWD_EAS_AFI = 0x10
|
|
||||||
|
|
||||||
|
|
||||||
class IcodeSlixTag(NxpPasswordAuth, NxpIcodeTag):
|
|
||||||
"""ICODE SLIX transponder (SL2S2002).
|
|
||||||
|
|
||||||
Features beyond NxpIcodeTag:
|
|
||||||
- 1 password: EAS/AFI (32-bit, XOR'd with GET_RANDOM)
|
|
||||||
- EAS (Electronic Article Surveillance) with lock
|
|
||||||
- INVENTORY READ / FAST INVENTORY READ
|
|
||||||
- WRITE PASSWORD, LOCK PASSWORD
|
|
||||||
|
|
||||||
Subclasses (SLIX2, ICODE 3) add more passwords and features.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_uid_prefix = b"\xE0\x04\x01\x10" # TAG type 0x01, bits 37:36 = 10 → byte bits 4:3 = 10
|
|
||||||
|
|
||||||
def __init__(self, uid: bytes | str | None = None,
|
|
||||||
eas_afi_password: int | None = None,
|
|
||||||
**kwargs):
|
|
||||||
# Default to 28 blocks for SLIX OG; subclasses override
|
|
||||||
kwargs.setdefault("num_blocks", 28)
|
|
||||||
kwargs.setdefault("ic_reference", 0x01) # SLIX IC type per SL2S2002
|
|
||||||
super().__init__(uid=uid, **kwargs)
|
|
||||||
|
|
||||||
# Initialize password auth from mixin
|
|
||||||
self._init_password_auth({PWD_EAS_AFI: eas_afi_password})
|
|
||||||
|
|
||||||
# EAS state
|
|
||||||
self._eas_enabled = False
|
|
||||||
self._eas_locked = False
|
|
||||||
self._eas_password_protected = False
|
|
||||||
self._eas_id: int = 0
|
|
||||||
self._eas_sequence = bytes(32) # 256-bit EAS sequence
|
|
||||||
|
|
||||||
@property
|
|
||||||
def eas_enabled(self) -> bool:
|
|
||||||
return self._eas_enabled
|
|
||||||
|
|
||||||
def set_eas(self, enabled: bool) -> None:
|
|
||||||
self._eas_enabled = enabled
|
|
||||||
|
|
||||||
# ----- Frame normalization -----
|
|
||||||
|
|
||||||
def _normalize_custom_frame(self, frame: RFFrame) -> RFFrame:
|
|
||||||
"""Normalize addressed custom commands by stripping UID.
|
|
||||||
|
|
||||||
Addressed: flags(1) + cmd(1) + uid(8) + mfg(1) + data...
|
|
||||||
Unaddressed: flags(1) + cmd(1) + mfg(1) + data...
|
|
||||||
Returns unaddressed-format frame for uniform processing.
|
|
||||||
"""
|
|
||||||
flags = frame.data[0]
|
|
||||||
if (flags & 0x20) and not (flags & 0x04): # addressed, not inventory
|
|
||||||
normalized = bytes([flags & ~0x20]) + frame.data[1:2] + frame.data[10:]
|
|
||||||
return RFFrame.from_bytes(normalized)
|
|
||||||
return frame
|
|
||||||
|
|
||||||
# ----- Custom command dispatch -----
|
|
||||||
|
|
||||||
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Handle SLIX custom commands, fall through to NXP base."""
|
|
||||||
frame = self._normalize_custom_frame(frame)
|
|
||||||
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
|
||||||
match cmd:
|
|
||||||
case 0xA0 | 0xA1: # INVENTORY READ / FAST INVENTORY READ
|
|
||||||
return self._handle_inventory_read(frame)
|
|
||||||
case 0xA2: # SET EAS
|
|
||||||
return self._handle_set_eas(frame)
|
|
||||||
case 0xA3: # RESET EAS
|
|
||||||
return self._handle_reset_eas(frame)
|
|
||||||
case 0xA4: # LOCK EAS
|
|
||||||
return self._handle_lock_eas(frame)
|
|
||||||
case 0xA5: # EAS ALARM
|
|
||||||
return self._handle_eas_alarm(frame)
|
|
||||||
case 0xA6: # PASSWORD PROTECT EAS/AFI
|
|
||||||
return self._handle_password_protect_eas_afi(frame)
|
|
||||||
case 0xB2: # GET RANDOM — override to store random
|
|
||||||
return self._handle_get_random(frame)
|
|
||||||
case 0xB3: # SET_PASSWORD
|
|
||||||
return self._handle_set_password(frame)
|
|
||||||
case 0xB4: # WRITE PASSWORD
|
|
||||||
return self._handle_write_password(frame)
|
|
||||||
case 0xB5: # LOCK PASSWORD
|
|
||||||
return self._handle_lock_password(frame)
|
|
||||||
return super()._handle_custom_command(cmd, frame)
|
|
||||||
|
|
||||||
# ----- EAS auth check -----
|
|
||||||
|
|
||||||
def _check_eas_auth(self) -> bool:
|
|
||||||
"""Check if EAS/AFI operations are allowed."""
|
|
||||||
if not self._eas_password_protected:
|
|
||||||
return True
|
|
||||||
return PWD_EAS_AFI in self._authenticated_passwords
|
|
||||||
|
|
||||||
# ----- EAS subsystem -----
|
|
||||||
|
|
||||||
def _handle_set_eas(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
if self._eas_locked:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
if not self._check_eas_auth():
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
self._eas_enabled = True
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
def _handle_reset_eas(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
if self._eas_locked:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
if not self._check_eas_auth():
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
self._eas_enabled = False
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
def _handle_lock_eas(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
if not self._check_eas_auth():
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
self._eas_locked = True
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
def _handle_eas_alarm(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
if not self._eas_enabled:
|
|
||||||
return None
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]) + self._eas_sequence)
|
|
||||||
|
|
||||||
def _handle_password_protect_eas_afi(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
if PWD_EAS_AFI not in self._authenticated_passwords:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
self._eas_password_protected = True
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
# ----- INVENTORY READ -----
|
|
||||||
|
|
||||||
def _handle_inventory_read(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""INVENTORY READ / FAST INVENTORY READ (0xA0/0xA1)."""
|
|
||||||
if len(frame.data) < 6:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
first_block = frame.data[4]
|
|
||||||
num_blocks = frame.data[5] + 1
|
|
||||||
|
|
||||||
resp = bytearray([0x00])
|
|
||||||
resp.append(self._dsfid)
|
|
||||||
resp.extend(self._uid)
|
|
||||||
|
|
||||||
for blk in range(first_block, first_block + num_blocks):
|
|
||||||
if blk >= self._num_blocks:
|
|
||||||
break
|
|
||||||
offset = blk * self._block_size
|
|
||||||
resp.extend(self._memory[offset:offset + self._block_size])
|
|
||||||
|
|
||||||
return RFFrame.from_bytes(bytes(resp))
|
|
||||||
|
|||||||
@@ -1,257 +1,2 @@
|
|||||||
"""ICODE SLIX2 — NXP IC with password protection and privacy mode.
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
|
from pm3py.transponders.hf.iso15693.nxp.icode_slix2 import * # noqa: F401,F403
|
||||||
Extends IcodeSlixTag with:
|
|
||||||
- 4 additional passwords (read, write, privacy, destroy)
|
|
||||||
- Privacy mode + ENABLE PRIVACY command
|
|
||||||
- Protection pointer + PROTECT/LOCK PAGE PROTECTION
|
|
||||||
- DESTROY, 64-BIT PASSWORD PROTECTION, STAY QUIET PERSISTENT
|
|
||||||
- Block 79 counter
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from .frame import RFFrame
|
|
||||||
from .icode_slix import IcodeSlixTag, PWD_EAS_AFI
|
|
||||||
from .nxp_icode import NXP_MFG
|
|
||||||
|
|
||||||
# SLIX2-specific password IDs
|
|
||||||
PWD_READ = 0x01
|
|
||||||
PWD_WRITE = 0x02
|
|
||||||
PWD_PRIVACY = 0x04
|
|
||||||
PWD_DESTROY = 0x08
|
|
||||||
|
|
||||||
# Protection page condition bits
|
|
||||||
PP_COND_READ_PROTECTION = 0x01
|
|
||||||
PP_COND_WRITE_PROTECTION = 0x02
|
|
||||||
|
|
||||||
|
|
||||||
class IcodeSlix2Tag(IcodeSlixTag):
|
|
||||||
"""ICODE SLIX2 transponder (SL2S2602).
|
|
||||||
|
|
||||||
Features beyond IcodeSlixTag:
|
|
||||||
- 4 additional passwords: read, write, privacy, destroy
|
|
||||||
- Privacy mode: hides UID from inventory
|
|
||||||
- Protection pointer: blocks above this are password-protected
|
|
||||||
- ENABLE PRIVACY (0xBA), DESTROY (0xB9), 64-BIT PASSWORD PROTECTION (0xBB)
|
|
||||||
- STAY QUIET PERSISTENT (0xBC), block 79 counter
|
|
||||||
"""
|
|
||||||
|
|
||||||
_uid_prefix = b"\xE0\x04\x02" # SLIX2 IC type = 0x02
|
|
||||||
|
|
||||||
def __init__(self, uid: bytes | str | None = None,
|
|
||||||
read_password: int | None = None,
|
|
||||||
write_password: int | None = None,
|
|
||||||
privacy_password: int | None = None,
|
|
||||||
destroy_password: int | None = None,
|
|
||||||
eas_afi_password: int | None = None,
|
|
||||||
protection_pointer: int | None = None,
|
|
||||||
**kwargs):
|
|
||||||
kwargs.setdefault("num_blocks", 80)
|
|
||||||
kwargs.setdefault("ic_reference", 0x01) # SLIX2 IC type per SL2S2602
|
|
||||||
super().__init__(uid=uid, eas_afi_password=eas_afi_password, **kwargs)
|
|
||||||
|
|
||||||
# Add SLIX2-specific passwords to the inherited dict
|
|
||||||
self._passwords[PWD_READ] = read_password
|
|
||||||
self._passwords[PWD_WRITE] = write_password
|
|
||||||
self._passwords[PWD_PRIVACY] = privacy_password
|
|
||||||
self._passwords[PWD_DESTROY] = destroy_password
|
|
||||||
|
|
||||||
# SLIX2-specific state
|
|
||||||
self._privacy_mode = False
|
|
||||||
self._protection_pointer = protection_pointer
|
|
||||||
self._page_protection_locked = False
|
|
||||||
self._extended_protection = False
|
|
||||||
self._destroyed = False
|
|
||||||
self._persistent_quiet = False
|
|
||||||
|
|
||||||
# Build per-block access map
|
|
||||||
self._build_access_map()
|
|
||||||
|
|
||||||
def _build_access_map(self) -> None:
|
|
||||||
"""Build per-block access map from protection pointer + counter."""
|
|
||||||
has_counter = self._num_blocks >= 80
|
|
||||||
has_pp = self._protection_pointer is not None
|
|
||||||
if not has_counter and not has_pp:
|
|
||||||
self.regions["user"].access_map = None
|
|
||||||
return
|
|
||||||
|
|
||||||
from .memory import BlockAccess
|
|
||||||
user = self.regions["user"]
|
|
||||||
access_map = []
|
|
||||||
for block in range(self._num_blocks):
|
|
||||||
ba = BlockAccess()
|
|
||||||
if has_pp and block >= self._protection_pointer:
|
|
||||||
ba = BlockAccess(read="password", read_key=PWD_READ)
|
|
||||||
if has_counter and block == 79:
|
|
||||||
ba = BlockAccess(read=ba.read, read_key=ba.read_key,
|
|
||||||
write_mode="counter")
|
|
||||||
access_map.append(ba)
|
|
||||||
user.access_map = access_map
|
|
||||||
|
|
||||||
async def power_on(self) -> None:
|
|
||||||
await super().power_on()
|
|
||||||
if self._persistent_quiet:
|
|
||||||
from .iso15693 import State15693
|
|
||||||
self._state = State15693.QUIET
|
|
||||||
|
|
||||||
@property
|
|
||||||
def privacy_mode(self) -> bool:
|
|
||||||
return self._privacy_mode
|
|
||||||
|
|
||||||
def enter_privacy_mode(self) -> None:
|
|
||||||
if self._passwords[PWD_PRIVACY] is not None:
|
|
||||||
self._privacy_mode = True
|
|
||||||
|
|
||||||
# ----- handle_frame override for destroyed/privacy -----
|
|
||||||
|
|
||||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
if self._destroyed:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if len(frame.data) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
cmd = frame.data[1]
|
|
||||||
|
|
||||||
# In privacy mode: only respond to GET_RANDOM, SET_PASSWORD, and ENABLE_PRIVACY
|
|
||||||
if self._privacy_mode:
|
|
||||||
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
|
||||||
if cmd == 0xB2:
|
|
||||||
return self._handle_get_random(frame)
|
|
||||||
if cmd == 0xB3:
|
|
||||||
return self._handle_set_password(frame)
|
|
||||||
if cmd == 0xBA:
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
return None
|
|
||||||
|
|
||||||
return await super().handle_frame(frame)
|
|
||||||
|
|
||||||
# ----- SLIX2-specific command dispatch -----
|
|
||||||
|
|
||||||
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Handle SLIX2-specific commands, delegate shared ones to parent."""
|
|
||||||
frame = self._normalize_custom_frame(frame)
|
|
||||||
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
|
||||||
match cmd:
|
|
||||||
case 0xB6: # PROTECT PAGE
|
|
||||||
return self._handle_protect_page(frame)
|
|
||||||
case 0xB7: # LOCK PAGE PROTECTION
|
|
||||||
return self._handle_lock_page_protection(frame)
|
|
||||||
case 0xB9: # DESTROY
|
|
||||||
return self._handle_destroy(frame)
|
|
||||||
case 0xBA: # ENABLE_PRIVACY
|
|
||||||
return self._handle_enable_privacy(frame)
|
|
||||||
case 0xBB: # 64-BIT PASSWORD PROTECTION
|
|
||||||
return self._handle_64bit_password_protection(frame)
|
|
||||||
case 0xBC: # STAY QUIET PERSISTENT
|
|
||||||
return self._handle_stay_quiet_persistent(frame)
|
|
||||||
case 0xA7: # WRITE EAS ID (SLIX2+ only, EAS Selective per AN11809)
|
|
||||||
return self._handle_write_eas_id(frame)
|
|
||||||
case 0xAB: # GET NXP SYSTEM INFO — override with SLIX2 data
|
|
||||||
return self._handle_get_nxp_system_info(frame)
|
|
||||||
case 0xBD: # READ SIGNATURE
|
|
||||||
return self._handle_read_signature(frame)
|
|
||||||
# Delegate to parent (IcodeSlixTag) for shared commands
|
|
||||||
# Re-normalize already happened, but parent will normalize again (no-op for unaddressed)
|
|
||||||
return super()._handle_custom_command(cmd, frame)
|
|
||||||
|
|
||||||
# ----- SET_PASSWORD override (privacy unlock) -----
|
|
||||||
|
|
||||||
def _handle_set_password(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Override to unlock privacy mode on privacy password auth."""
|
|
||||||
result = super()._handle_set_password(frame)
|
|
||||||
if result is not None and result.data[0] == 0x00:
|
|
||||||
pwd_id = frame.data[3]
|
|
||||||
if pwd_id == PWD_PRIVACY:
|
|
||||||
self._privacy_mode = False
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ----- SLIX2-only commands -----
|
|
||||||
|
|
||||||
def _handle_write_eas_id(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""WRITE EAS ID (0xA7): SLIX2+ only (EAS Selective per AN11809)."""
|
|
||||||
if len(frame.data) < 5:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
if not self._check_eas_auth():
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
self._eas_id = struct.unpack_from("<H", frame.data, 3)[0]
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
def _handle_enable_privacy(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""ENABLE PRIVACY (0xBA): enter privacy mode using XOR'd password."""
|
|
||||||
if len(frame.data) < 7:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
if not self._verify_xor_password(PWD_PRIVACY, frame.data[3:7]):
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
self._privacy_mode = True
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""GET NXP SYSTEM INFO (0xAB): SLIX2-specific response."""
|
|
||||||
pp = self._protection_pointer if self._protection_pointer is not None else 0
|
|
||||||
pp_cond = 0x00
|
|
||||||
if self._protection_pointer is not None:
|
|
||||||
pp_cond = PP_COND_READ_PROTECTION
|
|
||||||
lock_bits = 0x00
|
|
||||||
feature_flags = struct.pack("<I", 0x00000000)
|
|
||||||
return RFFrame.from_bytes(bytes([0x00, pp, pp_cond, lock_bits]) + feature_flags)
|
|
||||||
|
|
||||||
def _handle_protect_page(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""PROTECT PAGE (0xB6): set protection pointer and conditions."""
|
|
||||||
if len(frame.data) < 5:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
if self._page_protection_locked:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
if PWD_WRITE not in self._authenticated_passwords:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
self._protection_pointer = frame.data[3]
|
|
||||||
self._build_access_map()
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
def _handle_lock_page_protection(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""LOCK PAGE PROTECTION (0xB7): permanently lock protection config."""
|
|
||||||
if PWD_WRITE not in self._authenticated_passwords:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
self._page_protection_locked = True
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
def _handle_destroy(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""DESTROY (0xB9): irreversibly kill tag."""
|
|
||||||
if len(frame.data) < 7:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
if not self._verify_xor_password(PWD_DESTROY, frame.data[3:7]):
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
|
|
||||||
self._destroyed = True
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
def _handle_64bit_password_protection(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""64-BIT PASSWORD PROTECTION (0xBB): requires both read + write auth."""
|
|
||||||
if PWD_READ not in self._authenticated_passwords:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
if PWD_WRITE not in self._authenticated_passwords:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
self._extended_protection = True
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
def _handle_stay_quiet_persistent(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""STAY QUIET PERSISTENT (0xBC): persistent quiet survives power cycles."""
|
|
||||||
from .iso15693 import State15693
|
|
||||||
self._persistent_quiet = True
|
|
||||||
self._state = State15693.QUIET
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]))
|
|
||||||
|
|
||||||
def _handle_read_single(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Override to enforce read protection."""
|
|
||||||
block = self._extract_block_number(frame)
|
|
||||||
if block is None:
|
|
||||||
return None
|
|
||||||
if self._protection_pointer is not None and block >= self._protection_pointer:
|
|
||||||
if PWD_READ not in self._authenticated_passwords:
|
|
||||||
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
|
||||||
return super()._handle_read_single(frame)
|
|
||||||
|
|||||||
@@ -1,66 +1,2 @@
|
|||||||
"""Implant preset profiles — pre-configured transponder instances."""
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
from __future__ import annotations
|
from pm3py.transponders.implants import * # noqa: F401,F403
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,387 +1,2 @@
|
|||||||
"""ISO 14443-A transponder and reader state machines."""
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
from __future__ import annotations
|
from pm3py.transponders.hf.iso14443a.base import * # noqa: F401,F403
|
||||||
|
|
||||||
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:]
|
|
||||||
|
|||||||
@@ -1,518 +1,2 @@
|
|||||||
"""ISO 15693 transponder and reader state machines."""
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
from __future__ import annotations
|
from pm3py.transponders.hf.iso15693.base import * # noqa: F401,F403
|
||||||
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
from enum import IntEnum
|
|
||||||
|
|
||||||
from .frame import RFFrame
|
|
||||||
from .medium import Medium
|
|
||||||
from .trace_fmt import decode_15693
|
|
||||||
from .transponder import Transponder
|
|
||||||
from .reader import Reader
|
|
||||||
|
|
||||||
# ---- ISO 15693 flag bits ----
|
|
||||||
FLAG_SUBCARRIER = 0x01
|
|
||||||
FLAG_HIGH_DATA_RATE = 0x02
|
|
||||||
FLAG_INVENTORY = 0x04
|
|
||||||
FLAG_PROTOCOL_EXT = 0x08
|
|
||||||
FLAG_SELECT = 0x10 # non-inventory: selected
|
|
||||||
FLAG_ADDRESS = 0x20 # non-inventory: addressed
|
|
||||||
FLAG_INV_SLOTS1 = 0x20 # inventory: 1-slot (if set)
|
|
||||||
FLAG_OPTION = 0x40
|
|
||||||
FLAG_RFU = 0x80
|
|
||||||
|
|
||||||
# Response flag bits
|
|
||||||
RESP_ERROR = 0x01
|
|
||||||
|
|
||||||
# ---- ISO 15693 commands ----
|
|
||||||
CMD_INVENTORY = 0x01
|
|
||||||
CMD_STAY_QUIET = 0x02
|
|
||||||
CMD_READ_SINGLE = 0x20
|
|
||||||
CMD_WRITE_SINGLE = 0x21
|
|
||||||
CMD_READ_MULTIPLE = 0x23
|
|
||||||
CMD_RESET_TO_READY = 0x26
|
|
||||||
CMD_SYSTEM_INFO = 0x2B
|
|
||||||
CMD_EXT_READ_SINGLE = 0x30
|
|
||||||
CMD_EXT_WRITE_SINGLE = 0x31
|
|
||||||
CMD_EXT_READ_MULTIPLE = 0x33
|
|
||||||
|
|
||||||
# System info flag bits
|
|
||||||
SYSINFO_DSFID = 0x01
|
|
||||||
SYSINFO_AFI = 0x02
|
|
||||||
SYSINFO_MEMSIZE = 0x04
|
|
||||||
SYSINFO_ICREF = 0x08
|
|
||||||
|
|
||||||
|
|
||||||
class State15693(IntEnum):
|
|
||||||
POWER_OFF = 0
|
|
||||||
READY = 1
|
|
||||||
QUIET = 2
|
|
||||||
SELECTED = 3
|
|
||||||
|
|
||||||
|
|
||||||
class Tag15693(Transponder):
|
|
||||||
"""ISO 15693 transponder with inventory and block operations."""
|
|
||||||
|
|
||||||
# Subclasses override to set valid UID prefix for auto-generation.
|
|
||||||
# Format: [E0, manufacturer, IC_type, ...] — remaining bytes are random.
|
|
||||||
_uid_prefix: bytes = b"\xE0\x00"
|
|
||||||
|
|
||||||
def __init__(self, uid: bytes | str | None = None, dsfid: int = 0, afi: int = 0,
|
|
||||||
memory: bytearray | None = None,
|
|
||||||
block_size: int = 4, num_blocks: int = 28,
|
|
||||||
ic_reference: int = 0x00):
|
|
||||||
import warnings
|
|
||||||
super().__init__()
|
|
||||||
if uid is None:
|
|
||||||
uid = self._generate_uid()
|
|
||||||
else:
|
|
||||||
uid = self._parse_uid(uid)
|
|
||||||
if len(uid) != 8:
|
|
||||||
raise ValueError(f"UID must be 8 bytes, got {len(uid)}")
|
|
||||||
# Validate UID prefix matches expected IC type (only for product-specific subclasses)
|
|
||||||
prefix = self._uid_prefix
|
|
||||||
if len(prefix) > 2 and uid[:len(prefix)] != prefix:
|
|
||||||
expected = prefix.hex().upper()
|
|
||||||
actual = uid[:len(prefix)].hex().upper()
|
|
||||||
cls_name = type(self).__name__
|
|
||||||
warnings.warn(
|
|
||||||
f"{cls_name}: UID prefix {actual} doesn't match expected {expected}. "
|
|
||||||
f"Phones/readers may not identify the IC type correctly. "
|
|
||||||
f"Use uid=None for auto-generated valid UID, or start UID with {expected}.",
|
|
||||||
stacklevel=3,
|
|
||||||
)
|
|
||||||
self._uid = uid
|
|
||||||
self._dsfid = dsfid
|
|
||||||
self._afi = afi
|
|
||||||
self._block_size = block_size
|
|
||||||
self._num_blocks = num_blocks
|
|
||||||
self._ic_reference = ic_reference
|
|
||||||
self._state = State15693.POWER_OFF
|
|
||||||
self._uid_dirty = True # ensure first sync() pushes header to firmware
|
|
||||||
|
|
||||||
# Memory regions
|
|
||||||
from .memory import DirtyByteArray, MemoryRegion
|
|
||||||
user_data = DirtyByteArray(memory) if memory else DirtyByteArray(block_size * num_blocks)
|
|
||||||
self.regions["user"] = MemoryRegion(
|
|
||||||
name="user", data=user_data, block_size=block_size, eml_offset=175)
|
|
||||||
self.regions["locks"] = MemoryRegion(
|
|
||||||
name="locks", data=DirtyByteArray(num_blocks), block_size=1,
|
|
||||||
eml_offset=15, rf_readable=False)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _memory(self):
|
|
||||||
"""Alias for regions['user'].data — backwards compatible."""
|
|
||||||
return self.regions["user"].data
|
|
||||||
|
|
||||||
@_memory.setter
|
|
||||||
def _memory(self, value):
|
|
||||||
from .memory import DirtyByteArray
|
|
||||||
self.regions["user"].data = DirtyByteArray(value) if not isinstance(value, DirtyByteArray) else value
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _generate_uid(cls) -> bytes:
|
|
||||||
"""Generate a random valid UID with the correct prefix for this IC type."""
|
|
||||||
prefix = cls._uid_prefix
|
|
||||||
random_bytes = os.urandom(8 - len(prefix))
|
|
||||||
return prefix + random_bytes
|
|
||||||
|
|
||||||
def decode_trace(self, direction: int, payload: bytes) -> str | None:
|
|
||||||
return decode_15693(direction, payload)
|
|
||||||
|
|
||||||
def set_dsfid(self, dsfid: int) -> None:
|
|
||||||
"""Change DSFID. Call sync() to push."""
|
|
||||||
self._dsfid = dsfid
|
|
||||||
self._uid_dirty = True # reuse uid_dirty to push header
|
|
||||||
|
|
||||||
def set_afi(self, afi: int) -> None:
|
|
||||||
"""Change AFI (Application Family Identifier). Call sync() to push."""
|
|
||||||
self._afi = afi
|
|
||||||
self._uid_dirty = True
|
|
||||||
|
|
||||||
def _sync_uid(self) -> None:
|
|
||||||
"""Write tag header (UID, DSFID, AFI, config) to firmware EML offset 0."""
|
|
||||||
from ..core.transport import encode_ng_frame
|
|
||||||
from ..core.protocol import Cmd
|
|
||||||
header = bytearray(15)
|
|
||||||
header[0:8] = self._uid[::-1] # UID reversed
|
|
||||||
header[8] = self._dsfid
|
|
||||||
header[9] = 0 # dsfidLock
|
|
||||||
header[10] = self._afi
|
|
||||||
header[11] = 0 # afiLock
|
|
||||||
header[12] = self._block_size
|
|
||||||
header[13] = self._num_blocks
|
|
||||||
header[14] = self._ic_reference
|
|
||||||
payload = struct.pack("<IH", 0, 15) + header
|
|
||||||
frame = encode_ng_frame(Cmd.HF_ISO15693_EML_SETMEM, payload)
|
|
||||||
self._serial.write(frame)
|
|
||||||
|
|
||||||
async def power_on(self) -> None:
|
|
||||||
self._state = State15693.READY
|
|
||||||
|
|
||||||
async def power_off(self) -> None:
|
|
||||||
self._state = State15693.POWER_OFF
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state(self) -> str:
|
|
||||||
return self._state.name
|
|
||||||
|
|
||||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
if len(frame.data) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
flags = frame.data[0]
|
|
||||||
cmd = frame.data[1]
|
|
||||||
|
|
||||||
# Check if this is an addressed command and verify UID
|
|
||||||
is_inventory = bool(flags & FLAG_INVENTORY)
|
|
||||||
if not is_inventory and (flags & FLAG_ADDRESS):
|
|
||||||
if len(frame.data) < 10:
|
|
||||||
return None
|
|
||||||
frame_uid = bytes(reversed(frame.data[2:10]))
|
|
||||||
if frame_uid != self._uid:
|
|
||||||
return None
|
|
||||||
|
|
||||||
match cmd:
|
|
||||||
case 0x01: # Inventory
|
|
||||||
if self._state == State15693.QUIET:
|
|
||||||
return None
|
|
||||||
return self._handle_inventory(frame)
|
|
||||||
case 0x02: # Stay Quiet
|
|
||||||
self._state = State15693.QUIET
|
|
||||||
return None # no response per spec
|
|
||||||
case 0x20: # Read Single Block
|
|
||||||
return self._handle_read_single(frame)
|
|
||||||
case 0x21: # Write Single Block
|
|
||||||
return self._handle_write_single(frame)
|
|
||||||
case 0x26: # Reset to Ready
|
|
||||||
self._state = State15693.READY
|
|
||||||
return self._make_response(b"")
|
|
||||||
case 0x2B: # Get System Information
|
|
||||||
return self._handle_system_info(frame)
|
|
||||||
case 0x30: # Extended Read Single Block
|
|
||||||
return self._handle_extended_read_single(frame)
|
|
||||||
case 0x31: # Extended Write Single Block
|
|
||||||
return self._handle_extended_write_single(frame)
|
|
||||||
case 0x33: # Extended Read Multiple Blocks
|
|
||||||
return self._handle_extended_read_multiple(frame)
|
|
||||||
case _ if cmd >= 0xA0:
|
|
||||||
return self._handle_custom_command(cmd, frame)
|
|
||||||
case _:
|
|
||||||
return self._make_error(0x01) # not supported
|
|
||||||
|
|
||||||
def _handle_inventory(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Handle inventory command. Returns flags + DSFID + UID.
|
|
||||||
|
|
||||||
Supports mask filtering: if mask_len > 0, only respond if our UID
|
|
||||||
matches the mask bits (compared LSB-first from UID byte 0).
|
|
||||||
"""
|
|
||||||
if len(frame.data) < 3:
|
|
||||||
return None
|
|
||||||
|
|
||||||
mask_len = frame.data[2] # number of mask bits
|
|
||||||
if mask_len > 0:
|
|
||||||
# Mask bytes follow mask_len
|
|
||||||
mask_bytes_count = (mask_len + 7) // 8
|
|
||||||
if len(frame.data) < 3 + mask_bytes_count:
|
|
||||||
return None
|
|
||||||
mask_data = frame.data[3:3 + mask_bytes_count]
|
|
||||||
uid_lsb = self._uid[::-1] # UID in LSB-first order
|
|
||||||
|
|
||||||
# Compare mask bytes against UID (byte-level comparison)
|
|
||||||
# Full bytes must match exactly, partial last byte checked bit-by-bit
|
|
||||||
full_bytes = mask_len // 8
|
|
||||||
for i in range(full_bytes):
|
|
||||||
if i < len(mask_data) and i < len(uid_lsb):
|
|
||||||
if mask_data[i] != uid_lsb[i]:
|
|
||||||
return None
|
|
||||||
# Check remaining bits in partial byte (MSB-first within byte
|
|
||||||
# to match bitarray ordering used by collision resolution)
|
|
||||||
remaining_bits = mask_len % 8
|
|
||||||
if remaining_bits > 0 and full_bytes < len(mask_data) and full_bytes < len(uid_lsb):
|
|
||||||
# Mask the relevant bits (MSB-first: top N bits of byte)
|
|
||||||
shift = 8 - remaining_bits
|
|
||||||
mask_val = mask_data[full_bytes] >> shift
|
|
||||||
uid_val = uid_lsb[full_bytes] >> shift
|
|
||||||
if mask_val != uid_val:
|
|
||||||
return None
|
|
||||||
|
|
||||||
resp = bytes([0x00, self._dsfid]) + self._uid[::-1]
|
|
||||||
return RFFrame.from_bytes(resp)
|
|
||||||
|
|
||||||
def _handle_read_single(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Handle Read Single Block."""
|
|
||||||
block = self._extract_block_number(frame)
|
|
||||||
if block is None:
|
|
||||||
return None
|
|
||||||
if block >= self._num_blocks:
|
|
||||||
return self._make_error(0x10) # block not available
|
|
||||||
|
|
||||||
offset = block * self._block_size
|
|
||||||
data = self._memory[offset:offset + self._block_size]
|
|
||||||
return self._make_response(bytes(data))
|
|
||||||
|
|
||||||
def _handle_write_single(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Handle Write Single Block."""
|
|
||||||
flags = frame.data[0]
|
|
||||||
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
|
|
||||||
|
|
||||||
if is_addressed:
|
|
||||||
block_offset = 10 # flags + cmd + UID(8)
|
|
||||||
else:
|
|
||||||
block_offset = 2 # flags + cmd
|
|
||||||
|
|
||||||
if len(frame.data) <= block_offset:
|
|
||||||
return None
|
|
||||||
block = frame.data[block_offset]
|
|
||||||
if block >= self._num_blocks:
|
|
||||||
return self._make_error(0x10)
|
|
||||||
|
|
||||||
data_offset = block_offset + 1
|
|
||||||
data = frame.data[data_offset:data_offset + self._block_size]
|
|
||||||
if len(data) < self._block_size:
|
|
||||||
return self._make_error(0x0F) # unknown error
|
|
||||||
|
|
||||||
offset = block * self._block_size
|
|
||||||
|
|
||||||
# Check for special write modes (e.g. counter)
|
|
||||||
access = self.regions["user"].access_for_block(block)
|
|
||||||
if access.write_mode == "counter":
|
|
||||||
import struct as _st
|
|
||||||
increment = _st.unpack_from("<H", data, 0)[0]
|
|
||||||
current = _st.unpack_from("<H", self._memory, offset)[0]
|
|
||||||
new_val = min(current + increment, 0xFFFF) # saturate
|
|
||||||
self._memory[offset:offset + 2] = _st.pack("<H", new_val)
|
|
||||||
elif access.write_mode == "counter24":
|
|
||||||
value = int.from_bytes(data[0:3], 'little')
|
|
||||||
if value == 1:
|
|
||||||
# Increment current counter by 1
|
|
||||||
current = int.from_bytes(self._memory[offset:offset + 3], 'little')
|
|
||||||
new_val = min(current + 1, 0xFFFFFF)
|
|
||||||
else:
|
|
||||||
# Preset (overwrite) counter to new value
|
|
||||||
new_val = value
|
|
||||||
self._memory[offset:offset + 3] = new_val.to_bytes(3, 'little')
|
|
||||||
# Write PROT byte (data[3]) to memory
|
|
||||||
self._memory[offset + 3] = data[3]
|
|
||||||
else:
|
|
||||||
self._memory[offset:offset + self._block_size] = data
|
|
||||||
|
|
||||||
return self._make_response(b"")
|
|
||||||
|
|
||||||
def _handle_system_info(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Handle Get System Information."""
|
|
||||||
info_flags = SYSINFO_DSFID | SYSINFO_AFI | SYSINFO_MEMSIZE | SYSINFO_ICREF
|
|
||||||
resp = bytes([0x00, info_flags])
|
|
||||||
resp += self._uid[::-1] # UID LSB first
|
|
||||||
resp += bytes([self._dsfid]) # DSFID
|
|
||||||
resp += bytes([self._afi]) # AFI
|
|
||||||
resp += bytes([self._num_blocks - 1, self._block_size - 1]) # mem size
|
|
||||||
resp += bytes([self._ic_reference]) # IC reference
|
|
||||||
return RFFrame.from_bytes(resp)
|
|
||||||
|
|
||||||
def _extract_block_number(self, frame: RFFrame) -> int | None:
|
|
||||||
"""Extract block number from frame, accounting for addressed mode."""
|
|
||||||
flags = frame.data[0]
|
|
||||||
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
|
|
||||||
if is_addressed:
|
|
||||||
if len(frame.data) < 11:
|
|
||||||
return None
|
|
||||||
return frame.data[10]
|
|
||||||
else:
|
|
||||||
if len(frame.data) < 3:
|
|
||||||
return None
|
|
||||||
return frame.data[2]
|
|
||||||
|
|
||||||
def _extract_block_number_ext(self, frame: RFFrame) -> int | None:
|
|
||||||
"""Extract 2-byte (LE16) block number for extended commands."""
|
|
||||||
flags = frame.data[0]
|
|
||||||
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
|
|
||||||
offset = 10 if is_addressed else 2
|
|
||||||
if len(frame.data) < offset + 2:
|
|
||||||
return None
|
|
||||||
return struct.unpack_from("<H", frame.data, offset)[0]
|
|
||||||
|
|
||||||
def _handle_extended_read_single(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Handle Extended Read Single Block (0x30) — 2-byte block address."""
|
|
||||||
block = self._extract_block_number_ext(frame)
|
|
||||||
if block is None:
|
|
||||||
return None
|
|
||||||
if block >= self._num_blocks:
|
|
||||||
return self._make_error(0x10) # block not available
|
|
||||||
|
|
||||||
offset = block * self._block_size
|
|
||||||
data = self._memory[offset:offset + self._block_size]
|
|
||||||
return self._make_response(bytes(data))
|
|
||||||
|
|
||||||
def _handle_extended_write_single(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Handle Extended Write Single Block (0x31) — 2-byte block address."""
|
|
||||||
flags = frame.data[0]
|
|
||||||
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
|
|
||||||
block_offset = 10 if is_addressed else 2
|
|
||||||
|
|
||||||
if len(frame.data) < block_offset + 2:
|
|
||||||
return None
|
|
||||||
block = struct.unpack_from("<H", frame.data, block_offset)[0]
|
|
||||||
if block >= self._num_blocks:
|
|
||||||
return self._make_error(0x10)
|
|
||||||
|
|
||||||
data_offset = block_offset + 2
|
|
||||||
data = frame.data[data_offset:data_offset + self._block_size]
|
|
||||||
if len(data) < self._block_size:
|
|
||||||
return self._make_error(0x0F)
|
|
||||||
|
|
||||||
mem_offset = block * self._block_size
|
|
||||||
self._memory[mem_offset:mem_offset + self._block_size] = data
|
|
||||||
return self._make_response(b"")
|
|
||||||
|
|
||||||
def _handle_extended_read_multiple(self, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Handle Extended Read Multiple Blocks (0x33) — 2-byte block address + count."""
|
|
||||||
flags = frame.data[0]
|
|
||||||
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
|
|
||||||
offset = 10 if is_addressed else 2
|
|
||||||
|
|
||||||
if len(frame.data) < offset + 4:
|
|
||||||
return None
|
|
||||||
block = struct.unpack_from("<H", frame.data, offset)[0]
|
|
||||||
num_blocks = struct.unpack_from("<H", frame.data, offset + 2)[0] + 1
|
|
||||||
|
|
||||||
if block + num_blocks > self._num_blocks:
|
|
||||||
return self._make_error(0x10)
|
|
||||||
|
|
||||||
result = bytearray()
|
|
||||||
for i in range(num_blocks):
|
|
||||||
b = block + i
|
|
||||||
mem_off = b * self._block_size
|
|
||||||
result.extend(self._memory[mem_off:mem_off + self._block_size])
|
|
||||||
return self._make_response(bytes(result))
|
|
||||||
|
|
||||||
def _make_response(self, data: bytes) -> RFFrame:
|
|
||||||
"""Build a success response frame."""
|
|
||||||
return RFFrame.from_bytes(bytes([0x00]) + data)
|
|
||||||
|
|
||||||
def _make_error(self, error_code: int) -> RFFrame:
|
|
||||||
"""Build an error response frame."""
|
|
||||||
return RFFrame.from_bytes(bytes([RESP_ERROR, error_code]))
|
|
||||||
|
|
||||||
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
|
|
||||||
"""Extension point for vendor-specific commands (0xA0-0xDF).
|
|
||||||
Override in subclasses (e.g., NxpIcodeTag).
|
|
||||||
"""
|
|
||||||
return self._make_error(0x01) # not supported
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Reader15693 ----
|
|
||||||
|
|
||||||
class Reader15693(Reader):
|
|
||||||
"""ISO 15693 reader with inventory and block operations."""
|
|
||||||
|
|
||||||
async def run(self) -> dict:
|
|
||||||
tags = await self.inventory()
|
|
||||||
return {"tags": tags}
|
|
||||||
|
|
||||||
async def inventory(self, slots: int = 1) -> list[dict]:
|
|
||||||
"""Perform inventory. Returns list of {uid, dsfid}.
|
|
||||||
|
|
||||||
Uses 1-slot inventory with mask-based collision resolution:
|
|
||||||
when collision is detected, narrows the mask to isolate tags.
|
|
||||||
"""
|
|
||||||
tags: list[dict] = []
|
|
||||||
await self._inventory_recursive(tags, mask=b"", mask_bits=0)
|
|
||||||
return tags
|
|
||||||
|
|
||||||
async def _inventory_recursive(self, tags: list[dict],
|
|
||||||
mask: bytes, mask_bits: int,
|
|
||||||
depth: int = 0) -> None:
|
|
||||||
"""Recursive inventory with mask narrowing on collision."""
|
|
||||||
if depth > 64: # max 64 UID bits
|
|
||||||
return
|
|
||||||
|
|
||||||
inv_flags = 0x26 # high data rate + inventory + 1 slot
|
|
||||||
mask_bytes_count = (mask_bits + 7) // 8
|
|
||||||
inv = bytes([inv_flags, CMD_INVENTORY, mask_bits]) + mask[:mask_bytes_count]
|
|
||||||
await self._medium.transmit_reader(RFFrame.from_bytes(inv))
|
|
||||||
resp = await self._medium.receive_reader()
|
|
||||||
|
|
||||||
if resp is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if resp.has_collision and resp.collision_positions:
|
|
||||||
# Collision: find first collision bit in UID portion
|
|
||||||
# UID starts at byte 2 of response (bit 16 in MSB bitarray)
|
|
||||||
uid_bit_offset = 16 # flags(8) + dsfid(8)
|
|
||||||
col_in_uid = None
|
|
||||||
for pos in resp.collision_positions:
|
|
||||||
if pos >= uid_bit_offset:
|
|
||||||
col_in_uid = pos - uid_bit_offset
|
|
||||||
break
|
|
||||||
|
|
||||||
if col_in_uid is not None:
|
|
||||||
# Try both values at the collision bit
|
|
||||||
# Mask includes bits 0..col_in_uid (inclusive)
|
|
||||||
new_mask_bits = col_in_uid + 1
|
|
||||||
mask_byte_count = (new_mask_bits + 7) // 8
|
|
||||||
from bitarray import bitarray as ba
|
|
||||||
resp_bits = ba(resp.bits[:resp.bit_count])
|
|
||||||
|
|
||||||
for bit_val in (0, 1):
|
|
||||||
bits_copy = ba(resp_bits)
|
|
||||||
bits_copy[uid_bit_offset + col_in_uid] = bit_val
|
|
||||||
uid_bytes = bits_copy[uid_bit_offset:uid_bit_offset + mask_byte_count * 8].tobytes()
|
|
||||||
await self._inventory_recursive(tags, uid_bytes, new_mask_bits, depth + 1)
|
|
||||||
return
|
|
||||||
|
|
||||||
if len(resp.data) >= 10 and resp.data[0] == 0x00:
|
|
||||||
uid = bytes(reversed(resp.data[2:10]))
|
|
||||||
dsfid = resp.data[1]
|
|
||||||
# Avoid duplicates
|
|
||||||
if not any(t["uid"] == uid for t in tags):
|
|
||||||
tags.append({"uid": uid, "dsfid": dsfid})
|
|
||||||
|
|
||||||
async def read_block(self, uid: bytes, block: int) -> dict:
|
|
||||||
"""Read a single block (addressed mode)."""
|
|
||||||
cmd = bytes([0x22, CMD_READ_SINGLE]) + uid[::-1] + bytes([block])
|
|
||||||
await self._medium.transmit_reader(RFFrame.from_bytes(cmd))
|
|
||||||
resp = await self._medium.receive_reader()
|
|
||||||
if resp is None or resp.data[0] & RESP_ERROR:
|
|
||||||
return {"success": False, "data": None}
|
|
||||||
return {"success": True, "data": resp.data[1:]}
|
|
||||||
|
|
||||||
async def write_block(self, uid: bytes, block: int, data: bytes) -> dict:
|
|
||||||
"""Write a single block (addressed mode)."""
|
|
||||||
cmd = bytes([0x22, CMD_WRITE_SINGLE]) + uid[::-1] + bytes([block]) + data
|
|
||||||
await self._medium.transmit_reader(RFFrame.from_bytes(cmd))
|
|
||||||
resp = await self._medium.receive_reader()
|
|
||||||
if resp is None or resp.data[0] & RESP_ERROR:
|
|
||||||
return {"success": False}
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
async def system_info(self, uid: bytes) -> dict:
|
|
||||||
"""Get System Information (addressed mode)."""
|
|
||||||
cmd = bytes([0x22, CMD_SYSTEM_INFO]) + uid[::-1]
|
|
||||||
await self._medium.transmit_reader(RFFrame.from_bytes(cmd))
|
|
||||||
resp = await self._medium.receive_reader()
|
|
||||||
if resp is None or resp.data[0] & RESP_ERROR:
|
|
||||||
return {"uid": None}
|
|
||||||
|
|
||||||
data = resp.data
|
|
||||||
info_flags = data[1]
|
|
||||||
resp_uid = bytes(reversed(data[2:10]))
|
|
||||||
idx = 10
|
|
||||||
dsfid = data[idx] if info_flags & SYSINFO_DSFID else 0
|
|
||||||
idx += 1 if info_flags & SYSINFO_DSFID else 0
|
|
||||||
afi = data[idx] if info_flags & SYSINFO_AFI else 0
|
|
||||||
idx += 1 if info_flags & SYSINFO_AFI else 0
|
|
||||||
num_blocks = (data[idx] + 1) if info_flags & SYSINFO_MEMSIZE else 0
|
|
||||||
block_size = (data[idx + 1] + 1) if info_flags & SYSINFO_MEMSIZE else 0
|
|
||||||
idx += 2 if info_flags & SYSINFO_MEMSIZE else 0
|
|
||||||
ic_ref = data[idx] if info_flags & SYSINFO_ICREF else 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"uid": resp_uid,
|
|
||||||
"dsfid": dsfid,
|
|
||||||
"afi": afi,
|
|
||||||
"num_blocks": num_blocks,
|
|
||||||
"block_size": block_size,
|
|
||||||
"ic_reference": ic_ref,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,69 +1,2 @@
|
|||||||
"""LF transponder and reader base classes."""
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
from __future__ import annotations
|
from pm3py.transponders.lf.base import * # noqa: F401,F403
|
||||||
|
|
||||||
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}
|
|
||||||
|
|||||||
@@ -1,358 +1,2 @@
|
|||||||
"""MIFARE Classic transponder and reader models with Crypto-1 authentication."""
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
from __future__ import annotations
|
from pm3py.transponders.hf.iso14443a.nxp.mifare_classic import * # noqa: F401,F403
|
||||||
|
|
||||||
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}
|
|
||||||
|
|||||||
@@ -1,282 +1,2 @@
|
|||||||
"""NFC Forum Type 2 and Type 4 tag models with NDEF support."""
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
from __future__ import annotations
|
from pm3py.transponders.hf.iso14443a.ndef import * # noqa: F401,F403
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,23 +1,2 @@
|
|||||||
"""NTAG 5 Boost (NTA5332) — compact I2C bridge with ALM and AES.
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
|
from pm3py.transponders.hf.iso15693.nxp.ntag5_boost import * # noqa: F401,F403
|
||||||
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)
|
|
||||||
|
|||||||
@@ -1,261 +1,2 @@
|
|||||||
"""NTAG 5 Link (NTP53x2) — NXP dual-interface NFC + I2C bridge.
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
|
from pm3py.transponders.hf.iso15693.nxp.ntag5_link import * # noqa: F401,F403
|
||||||
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]))
|
|
||||||
|
|||||||
@@ -1,446 +1,2 @@
|
|||||||
"""NTAG 5 platform base — shared across Switch, Link, and Boost.
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
|
from pm3py.transponders.hf.iso15693.nxp.ntag5_platform import * # noqa: F401,F403
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,21 +1,2 @@
|
|||||||
"""NTAG 5 Switch (NTP5210) — NFC PWM/GPIO bridge, no I2C.
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
|
from pm3py.transponders.hf.iso15693.nxp.ntag5_switch import * # noqa: F401,F403
|
||||||
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)
|
|
||||||
|
|||||||
@@ -1,86 +1,2 @@
|
|||||||
"""NXP ICODE/NTAG5 platform — shared NXP custom commands."""
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
from __future__ import annotations
|
from pm3py.transponders.hf.iso15693.nxp.nxp_icode import * # noqa: F401,F403
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,163 +1,2 @@
|
|||||||
"""T5577/ATA5577 — programmable LF emulator transponder."""
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
from __future__ import annotations
|
from pm3py.transponders.lf.atmel.t5577 import * # noqa: F401,F403
|
||||||
|
|
||||||
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}
|
|
||||||
|
|||||||
@@ -1,150 +1,2 @@
|
|||||||
"""NFC Forum Type 5 Tag — extends ISO 15693 with NDEF support."""
|
"""Backward compat shim — moved to pm3py.transponders."""
|
||||||
from __future__ import annotations
|
from pm3py.transponders.hf.iso15693.type5 import * # noqa: F401,F403
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
1
pm3py/transponders/hf/iso14443a/__init__.py
Normal file
1
pm3py/transponders/hf/iso14443a/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""ISO 14443-A transponders (Layer 3 + Layer 4)."""
|
||||||
394
pm3py/transponders/hf/iso14443a/base.py
Normal file
394
pm3py/transponders/hf/iso14443a/base.py
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
"""ISO 14443-A transponder and reader state machines."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
from pm3py.sim.frame import RFFrame
|
||||||
|
from pm3py.sim.medium import Medium
|
||||||
|
from pm3py.sim.trace_fmt import decode_14443a
|
||||||
|
from pm3py.sim.transponder import Transponder
|
||||||
|
from pm3py.sim.reader import Reader
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"REQA", "WUPA", "HLTA", "CL1", "CL2", "CL3", "CT", "RATS_CMD",
|
||||||
|
"NVB_ANTICOL", "NVB_SELECT", "CL_MAP",
|
||||||
|
"State14443A", "Tag14443A", "Tag14443A_3", "Tag14443A_4", "Reader14443A",
|
||||||
|
"_compute_bcc", "_split_uid_cascades",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---- Constants ----
|
||||||
|
|
||||||
|
REQA = 0x26
|
||||||
|
WUPA = 0x52
|
||||||
|
HLTA = 0x50
|
||||||
|
CL1 = 0x93
|
||||||
|
CL2 = 0x95
|
||||||
|
CL3 = 0x97
|
||||||
|
CT = 0x88 # Cascade Tag
|
||||||
|
RATS_CMD = 0xE0
|
||||||
|
|
||||||
|
NVB_ANTICOL = 0x20 # 2 bytes valid (SEL + NVB only)
|
||||||
|
NVB_SELECT = 0x70 # 7 bytes valid (full SELECT)
|
||||||
|
|
||||||
|
CL_MAP = {CL1: 0, CL2: 1, CL3: 2}
|
||||||
|
|
||||||
|
|
||||||
|
class State14443A(IntEnum):
|
||||||
|
IDLE = 0
|
||||||
|
READY = 1
|
||||||
|
ACTIVE = 2
|
||||||
|
HALT = 3
|
||||||
|
PROTOCOL = 4 # Layer 4 (ISO-DEP) active
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_bcc(data: bytes) -> int:
|
||||||
|
r = 0
|
||||||
|
for b in data:
|
||||||
|
r ^= b
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _split_uid_cascades(uid: bytes) -> list[bytes]:
|
||||||
|
"""Split UID into cascade-level chunks of 4 bytes each."""
|
||||||
|
if len(uid) == 4:
|
||||||
|
return [uid]
|
||||||
|
elif len(uid) == 7:
|
||||||
|
return [bytes([CT]) + uid[0:3], uid[3:7]]
|
||||||
|
elif len(uid) == 10:
|
||||||
|
return [bytes([CT]) + uid[0:3], bytes([CT]) + uid[3:6], uid[6:10]]
|
||||||
|
else:
|
||||||
|
raise ValueError(f"UID must be 4, 7, or 10 bytes, got {len(uid)}")
|
||||||
|
|
||||||
|
|
||||||
|
class Tag14443A(Transponder):
|
||||||
|
"""ISO 14443-A Part 2+3 base: anticollision + selection."""
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes | str, atqa: bytes = b"\x04\x00", sak: int = 0x08):
|
||||||
|
super().__init__()
|
||||||
|
uid = self._parse_uid(uid)
|
||||||
|
if len(uid) not in (4, 7, 10):
|
||||||
|
raise ValueError(f"UID must be 4, 7, or 10 bytes, got {len(uid)}")
|
||||||
|
self._uid = uid
|
||||||
|
self._atqa = atqa
|
||||||
|
self._sak = sak
|
||||||
|
self._state = State14443A.IDLE
|
||||||
|
self._cascades = _split_uid_cascades(uid)
|
||||||
|
self._current_cl = 0
|
||||||
|
|
||||||
|
def decode_trace(self, direction: int, payload: bytes) -> str | None:
|
||||||
|
return decode_14443a(direction, payload)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def atqa(self) -> bytes:
|
||||||
|
return self._atqa
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sak(self) -> int:
|
||||||
|
return self._sak
|
||||||
|
|
||||||
|
def _cascade_entries(self) -> list[tuple[int, bytes]]:
|
||||||
|
"""Return (sel_byte, uid_chunk) for each cascade level."""
|
||||||
|
sels = [CL1, CL2, CL3]
|
||||||
|
return list(zip(sels[:len(self._cascades)], self._cascades))
|
||||||
|
|
||||||
|
async def power_on(self) -> None:
|
||||||
|
self._state = State14443A.IDLE
|
||||||
|
self._current_cl = 0
|
||||||
|
|
||||||
|
async def power_off(self) -> None:
|
||||||
|
self._state = State14443A.IDLE
|
||||||
|
self._current_cl = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> str:
|
||||||
|
return self._state.name
|
||||||
|
|
||||||
|
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if not frame.data:
|
||||||
|
return None
|
||||||
|
cmd = frame.data[0]
|
||||||
|
|
||||||
|
match self._state:
|
||||||
|
case State14443A.IDLE:
|
||||||
|
if cmd == REQA or cmd == WUPA:
|
||||||
|
self._state = State14443A.READY
|
||||||
|
self._current_cl = 0
|
||||||
|
return RFFrame.from_bytes(self._atqa)
|
||||||
|
return None
|
||||||
|
|
||||||
|
case State14443A.READY:
|
||||||
|
if cmd == REQA or cmd == WUPA:
|
||||||
|
self._current_cl = 0
|
||||||
|
return RFFrame.from_bytes(self._atqa)
|
||||||
|
return self._handle_anticollision(frame)
|
||||||
|
|
||||||
|
case State14443A.ACTIVE:
|
||||||
|
# Application handler gets first chance (e.g., MIFARE auth response)
|
||||||
|
app_result = self._handle_application(frame)
|
||||||
|
if app_result is not None:
|
||||||
|
return app_result
|
||||||
|
if cmd == HLTA and len(frame.data) >= 2:
|
||||||
|
self._state = State14443A.HALT
|
||||||
|
return None
|
||||||
|
if cmd == REQA or cmd == WUPA:
|
||||||
|
# Re-selection: reset to READY
|
||||||
|
self._state = State14443A.READY
|
||||||
|
self._current_cl = 0
|
||||||
|
return RFFrame.from_bytes(self._atqa)
|
||||||
|
if cmd == RATS_CMD:
|
||||||
|
return self._handle_rats(frame)
|
||||||
|
return None
|
||||||
|
|
||||||
|
case State14443A.HALT:
|
||||||
|
if cmd == WUPA:
|
||||||
|
self._state = State14443A.READY
|
||||||
|
self._current_cl = 0
|
||||||
|
return RFFrame.from_bytes(self._atqa)
|
||||||
|
return None
|
||||||
|
|
||||||
|
case State14443A.PROTOCOL:
|
||||||
|
if cmd == REQA or cmd == WUPA:
|
||||||
|
self._state = State14443A.READY
|
||||||
|
self._current_cl = 0
|
||||||
|
return RFFrame.from_bytes(self._atqa)
|
||||||
|
return self._handle_layer4(frame)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_anticollision(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if len(frame.data) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sel = frame.data[0]
|
||||||
|
nvb = frame.data[1]
|
||||||
|
|
||||||
|
if sel not in CL_MAP:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cl_index = CL_MAP[sel]
|
||||||
|
if cl_index != self._current_cl:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cascade_uid = self._cascades[cl_index]
|
||||||
|
uid_bcc = cascade_uid + bytes([_compute_bcc(cascade_uid)])
|
||||||
|
|
||||||
|
if nvb == NVB_SELECT and len(frame.data) >= 7:
|
||||||
|
# Full SELECT
|
||||||
|
incoming_uid = frame.data[2:6]
|
||||||
|
if incoming_uid == cascade_uid:
|
||||||
|
self._current_cl += 1
|
||||||
|
if self._current_cl >= len(self._cascades):
|
||||||
|
self._state = State14443A.ACTIVE
|
||||||
|
return RFFrame.from_bytes(bytes([self._sak]))
|
||||||
|
else:
|
||||||
|
return RFFrame.from_bytes(bytes([0x04]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ANTICOLLISION with known bytes
|
||||||
|
known_bytes = (nvb >> 4) - 2 # subtract SEL + NVB bytes
|
||||||
|
if known_bytes < 0:
|
||||||
|
known_bytes = 0
|
||||||
|
|
||||||
|
# Check if our UID matches the known bytes
|
||||||
|
if known_bytes > 0 and len(frame.data) > 2:
|
||||||
|
known_data = frame.data[2:2 + known_bytes]
|
||||||
|
for i in range(min(known_bytes, len(uid_bcc))):
|
||||||
|
if i < len(known_data) and known_data[i] != uid_bcc[i]:
|
||||||
|
return None # doesn't match
|
||||||
|
|
||||||
|
return RFFrame.from_bytes(uid_bcc)
|
||||||
|
|
||||||
|
def _handle_rats(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_layer4(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Tag14443A_3(Tag14443A):
|
||||||
|
"""ISO 14443-A Part 3 only — no ISO-DEP. SAK bit 5 = 0."""
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes, atqa: bytes = b"\x04\x00", sak: int = 0x08):
|
||||||
|
super().__init__(uid=uid, atqa=atqa, sak=sak & ~0x20)
|
||||||
|
|
||||||
|
|
||||||
|
class Tag14443A_4(Tag14443A):
|
||||||
|
"""ISO 14443-A Part 3+4 — ISO-DEP capable. SAK bit 5 = 1."""
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes, atqa: bytes = b"\x04\x00", sak: int = 0x20,
|
||||||
|
ats: bytes = b"\x05\x78\x80\x70\x02"):
|
||||||
|
super().__init__(uid=uid, atqa=atqa, sak=sak | 0x20)
|
||||||
|
self._ats = ats
|
||||||
|
self._block_number = 0
|
||||||
|
|
||||||
|
def _handle_rats(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
self._state = State14443A.PROTOCOL
|
||||||
|
self._block_number = 0
|
||||||
|
return RFFrame.from_bytes(self._ats)
|
||||||
|
|
||||||
|
def _handle_layer4(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if not frame.data:
|
||||||
|
return None
|
||||||
|
pcb = frame.data[0]
|
||||||
|
if pcb & 0xC0 == 0x00: # I-block
|
||||||
|
payload = frame.data[1:]
|
||||||
|
response_payload = self._handle_apdu(payload)
|
||||||
|
resp_pcb = 0x02 if (pcb & 0x01) == 0 else 0x03
|
||||||
|
self._block_number = resp_pcb & 0x01
|
||||||
|
return RFFrame.from_bytes(bytes([resp_pcb]) + response_payload)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_apdu(self, apdu: bytes) -> bytes:
|
||||||
|
"""Override in subclasses. Default: 6A82 (file not found)."""
|
||||||
|
return b"\x6A\x82"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Reader14443A ----
|
||||||
|
|
||||||
|
class Reader14443A(Reader):
|
||||||
|
"""ISO 14443-A reader with full anticollision tree walk."""
|
||||||
|
|
||||||
|
async def run(self) -> dict:
|
||||||
|
uids = await self.inventory()
|
||||||
|
return {"uids": uids}
|
||||||
|
|
||||||
|
async def inventory(self) -> list[bytes]:
|
||||||
|
"""Perform full anticollision, return all UIDs found."""
|
||||||
|
uids: list[bytes] = []
|
||||||
|
while True:
|
||||||
|
uid = await self._select_one_tag()
|
||||||
|
if uid is None:
|
||||||
|
break
|
||||||
|
uids.append(uid)
|
||||||
|
# HALT so this tag won't respond to REQA
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_hex("5000"))
|
||||||
|
await self._medium.receive_reader()
|
||||||
|
return uids
|
||||||
|
|
||||||
|
async def _select_one_tag(self) -> bytes | None:
|
||||||
|
"""Wake tags with REQA, resolve collisions, return one full UID."""
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(bytes([REQA])))
|
||||||
|
atqa = await self._medium.receive_reader()
|
||||||
|
if atqa is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
uid_acc = b""
|
||||||
|
for sel in (CL1, CL2, CL3):
|
||||||
|
result = await self._anticol_select(sel)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
sak = result["sak"]
|
||||||
|
cl_uid = result["uid"]
|
||||||
|
|
||||||
|
if sak & 0x04:
|
||||||
|
uid_acc += cl_uid[1:4] if cl_uid[0] == CT else cl_uid
|
||||||
|
else:
|
||||||
|
return uid_acc + cl_uid
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _anticol_select(self, sel: int,
|
||||||
|
known_bytes: bytes = b"") -> dict | None:
|
||||||
|
"""Anticollision + SELECT for one cascade level.
|
||||||
|
|
||||||
|
Uses byte-level collision resolution: when collision is detected
|
||||||
|
at byte N, try all 256 values for that byte position until one
|
||||||
|
produces a clean (no-collision) response.
|
||||||
|
"""
|
||||||
|
# Build ANTICOL command with known bytes
|
||||||
|
nvb_byte_count = len(known_bytes) + 2 # +2 for SEL + NVB
|
||||||
|
nvb = nvb_byte_count << 4 # no extra bits
|
||||||
|
anticol = bytes([sel, nvb]) + known_bytes
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(anticol))
|
||||||
|
resp = await self._medium.receive_reader()
|
||||||
|
if resp is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if resp.has_collision and resp.collision_positions:
|
||||||
|
# Find which response byte has the first collision
|
||||||
|
first_col = resp.collision_positions[0]
|
||||||
|
col_byte_idx = first_col // 8
|
||||||
|
|
||||||
|
if col_byte_idx >= 4:
|
||||||
|
# Collision only in BCC byte — use data bytes as-is
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Try each possible value for the collision byte
|
||||||
|
# Use values from the collision response as starting hints
|
||||||
|
resp_byte = resp.data[col_byte_idx] if col_byte_idx < len(resp.data) else 0
|
||||||
|
# Try the collision byte with bit set to 0 and 1 first, then others
|
||||||
|
from bitarray import bitarray as ba
|
||||||
|
resp_bits = ba(resp.bits[:resp.bit_count])
|
||||||
|
resp_bits[first_col] = 0
|
||||||
|
hint0 = resp_bits[col_byte_idx*8:(col_byte_idx+1)*8].tobytes()[0]
|
||||||
|
resp_bits[first_col] = 1
|
||||||
|
hint1 = resp_bits[col_byte_idx*8:(col_byte_idx+1)*8].tobytes()[0]
|
||||||
|
|
||||||
|
candidates = [hint0, hint1]
|
||||||
|
for candidate in candidates:
|
||||||
|
prefix = known_bytes[:col_byte_idx] + bytes([candidate])
|
||||||
|
if len(prefix) <= col_byte_idx:
|
||||||
|
# Need intermediate bytes from response
|
||||||
|
prefix = resp.data[:col_byte_idx] + bytes([candidate])
|
||||||
|
result = await self._anticol_select(sel, prefix)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
# No collision — we have a clean UID
|
||||||
|
if len(resp.data) < 5:
|
||||||
|
return None
|
||||||
|
uid_bytes = resp.data[0:4]
|
||||||
|
bcc = _compute_bcc(uid_bytes)
|
||||||
|
|
||||||
|
# SELECT
|
||||||
|
select_cmd = bytes([sel, NVB_SELECT]) + uid_bytes + bytes([bcc])
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(select_cmd))
|
||||||
|
sak_resp = await self._medium.receive_reader()
|
||||||
|
if sak_resp is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {"uid": uid_bytes, "sak": sak_resp.data[0]}
|
||||||
|
|
||||||
|
async def select_tag(self, uid: bytes) -> dict:
|
||||||
|
"""Full SELECT sequence for a known UID."""
|
||||||
|
cascades = _split_uid_cascades(uid)
|
||||||
|
sels = [CL1, CL2, CL3]
|
||||||
|
sak = 0
|
||||||
|
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(bytes([WUPA])))
|
||||||
|
await self._medium.receive_reader()
|
||||||
|
|
||||||
|
for i, (sel, cascade_uid) in enumerate(zip(sels, cascades)):
|
||||||
|
bcc = _compute_bcc(cascade_uid)
|
||||||
|
select_cmd = bytes([sel, NVB_SELECT]) + cascade_uid + bytes([bcc])
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(select_cmd))
|
||||||
|
resp = await self._medium.receive_reader()
|
||||||
|
if resp is None:
|
||||||
|
raise RuntimeError(f"No response to SELECT CL{i+1}")
|
||||||
|
sak = resp.data[0]
|
||||||
|
|
||||||
|
return {"uid": uid, "sak": sak}
|
||||||
|
|
||||||
|
async def rats(self, cid: int = 0) -> dict:
|
||||||
|
"""Send RATS, enter Layer 4. Returns ATS."""
|
||||||
|
fsd_cid = 0x50 | (cid & 0x0F)
|
||||||
|
rats_frame = RFFrame.from_bytes(bytes([RATS_CMD, fsd_cid]))
|
||||||
|
await self._medium.transmit_reader(rats_frame)
|
||||||
|
resp = await self._medium.receive_reader()
|
||||||
|
if resp is None:
|
||||||
|
raise RuntimeError("No response to RATS")
|
||||||
|
return {"ats": resp.data}
|
||||||
|
|
||||||
|
async def transceive_apdu(self, apdu: bytes, block_number: int = 0) -> bytes:
|
||||||
|
"""Send APDU via I-block, return response payload."""
|
||||||
|
pcb = 0x02 | (block_number & 0x01)
|
||||||
|
iblock = bytes([pcb]) + apdu
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(iblock))
|
||||||
|
resp = await self._medium.receive_reader()
|
||||||
|
if resp is None:
|
||||||
|
raise RuntimeError("No response to I-block")
|
||||||
|
return resp.data[1:]
|
||||||
282
pm3py/transponders/hf/iso14443a/ndef.py
Normal file
282
pm3py/transponders/hf/iso14443a/ndef.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
"""NFC Forum Type 2 and Type 4 tag models with NDEF support."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from 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
|
||||||
1
pm3py/transponders/hf/iso14443a/nxp/__init__.py
Normal file
1
pm3py/transponders/hf/iso14443a/nxp/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""NXP ISO 14443-A transponders (MIFARE Classic, DESFire)."""
|
||||||
103
pm3py/transponders/hf/iso14443a/nxp/crypto1.py
Normal file
103
pm3py/transponders/hf/iso14443a/nxp/crypto1.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Pure-Python Crypto-1 stream cipher for MIFARE Classic.
|
||||||
|
|
||||||
|
48-bit LFSR with non-linear filter function.
|
||||||
|
Feedback polynomial: x^48 + x^43 + x^39 + x^38 + x^36 + x^34 + x^33 + x^31 +
|
||||||
|
x^29 + x^24 + x^23 + x^21 + x^19 + x^13 + x^9 + x^7 + x^6 + x^5 + 1
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Feedback taps (bit positions, 0-indexed from LSB)
|
||||||
|
FEEDBACK_TAPS = (0, 5, 6, 7, 9, 13, 19, 21, 23, 24, 29, 31, 33, 34, 36, 38, 39, 43)
|
||||||
|
|
||||||
|
# Filter function lookup table (20-bit input → 1-bit output)
|
||||||
|
# The filter function f(x0..x3, x4..x7, ..., x16..x19) is built from
|
||||||
|
# layers of 4-input boolean functions.
|
||||||
|
_FA = 0x9E98 # f_a lookup
|
||||||
|
_FB = 0xB48E # f_b lookup
|
||||||
|
_FC = 0xEC57E80A # f_c lookup
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_bit(lfsr: int) -> int:
|
||||||
|
"""Compute the non-linear filter function output."""
|
||||||
|
# Extract 20 bits from specific LFSR positions
|
||||||
|
# Positions: 9,11,13,15,17,19,21,23,25,27,29,31,33,35,37,39,41,43,45,47
|
||||||
|
x = 0
|
||||||
|
for i, pos in enumerate((9, 11, 13, 15, 17, 19, 21, 23, 25, 27,
|
||||||
|
29, 31, 33, 35, 37, 39, 41, 43, 45, 47)):
|
||||||
|
x |= ((lfsr >> pos) & 1) << i
|
||||||
|
|
||||||
|
# Layer 1: four 4-input functions using _FA
|
||||||
|
a = (_FA >> (x & 0xF)) & 1
|
||||||
|
b = (_FA >> ((x >> 4) & 0xF)) & 1
|
||||||
|
c = (_FB >> ((x >> 8) & 0xF)) & 1
|
||||||
|
d = (_FB >> ((x >> 12) & 0xF)) & 1
|
||||||
|
e = (_FA >> ((x >> 16) & 0xF)) & 1
|
||||||
|
|
||||||
|
# Layer 2: combine with _FC
|
||||||
|
idx = a | (b << 1) | (c << 2) | (d << 3) | (e << 4)
|
||||||
|
return (_FC >> idx) & 1
|
||||||
|
|
||||||
|
|
||||||
|
def _feedback_bit(lfsr: int) -> int:
|
||||||
|
"""Compute LFSR feedback from tapped positions."""
|
||||||
|
fb = 0
|
||||||
|
for tap in FEEDBACK_TAPS:
|
||||||
|
fb ^= (lfsr >> tap) & 1
|
||||||
|
return fb
|
||||||
|
|
||||||
|
|
||||||
|
class Crypto1:
|
||||||
|
"""MIFARE Classic Crypto-1 stream cipher."""
|
||||||
|
|
||||||
|
def __init__(self, key: bytes):
|
||||||
|
self._lfsr: int = 0
|
||||||
|
self._load_key(key)
|
||||||
|
|
||||||
|
def _load_key(self, key: bytes) -> None:
|
||||||
|
"""Load 48-bit key into LFSR."""
|
||||||
|
# Key is loaded LSB first into the LFSR
|
||||||
|
key_int = int.from_bytes(key, "little")
|
||||||
|
self._lfsr = key_int & ((1 << 48) - 1)
|
||||||
|
|
||||||
|
def _clock(self, input_bit: int = 0, feedback: bool = True) -> int:
|
||||||
|
"""Clock the LFSR once. Returns filter output before clocking."""
|
||||||
|
out = _filter_bit(self._lfsr)
|
||||||
|
fb = _feedback_bit(self._lfsr) if feedback else 0
|
||||||
|
new_bit = fb ^ input_bit
|
||||||
|
self._lfsr = ((self._lfsr << 1) | new_bit) & ((1 << 48) - 1)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def generate_bit(self) -> int:
|
||||||
|
"""Generate one keystream bit."""
|
||||||
|
return self._clock(0, True)
|
||||||
|
|
||||||
|
def generate_byte(self) -> int:
|
||||||
|
"""Generate one keystream byte (MSB first)."""
|
||||||
|
val = 0
|
||||||
|
for i in range(8):
|
||||||
|
val = (val << 1) | self.generate_bit()
|
||||||
|
return val
|
||||||
|
|
||||||
|
def encrypt_bytes(self, data: bytes) -> bytes:
|
||||||
|
"""XOR data with keystream (encryption = decryption for stream cipher)."""
|
||||||
|
result = bytearray(len(data))
|
||||||
|
for i, b in enumerate(data):
|
||||||
|
result[i] = b ^ self.generate_byte()
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
def generate_nonce(self) -> bytes:
|
||||||
|
"""Generate a 4-byte tag nonce from LFSR state."""
|
||||||
|
# Use PRNG based on LFSR state
|
||||||
|
nt = struct.pack(">I", self._lfsr & 0xFFFFFFFF)
|
||||||
|
return nt
|
||||||
|
|
||||||
|
def init_auth(self, uid: int, nt: int) -> None:
|
||||||
|
"""Initialize cipher for authentication with uid XOR nt fed back."""
|
||||||
|
# Feed uid ^ nt into LFSR bit by bit
|
||||||
|
xor_val = uid ^ nt
|
||||||
|
for i in range(32):
|
||||||
|
bit = (xor_val >> (31 - i)) & 1
|
||||||
|
self._clock(bit, True)
|
||||||
276
pm3py/transponders/hf/iso14443a/nxp/desfire.py
Normal file
276
pm3py/transponders/hf/iso14443a/nxp/desfire.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"""DESFire EV1/EV2 transponder and reader models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from pm3py.sim.frame import RFFrame
|
||||||
|
from pm3py.sim.medium import Medium
|
||||||
|
from ..base import Tag14443A_4, Reader14443A, _compute_bcc, _split_uid_cascades
|
||||||
|
|
||||||
|
# DESFire status codes
|
||||||
|
ST_OK = 0x00
|
||||||
|
ST_AF = 0xAF # Additional Frame
|
||||||
|
ST_AE = 0xAE # Authentication Error
|
||||||
|
ST_DE = 0xDE # Duplicate Error
|
||||||
|
ST_NE = 0x9D # Application not found
|
||||||
|
ST_FE = 0xF0 # File not found
|
||||||
|
ST_PE = 0x9E # Permission denied
|
||||||
|
ST_IE = 0x1C # Integrity error
|
||||||
|
|
||||||
|
# DESFire commands
|
||||||
|
CMD_GET_VERSION = 0x60
|
||||||
|
CMD_GET_APP_IDS = 0x6A
|
||||||
|
CMD_SELECT_APP = 0x5A
|
||||||
|
CMD_CREATE_APP = 0xCA
|
||||||
|
CMD_DELETE_APP = 0xDA
|
||||||
|
CMD_CREATE_STD_FILE = 0xCD
|
||||||
|
CMD_READ_DATA = 0xBD
|
||||||
|
CMD_WRITE_DATA = 0x3D
|
||||||
|
CMD_AUTH_AES = 0xAA
|
||||||
|
CMD_ADDITIONAL_FRAME = 0xAF
|
||||||
|
|
||||||
|
PICC_AID = b"\x00\x00\x00"
|
||||||
|
|
||||||
|
|
||||||
|
class DesfireApp:
|
||||||
|
"""A DESFire application with files and keys."""
|
||||||
|
|
||||||
|
def __init__(self, aid: bytes, key_settings: int, num_keys: int,
|
||||||
|
key_type: str = "aes"):
|
||||||
|
self.aid = aid
|
||||||
|
self.key_settings = key_settings
|
||||||
|
self.num_keys = num_keys
|
||||||
|
self.key_type = key_type
|
||||||
|
self.keys: list[bytes] = [b"\x00" * 16 for _ in range(num_keys)]
|
||||||
|
self.files: dict[int, DesfireFile] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class DesfireFile:
|
||||||
|
"""A DESFire standard data file."""
|
||||||
|
|
||||||
|
def __init__(self, file_no: int, comm_settings: int,
|
||||||
|
access_rights: int, size: int):
|
||||||
|
self.file_no = file_no
|
||||||
|
self.comm_settings = comm_settings
|
||||||
|
self.access_rights = access_rights
|
||||||
|
self.data = bytearray(size)
|
||||||
|
|
||||||
|
|
||||||
|
class DesfireTag(Tag14443A_4):
|
||||||
|
"""DESFire EV1/EV2 transponder.
|
||||||
|
|
||||||
|
Application/file structure with AES authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes,
|
||||||
|
atqa: bytes = b"\x44\x03", sak: int = 0x20,
|
||||||
|
ats: bytes = b"\x06\x75\x77\x81\x02\x80"):
|
||||||
|
super().__init__(uid=uid, atqa=atqa, sak=sak, ats=ats)
|
||||||
|
# PICC master application
|
||||||
|
picc_app = DesfireApp(PICC_AID, key_settings=0x0F, num_keys=1)
|
||||||
|
self._apps: dict[bytes, DesfireApp] = {PICC_AID: picc_app}
|
||||||
|
self._selected_app: DesfireApp = picc_app
|
||||||
|
self._authenticated_key: int | None = None
|
||||||
|
self._version_state = 0 # for multi-frame GetVersion
|
||||||
|
|
||||||
|
def _handle_apdu(self, apdu: bytes) -> bytes:
|
||||||
|
"""Process DESFire native command (wrapped in ISO-DEP I-block)."""
|
||||||
|
if not apdu:
|
||||||
|
return bytes([ST_IE])
|
||||||
|
|
||||||
|
cmd = apdu[0]
|
||||||
|
data = apdu[1:]
|
||||||
|
|
||||||
|
match cmd:
|
||||||
|
case 0x60: # GetVersion
|
||||||
|
return self._cmd_get_version(data)
|
||||||
|
case 0xAF: # AdditionalFrame
|
||||||
|
return self._cmd_additional_frame(data)
|
||||||
|
case 0x6A: # GetApplicationIDs
|
||||||
|
return self._cmd_get_app_ids(data)
|
||||||
|
case 0x5A: # SelectApplication
|
||||||
|
return self._cmd_select_app(data)
|
||||||
|
case 0xCA: # CreateApplication
|
||||||
|
return self._cmd_create_app(data)
|
||||||
|
case 0xDA: # DeleteApplication
|
||||||
|
return self._cmd_delete_app(data)
|
||||||
|
case 0xCD: # CreateStdDataFile
|
||||||
|
return self._cmd_create_file(data)
|
||||||
|
case 0xBD: # ReadData
|
||||||
|
return self._cmd_read_data(data)
|
||||||
|
case 0x3D: # WriteData
|
||||||
|
return self._cmd_write_data(data)
|
||||||
|
case 0xAA: # AuthenticateAES
|
||||||
|
return self._cmd_auth_aes(data)
|
||||||
|
|
||||||
|
return bytes([ST_IE])
|
||||||
|
|
||||||
|
def _cmd_get_version(self, data: bytes) -> bytes:
|
||||||
|
"""GetVersion part 1: hardware info."""
|
||||||
|
self._version_state = 1
|
||||||
|
# hw_vendor=0x04(NXP), hw_type=0x01, hw_subtype=0x01, hw_major=1, hw_minor=0
|
||||||
|
# hw_storage=0x18(8K), hw_protocol=0x05
|
||||||
|
return bytes([ST_AF, 0x04, 0x01, 0x01, 0x01, 0x00, 0x18, 0x05])
|
||||||
|
|
||||||
|
def _cmd_additional_frame(self, data: bytes) -> bytes:
|
||||||
|
"""Handle additional frame for multi-part responses."""
|
||||||
|
if self._version_state == 1:
|
||||||
|
self._version_state = 2
|
||||||
|
# sw_vendor, sw_type, sw_subtype, sw_major, sw_minor, sw_storage, sw_protocol
|
||||||
|
return bytes([ST_AF, 0x04, 0x01, 0x01, 0x01, 0x00, 0x18, 0x05])
|
||||||
|
elif self._version_state == 2:
|
||||||
|
self._version_state = 0
|
||||||
|
# UID(7) + batch(5) + production_week + production_year
|
||||||
|
uid_bytes = self._uid[:7] if len(self._uid) >= 7 else self._uid + b"\x00" * (7 - len(self._uid))
|
||||||
|
return bytes([ST_OK]) + uid_bytes + b"\x00" * 5 + b"\x01\x1A"
|
||||||
|
return bytes([ST_IE])
|
||||||
|
|
||||||
|
def _cmd_get_app_ids(self, data: bytes) -> bytes:
|
||||||
|
aids = b""
|
||||||
|
for aid in self._apps:
|
||||||
|
if aid != PICC_AID:
|
||||||
|
aids += aid
|
||||||
|
return bytes([ST_OK]) + aids
|
||||||
|
|
||||||
|
def _cmd_select_app(self, data: bytes) -> bytes:
|
||||||
|
if len(data) < 3:
|
||||||
|
return bytes([ST_IE])
|
||||||
|
aid = data[:3]
|
||||||
|
if aid in self._apps:
|
||||||
|
self._selected_app = self._apps[aid]
|
||||||
|
self._authenticated_key = None
|
||||||
|
return bytes([ST_OK])
|
||||||
|
return bytes([ST_NE])
|
||||||
|
|
||||||
|
def _cmd_create_app(self, data: bytes) -> bytes:
|
||||||
|
if len(data) < 5:
|
||||||
|
return bytes([ST_IE])
|
||||||
|
aid = data[:3]
|
||||||
|
key_settings = data[3]
|
||||||
|
num_keys = data[4] & 0x3F
|
||||||
|
key_type = "aes" if data[4] & 0x80 else "des"
|
||||||
|
if aid in self._apps:
|
||||||
|
return bytes([ST_DE])
|
||||||
|
self._apps[aid] = DesfireApp(aid, key_settings, max(num_keys, 1), key_type)
|
||||||
|
return bytes([ST_OK])
|
||||||
|
|
||||||
|
def _cmd_delete_app(self, data: bytes) -> bytes:
|
||||||
|
if len(data) < 3:
|
||||||
|
return bytes([ST_IE])
|
||||||
|
aid = data[:3]
|
||||||
|
if aid not in self._apps or aid == PICC_AID:
|
||||||
|
return bytes([ST_NE])
|
||||||
|
del self._apps[aid]
|
||||||
|
return bytes([ST_OK])
|
||||||
|
|
||||||
|
def _cmd_create_file(self, data: bytes) -> bytes:
|
||||||
|
if len(data) < 7:
|
||||||
|
return bytes([ST_IE])
|
||||||
|
file_no = data[0]
|
||||||
|
comm_settings = data[1]
|
||||||
|
access_rights = (data[2] << 8) | data[3]
|
||||||
|
size = data[4] | (data[5] << 8) | (data[6] << 16)
|
||||||
|
if file_no in self._selected_app.files:
|
||||||
|
return bytes([ST_DE])
|
||||||
|
self._selected_app.files[file_no] = DesfireFile(file_no, comm_settings, access_rights, size)
|
||||||
|
return bytes([ST_OK])
|
||||||
|
|
||||||
|
def _cmd_read_data(self, data: bytes) -> bytes:
|
||||||
|
if len(data) < 7:
|
||||||
|
return bytes([ST_IE])
|
||||||
|
file_no = data[0]
|
||||||
|
offset = data[1] | (data[2] << 8) | (data[3] << 16)
|
||||||
|
length = data[4] | (data[5] << 8) | (data[6] << 16)
|
||||||
|
f = self._selected_app.files.get(file_no)
|
||||||
|
if f is None:
|
||||||
|
return bytes([ST_FE])
|
||||||
|
end = min(offset + length, len(f.data))
|
||||||
|
return bytes([ST_OK]) + bytes(f.data[offset:end])
|
||||||
|
|
||||||
|
def _cmd_write_data(self, data: bytes) -> bytes:
|
||||||
|
if len(data) < 7:
|
||||||
|
return bytes([ST_IE])
|
||||||
|
file_no = data[0]
|
||||||
|
offset = data[1] | (data[2] << 8) | (data[3] << 16)
|
||||||
|
length = data[4] | (data[5] << 8) | (data[6] << 16)
|
||||||
|
write_data = data[7:7 + length]
|
||||||
|
f = self._selected_app.files.get(file_no)
|
||||||
|
if f is None:
|
||||||
|
return bytes([ST_FE])
|
||||||
|
f.data[offset:offset + len(write_data)] = write_data
|
||||||
|
return bytes([ST_OK])
|
||||||
|
|
||||||
|
def _cmd_auth_aes(self, data: bytes) -> bytes:
|
||||||
|
"""Start AES authentication: return encrypted challenge."""
|
||||||
|
if len(data) < 1:
|
||||||
|
return bytes([ST_IE])
|
||||||
|
key_no = data[0]
|
||||||
|
if key_no >= self._selected_app.num_keys:
|
||||||
|
return bytes([ST_AE])
|
||||||
|
# Generate random challenge (16 bytes for AES)
|
||||||
|
challenge = os.urandom(16)
|
||||||
|
self._auth_challenge = challenge
|
||||||
|
self._auth_key_no = key_no
|
||||||
|
return bytes([ST_AF]) + challenge
|
||||||
|
|
||||||
|
|
||||||
|
# ---- DesfireReader ----
|
||||||
|
|
||||||
|
class DesfireReader:
|
||||||
|
"""DESFire reader — high-level operations."""
|
||||||
|
|
||||||
|
def __init__(self, medium: Medium):
|
||||||
|
self._medium = medium
|
||||||
|
self._reader = Reader14443A(medium)
|
||||||
|
|
||||||
|
async def _activate(self, uid: bytes) -> None:
|
||||||
|
await self._reader.select_tag(uid)
|
||||||
|
await self._reader.rats()
|
||||||
|
|
||||||
|
async def _send_cmd(self, cmd: bytes) -> bytes | None:
|
||||||
|
"""Send DESFire command, return response (status + data)."""
|
||||||
|
pcb = 0x02
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(bytes([pcb]) + cmd))
|
||||||
|
resp = await self._medium.receive_reader()
|
||||||
|
if resp is None:
|
||||||
|
return None
|
||||||
|
return resp.data[1:] # strip PCB
|
||||||
|
|
||||||
|
async def get_version(self, uid: bytes) -> dict | None:
|
||||||
|
await self._activate(uid)
|
||||||
|
# Part 1
|
||||||
|
r1 = await self._send_cmd(b"\x60")
|
||||||
|
if r1 is None or r1[0] != ST_AF:
|
||||||
|
return None
|
||||||
|
# Part 2
|
||||||
|
r2 = await self._send_cmd(b"\xAF")
|
||||||
|
if r2 is None or r2[0] != ST_AF:
|
||||||
|
return None
|
||||||
|
# Part 3
|
||||||
|
r3 = await self._send_cmd(b"\xAF")
|
||||||
|
if r3 is None or r3[0] != ST_OK:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"hw_vendor": r1[1],
|
||||||
|
"hw_type": r1[2],
|
||||||
|
"hw_storage": r1[6],
|
||||||
|
"sw_vendor": r2[1],
|
||||||
|
"sw_type": r2[2],
|
||||||
|
"uid": r3[1:8],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def create_application(self, uid: bytes, aid: bytes,
|
||||||
|
num_keys: int = 1,
|
||||||
|
key_settings: int = 0x0F) -> dict:
|
||||||
|
await self._activate(uid)
|
||||||
|
cmd = bytes([CMD_CREATE_APP]) + aid + bytes([key_settings, num_keys])
|
||||||
|
resp = await self._send_cmd(cmd)
|
||||||
|
return {"success": resp is not None and resp[0] == ST_OK}
|
||||||
|
|
||||||
|
async def select_application(self, uid: bytes, aid: bytes) -> dict:
|
||||||
|
await self._activate(uid)
|
||||||
|
cmd = bytes([CMD_SELECT_APP]) + aid
|
||||||
|
resp = await self._send_cmd(cmd)
|
||||||
|
return {"success": resp is not None and resp[0] == ST_OK}
|
||||||
358
pm3py/transponders/hf/iso14443a/nxp/mifare_classic.py
Normal file
358
pm3py/transponders/hf/iso14443a/nxp/mifare_classic.py
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
"""MIFARE Classic transponder and reader models with Crypto-1 authentication."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from pm3py.sim.frame import RFFrame
|
||||||
|
from pm3py.sim.medium import Medium
|
||||||
|
from ..base import Tag14443A_3, Reader14443A, _compute_bcc
|
||||||
|
from .crypto1 import Crypto1
|
||||||
|
|
||||||
|
# MIFARE Classic commands
|
||||||
|
AUTH_A = 0x60
|
||||||
|
AUTH_B = 0x61
|
||||||
|
READ_CMD = 0x30
|
||||||
|
WRITE_CMD = 0xA0
|
||||||
|
ACK = 0x0A
|
||||||
|
NACK = 0x00
|
||||||
|
|
||||||
|
BLOCK_SIZE = 16
|
||||||
|
|
||||||
|
|
||||||
|
class MifareClassicTag(Tag14443A_3):
|
||||||
|
"""MIFARE Classic 1K/4K transponder with Crypto-1 authentication."""
|
||||||
|
|
||||||
|
_SIZE_CONFIG = {
|
||||||
|
"1k": {"atqa": b"\x04\x00", "sak": 0x08, "blocks": 64},
|
||||||
|
"4k": {"atqa": b"\x02\x00", "sak": 0x18, "blocks": 256},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes, size: str = "1k",
|
||||||
|
keys_a: dict[int, bytes] | None = None,
|
||||||
|
keys_b: dict[int, bytes] | None = None,
|
||||||
|
data: bytearray | None = None):
|
||||||
|
cfg = self._SIZE_CONFIG[size]
|
||||||
|
super().__init__(uid=uid, atqa=cfg["atqa"], sak=cfg["sak"])
|
||||||
|
self._size = size
|
||||||
|
self._num_blocks = cfg["blocks"]
|
||||||
|
self._num_sectors = 16 if size == "1k" else 40
|
||||||
|
self._keys_a = keys_a or {s: b"\xFF" * 6 for s in range(self._num_sectors)}
|
||||||
|
self._keys_b = keys_b or {s: b"\xFF" * 6 for s in range(self._num_sectors)}
|
||||||
|
self._data = data if data is not None else bytearray(self._num_blocks * BLOCK_SIZE)
|
||||||
|
# Region for block data (14a EML not yet supported, so eml_offset=-1)
|
||||||
|
from pm3py.sim.memory import DirtyByteArray, MemoryRegion
|
||||||
|
self.regions["blocks"] = MemoryRegion(
|
||||||
|
name="blocks", data=DirtyByteArray(self._data),
|
||||||
|
block_size=BLOCK_SIZE, eml_offset=-1)
|
||||||
|
self._crypto: Crypto1 | None = None
|
||||||
|
self._auth_sector: int | None = None
|
||||||
|
self._auth_state: str = "NONE" # NONE, NONCE_SENT, AUTHENTICATED
|
||||||
|
self._tag_nonce: bytes = b""
|
||||||
|
|
||||||
|
def sector_for_block(self, block: int) -> int:
|
||||||
|
"""Map block number to sector number."""
|
||||||
|
if block < 128:
|
||||||
|
return block // 4
|
||||||
|
else:
|
||||||
|
return 32 + (block - 128) // 16
|
||||||
|
|
||||||
|
def _sector_trailer_block(self, sector: int) -> int:
|
||||||
|
"""Get the trailer block number for a sector."""
|
||||||
|
if sector < 32:
|
||||||
|
return sector * 4 + 3
|
||||||
|
else:
|
||||||
|
return 128 + (sector - 32) * 16 + 15
|
||||||
|
|
||||||
|
def read_block_raw(self, block: int) -> bytes:
|
||||||
|
"""Direct read of block data (bypassing auth)."""
|
||||||
|
offset = block * BLOCK_SIZE
|
||||||
|
return bytes(self._data[offset:offset + BLOCK_SIZE])
|
||||||
|
|
||||||
|
def write_block_raw(self, block: int, data: bytes) -> None:
|
||||||
|
"""Direct write of block data (bypassing auth)."""
|
||||||
|
if len(data) != BLOCK_SIZE:
|
||||||
|
raise ValueError(f"Block data must be {BLOCK_SIZE} bytes")
|
||||||
|
offset = block * BLOCK_SIZE
|
||||||
|
self._data[offset:offset + BLOCK_SIZE] = data
|
||||||
|
|
||||||
|
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle MIFARE Classic commands after SELECT."""
|
||||||
|
if not frame.data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Auth response comes as raw encrypted data (not a command)
|
||||||
|
if self._auth_state == "NONCE_SENT":
|
||||||
|
return self._handle_auth_response(frame)
|
||||||
|
|
||||||
|
# Write phase 2: receive data after ACK (before command dispatch)
|
||||||
|
if self._auth_state == "WRITE_PENDING":
|
||||||
|
return self._handle_write_phase2(frame)
|
||||||
|
|
||||||
|
cmd = frame.data[0]
|
||||||
|
|
||||||
|
# AUTH commands work in any post-SELECT state
|
||||||
|
if cmd in (AUTH_A, AUTH_B):
|
||||||
|
return self._handle_auth_start(frame)
|
||||||
|
|
||||||
|
if self._auth_state != "AUTHENTICATED":
|
||||||
|
return None # not authenticated
|
||||||
|
|
||||||
|
# Authenticated commands (plaintext in simulation)
|
||||||
|
if cmd == READ_CMD:
|
||||||
|
return self._handle_read(frame)
|
||||||
|
if cmd == WRITE_CMD:
|
||||||
|
return self._handle_write_phase1(frame)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_auth_start(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle AUTH_A/AUTH_B command: return tag nonce."""
|
||||||
|
if len(frame.data) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cmd = frame.data[0]
|
||||||
|
block = frame.data[1]
|
||||||
|
sector = self.sector_for_block(block)
|
||||||
|
key_type = "a" if cmd == AUTH_A else "b"
|
||||||
|
key = self._keys_a[sector] if key_type == "a" else self._keys_b[sector]
|
||||||
|
|
||||||
|
# Store the key for verification during auth response
|
||||||
|
self._auth_key = key
|
||||||
|
self._auth_sector = sector
|
||||||
|
|
||||||
|
# Generate tag nonce
|
||||||
|
self._crypto = Crypto1(key)
|
||||||
|
self._tag_nonce = self._crypto.generate_nonce()
|
||||||
|
self._auth_state = "NONCE_SENT"
|
||||||
|
|
||||||
|
# Initialize cipher with uid ^ nt
|
||||||
|
uid_int = struct.unpack(">I", self._uid[:4])[0]
|
||||||
|
nt_int = struct.unpack(">I", self._tag_nonce)[0]
|
||||||
|
|
||||||
|
# Re-init crypto with key and auth init
|
||||||
|
self._crypto = Crypto1(key)
|
||||||
|
self._crypto.init_auth(uid_int, nt_int)
|
||||||
|
|
||||||
|
return RFFrame.from_bytes(self._tag_nonce)
|
||||||
|
|
||||||
|
def _handle_auth_response(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle reader's auth response (nr_enc + ar_enc).
|
||||||
|
|
||||||
|
Verifies the reader used the correct key by re-deriving the expected
|
||||||
|
encrypted response and comparing.
|
||||||
|
"""
|
||||||
|
if len(frame.data) < 8 or self._crypto is None:
|
||||||
|
self._auth_state = "NONE"
|
||||||
|
return None
|
||||||
|
|
||||||
|
nr_enc = frame.data[0:4]
|
||||||
|
ar_enc = frame.data[4:8]
|
||||||
|
|
||||||
|
# Verify: build a reader-side Crypto1 with the tag's key and same
|
||||||
|
# init_auth parameters. If the reader used the correct key, its
|
||||||
|
# encrypted output will match what we compute.
|
||||||
|
uid_int = struct.unpack(">I", self._uid[:4])[0]
|
||||||
|
nt_int = struct.unpack(">I", self._tag_nonce)[0]
|
||||||
|
|
||||||
|
verify = Crypto1(self._auth_key)
|
||||||
|
verify.init_auth(uid_int, nt_int)
|
||||||
|
|
||||||
|
# The reader encrypted `nr` with its keystream. If keys match,
|
||||||
|
# decrypting with our matching keystream gives the real `nr`.
|
||||||
|
# Then re-encrypting `nr` with our verify keystream should match nr_enc.
|
||||||
|
expected_ks = bytearray(8)
|
||||||
|
for i in range(8):
|
||||||
|
expected_ks[i] = verify.generate_byte()
|
||||||
|
|
||||||
|
# If keys match: nr_enc = nr XOR ks. Both sides have same ks.
|
||||||
|
# We can verify by checking that nr_enc XOR ks produces the same
|
||||||
|
# nr that the reader intended, and then checking ar consistency.
|
||||||
|
# Simpler: just verify the reader's 8 bytes match what our key produces.
|
||||||
|
# The reader sends nr_enc = nr XOR reader_ks, ar_enc = ar XOR reader_ks.
|
||||||
|
# If reader has same key, reader_ks == our_ks, so:
|
||||||
|
# nr_enc XOR our_ks[0:4] == nr (the reader's chosen nonce)
|
||||||
|
# ar_enc XOR our_ks[4:8] == ar (should be a specific value)
|
||||||
|
# Since nr is random (reader-chosen), we can't verify it.
|
||||||
|
# But we CAN verify the relationship: if keys differ, ks differs,
|
||||||
|
# so the decrypted values will be inconsistent.
|
||||||
|
|
||||||
|
# For simulation simplicity: accept if keys match, reject otherwise.
|
||||||
|
# We detect key mismatch by checking that both sides produce
|
||||||
|
# identical keystreams after init_auth with the same parameters.
|
||||||
|
tag_ks = bytearray(8)
|
||||||
|
# Reset tag's crypto to same state
|
||||||
|
tag_verify = Crypto1(self._auth_key)
|
||||||
|
tag_verify.init_auth(uid_int, nt_int)
|
||||||
|
for i in range(8):
|
||||||
|
tag_ks[i] = tag_verify.generate_byte()
|
||||||
|
|
||||||
|
# Now check if nr_enc was produced by the same keystream
|
||||||
|
# nr_enc = nr XOR ks_reader. If ks_reader == ks_tag, then
|
||||||
|
# nr_enc XOR ks_tag gives us the real nr.
|
||||||
|
# We don't know nr, but we can verify that the READER also used
|
||||||
|
# this same ks by checking if their encrypted output is valid.
|
||||||
|
# The reader's key produces a different ks if wrong.
|
||||||
|
# Detect by: reader computes ks_r = Crypto1(wrong_key).init_auth(uid,nt)
|
||||||
|
# nr_enc = nr XOR ks_r. ks_r != ks_tag. So nr_enc XOR ks_tag != nr.
|
||||||
|
# This is fine, but we can't verify nr is "correct" since it's random.
|
||||||
|
|
||||||
|
# ACTUAL SIMPLE APPROACH: The reader embeds its key hash in the protocol.
|
||||||
|
# For simulation, we'll pass the reader's key through the frame metadata.
|
||||||
|
# But that breaks the protocol abstraction.
|
||||||
|
|
||||||
|
# PRAGMATIC APPROACH: Use deterministic nr and verify the full 8-byte
|
||||||
|
# encrypted block matches what a correct-key reader would produce.
|
||||||
|
reader_crypto_check = Crypto1(self._auth_key)
|
||||||
|
reader_crypto_check.init_auth(uid_int, nt_int)
|
||||||
|
# Generate the same nr the reader uses (we know it's deterministic: 0x01234567)
|
||||||
|
# and compute what the correct output should be
|
||||||
|
check_nr = b"\x01\x23\x45\x67" # reader's known nr
|
||||||
|
expected_nr_enc = reader_crypto_check.encrypt_bytes(check_nr)
|
||||||
|
expected_ar_enc = reader_crypto_check.encrypt_bytes(b"\x00\x00\x00\x00")
|
||||||
|
|
||||||
|
if nr_enc + ar_enc != expected_nr_enc + expected_ar_enc:
|
||||||
|
self._auth_state = "NONE"
|
||||||
|
self._crypto = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Consume our crypto's keystream to stay in sync
|
||||||
|
self._crypto.encrypt_bytes(nr_enc)
|
||||||
|
self._crypto.encrypt_bytes(ar_enc)
|
||||||
|
|
||||||
|
self._auth_state = "AUTHENTICATED"
|
||||||
|
at = self._crypto.encrypt_bytes(b"\x00\x00\x00\x00")
|
||||||
|
return RFFrame.from_bytes(at)
|
||||||
|
|
||||||
|
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle READ command — return 16 bytes of block data."""
|
||||||
|
if len(frame.data) < 2:
|
||||||
|
return None
|
||||||
|
block = frame.data[1]
|
||||||
|
if block >= self._num_blocks:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check block is in authenticated sector
|
||||||
|
if self.sector_for_block(block) != self._auth_sector:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = self.read_block_raw(block)
|
||||||
|
return RFFrame.from_bytes(data)
|
||||||
|
|
||||||
|
def _handle_write_phase1(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle WRITE command phase 1 — ACK, wait for data."""
|
||||||
|
if len(frame.data) < 2:
|
||||||
|
return None
|
||||||
|
block = frame.data[1]
|
||||||
|
if block >= self._num_blocks:
|
||||||
|
return None
|
||||||
|
if self.sector_for_block(block) != self._auth_sector:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._write_target_block = block
|
||||||
|
self._auth_state = "WRITE_PENDING"
|
||||||
|
return RFFrame.from_bytes(bytes([ACK]))
|
||||||
|
|
||||||
|
def _handle_write_phase2(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle WRITE data (16 bytes after ACK)."""
|
||||||
|
if len(frame.data) < BLOCK_SIZE:
|
||||||
|
self._auth_state = "AUTHENTICATED"
|
||||||
|
return None
|
||||||
|
self.write_block_raw(self._write_target_block, frame.data[:BLOCK_SIZE])
|
||||||
|
self._auth_state = "AUTHENTICATED"
|
||||||
|
return RFFrame.from_bytes(bytes([ACK]))
|
||||||
|
|
||||||
|
|
||||||
|
class MifareClassicReader:
|
||||||
|
"""MIFARE Classic reader with Crypto-1 authentication."""
|
||||||
|
|
||||||
|
def __init__(self, medium: Medium):
|
||||||
|
self._medium = medium
|
||||||
|
self._reader = Reader14443A(medium)
|
||||||
|
|
||||||
|
async def read_block(self, uid: bytes, block: int,
|
||||||
|
key: bytes, key_type: str = "a") -> dict:
|
||||||
|
"""Authenticate and read a block."""
|
||||||
|
try:
|
||||||
|
await self._reader.select_tag(uid)
|
||||||
|
except RuntimeError:
|
||||||
|
return {"success": False, "data": None}
|
||||||
|
|
||||||
|
# AUTH
|
||||||
|
auth_cmd = AUTH_A if key_type == "a" else AUTH_B
|
||||||
|
auth_frame = RFFrame.from_bytes(bytes([auth_cmd, block]))
|
||||||
|
await self._medium.transmit_reader(auth_frame)
|
||||||
|
nt_resp = await self._medium.receive_reader()
|
||||||
|
if nt_resp is None or len(nt_resp.data) < 4:
|
||||||
|
return {"success": False, "data": None}
|
||||||
|
|
||||||
|
nt = nt_resp.data[:4]
|
||||||
|
|
||||||
|
# Initialize crypto
|
||||||
|
crypto = Crypto1(key)
|
||||||
|
uid_int = struct.unpack(">I", uid[:4])[0]
|
||||||
|
nt_int = struct.unpack(">I", nt)[0]
|
||||||
|
crypto.init_auth(uid_int, nt_int)
|
||||||
|
|
||||||
|
# Send nr_enc + ar_enc
|
||||||
|
nr = b"\x01\x23\x45\x67"
|
||||||
|
nr_enc = crypto.encrypt_bytes(nr)
|
||||||
|
ar_enc = crypto.encrypt_bytes(b"\x00\x00\x00\x00")
|
||||||
|
auth_resp_frame = RFFrame.from_bytes(nr_enc + ar_enc)
|
||||||
|
await self._medium.transmit_reader(auth_resp_frame)
|
||||||
|
at_resp = await self._medium.receive_reader()
|
||||||
|
if at_resp is None:
|
||||||
|
return {"success": False, "data": None}
|
||||||
|
|
||||||
|
# Authenticated — send READ
|
||||||
|
read_frame = RFFrame.from_bytes(bytes([READ_CMD, block]))
|
||||||
|
await self._medium.transmit_reader(read_frame)
|
||||||
|
data_resp = await self._medium.receive_reader()
|
||||||
|
if data_resp is None:
|
||||||
|
return {"success": False, "data": None}
|
||||||
|
|
||||||
|
return {"success": True, "data": data_resp.data[:16]}
|
||||||
|
|
||||||
|
async def write_block(self, uid: bytes, block: int, data: bytes,
|
||||||
|
key: bytes, key_type: str = "a") -> dict:
|
||||||
|
"""Authenticate and write a block."""
|
||||||
|
try:
|
||||||
|
await self._reader.select_tag(uid)
|
||||||
|
except RuntimeError:
|
||||||
|
return {"success": False}
|
||||||
|
|
||||||
|
# AUTH
|
||||||
|
auth_cmd = AUTH_A if key_type == "a" else AUTH_B
|
||||||
|
auth_frame = RFFrame.from_bytes(bytes([auth_cmd, block]))
|
||||||
|
await self._medium.transmit_reader(auth_frame)
|
||||||
|
nt_resp = await self._medium.receive_reader()
|
||||||
|
if nt_resp is None or len(nt_resp.data) < 4:
|
||||||
|
return {"success": False}
|
||||||
|
|
||||||
|
nt = nt_resp.data[:4]
|
||||||
|
|
||||||
|
# Initialize crypto
|
||||||
|
crypto = Crypto1(key)
|
||||||
|
uid_int = struct.unpack(">I", uid[:4])[0]
|
||||||
|
nt_int = struct.unpack(">I", nt)[0]
|
||||||
|
crypto.init_auth(uid_int, nt_int)
|
||||||
|
|
||||||
|
# Send nr_enc + ar_enc
|
||||||
|
nr = b"\x01\x23\x45\x67"
|
||||||
|
nr_enc = crypto.encrypt_bytes(nr)
|
||||||
|
ar_enc = crypto.encrypt_bytes(b"\x00\x00\x00\x00")
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(nr_enc + ar_enc))
|
||||||
|
at_resp = await self._medium.receive_reader()
|
||||||
|
if at_resp is None:
|
||||||
|
return {"success": False}
|
||||||
|
|
||||||
|
# WRITE phase 1
|
||||||
|
write_frame = RFFrame.from_bytes(bytes([WRITE_CMD, block]))
|
||||||
|
await self._medium.transmit_reader(write_frame)
|
||||||
|
ack_resp = await self._medium.receive_reader()
|
||||||
|
if ack_resp is None or ack_resp.data[0] != ACK:
|
||||||
|
return {"success": False}
|
||||||
|
|
||||||
|
# WRITE phase 2: send data (plaintext in simulation)
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(data))
|
||||||
|
await self._medium.receive_reader() # consume any response
|
||||||
|
return {"success": True}
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""ISO 14443-A Layer 3 transponders (MIFARE Classic, Ultralight, NTAG)."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""NXP ISO 14443-A Layer 3 transponders."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""ISO 14443-A Layer 4 (ISO-DEP) transponders (DESFire, NTAG 5 via -4)."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""NXP ISO 14443-A Layer 4 transponders."""
|
|
||||||
529
pm3py/transponders/hf/iso15693/base.py
Normal file
529
pm3py/transponders/hf/iso15693/base.py
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
"""ISO 15693 transponder and reader state machines."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
from pm3py.sim.frame import RFFrame
|
||||||
|
from pm3py.sim.medium import Medium
|
||||||
|
from pm3py.sim.trace_fmt import decode_15693
|
||||||
|
from pm3py.sim.transponder import Transponder
|
||||||
|
from pm3py.sim.reader import Reader
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FLAG_SUBCARRIER", "FLAG_HIGH_DATA_RATE", "FLAG_INVENTORY", "FLAG_PROTOCOL_EXT",
|
||||||
|
"FLAG_SELECT", "FLAG_ADDRESS", "FLAG_INV_SLOTS1", "FLAG_OPTION", "FLAG_RFU",
|
||||||
|
"RESP_ERROR",
|
||||||
|
"CMD_INVENTORY", "CMD_STAY_QUIET", "CMD_READ_SINGLE", "CMD_WRITE_SINGLE",
|
||||||
|
"CMD_READ_MULTIPLE", "CMD_RESET_TO_READY", "CMD_SYSTEM_INFO",
|
||||||
|
"CMD_EXT_READ_SINGLE", "CMD_EXT_WRITE_SINGLE", "CMD_EXT_READ_MULTIPLE",
|
||||||
|
"SYSINFO_DSFID", "SYSINFO_AFI", "SYSINFO_MEMSIZE", "SYSINFO_ICREF",
|
||||||
|
"State15693", "Tag15693", "Reader15693",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---- ISO 15693 flag bits ----
|
||||||
|
FLAG_SUBCARRIER = 0x01
|
||||||
|
FLAG_HIGH_DATA_RATE = 0x02
|
||||||
|
FLAG_INVENTORY = 0x04
|
||||||
|
FLAG_PROTOCOL_EXT = 0x08
|
||||||
|
FLAG_SELECT = 0x10 # non-inventory: selected
|
||||||
|
FLAG_ADDRESS = 0x20 # non-inventory: addressed
|
||||||
|
FLAG_INV_SLOTS1 = 0x20 # inventory: 1-slot (if set)
|
||||||
|
FLAG_OPTION = 0x40
|
||||||
|
FLAG_RFU = 0x80
|
||||||
|
|
||||||
|
# Response flag bits
|
||||||
|
RESP_ERROR = 0x01
|
||||||
|
|
||||||
|
# ---- ISO 15693 commands ----
|
||||||
|
CMD_INVENTORY = 0x01
|
||||||
|
CMD_STAY_QUIET = 0x02
|
||||||
|
CMD_READ_SINGLE = 0x20
|
||||||
|
CMD_WRITE_SINGLE = 0x21
|
||||||
|
CMD_READ_MULTIPLE = 0x23
|
||||||
|
CMD_RESET_TO_READY = 0x26
|
||||||
|
CMD_SYSTEM_INFO = 0x2B
|
||||||
|
CMD_EXT_READ_SINGLE = 0x30
|
||||||
|
CMD_EXT_WRITE_SINGLE = 0x31
|
||||||
|
CMD_EXT_READ_MULTIPLE = 0x33
|
||||||
|
|
||||||
|
# System info flag bits
|
||||||
|
SYSINFO_DSFID = 0x01
|
||||||
|
SYSINFO_AFI = 0x02
|
||||||
|
SYSINFO_MEMSIZE = 0x04
|
||||||
|
SYSINFO_ICREF = 0x08
|
||||||
|
|
||||||
|
|
||||||
|
class State15693(IntEnum):
|
||||||
|
POWER_OFF = 0
|
||||||
|
READY = 1
|
||||||
|
QUIET = 2
|
||||||
|
SELECTED = 3
|
||||||
|
|
||||||
|
|
||||||
|
class Tag15693(Transponder):
|
||||||
|
"""ISO 15693 transponder with inventory and block operations."""
|
||||||
|
|
||||||
|
# Subclasses override to set valid UID prefix for auto-generation.
|
||||||
|
# Format: [E0, manufacturer, IC_type, ...] — remaining bytes are random.
|
||||||
|
_uid_prefix: bytes = b"\xE0\x00"
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes | str | None = None, dsfid: int = 0, afi: int = 0,
|
||||||
|
memory: bytearray | None = None,
|
||||||
|
block_size: int = 4, num_blocks: int = 28,
|
||||||
|
ic_reference: int = 0x00):
|
||||||
|
import warnings
|
||||||
|
super().__init__()
|
||||||
|
if uid is None:
|
||||||
|
uid = self._generate_uid()
|
||||||
|
else:
|
||||||
|
uid = self._parse_uid(uid)
|
||||||
|
if len(uid) != 8:
|
||||||
|
raise ValueError(f"UID must be 8 bytes, got {len(uid)}")
|
||||||
|
# Validate UID prefix matches expected IC type (only for product-specific subclasses)
|
||||||
|
prefix = self._uid_prefix
|
||||||
|
if len(prefix) > 2 and uid[:len(prefix)] != prefix:
|
||||||
|
expected = prefix.hex().upper()
|
||||||
|
actual = uid[:len(prefix)].hex().upper()
|
||||||
|
cls_name = type(self).__name__
|
||||||
|
warnings.warn(
|
||||||
|
f"{cls_name}: UID prefix {actual} doesn't match expected {expected}. "
|
||||||
|
f"Phones/readers may not identify the IC type correctly. "
|
||||||
|
f"Use uid=None for auto-generated valid UID, or start UID with {expected}.",
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
self._uid = uid
|
||||||
|
self._dsfid = dsfid
|
||||||
|
self._afi = afi
|
||||||
|
self._block_size = block_size
|
||||||
|
self._num_blocks = num_blocks
|
||||||
|
self._ic_reference = ic_reference
|
||||||
|
self._state = State15693.POWER_OFF
|
||||||
|
self._uid_dirty = True # ensure first sync() pushes header to firmware
|
||||||
|
|
||||||
|
# Memory regions
|
||||||
|
from pm3py.sim.memory import DirtyByteArray, MemoryRegion
|
||||||
|
user_data = DirtyByteArray(memory) if memory else DirtyByteArray(block_size * num_blocks)
|
||||||
|
self.regions["user"] = MemoryRegion(
|
||||||
|
name="user", data=user_data, block_size=block_size, eml_offset=175)
|
||||||
|
self.regions["locks"] = MemoryRegion(
|
||||||
|
name="locks", data=DirtyByteArray(num_blocks), block_size=1,
|
||||||
|
eml_offset=15, rf_readable=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _memory(self):
|
||||||
|
"""Alias for regions['user'].data — backwards compatible."""
|
||||||
|
return self.regions["user"].data
|
||||||
|
|
||||||
|
@_memory.setter
|
||||||
|
def _memory(self, value):
|
||||||
|
from pm3py.sim.memory import DirtyByteArray
|
||||||
|
self.regions["user"].data = DirtyByteArray(value) if not isinstance(value, DirtyByteArray) else value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _generate_uid(cls) -> bytes:
|
||||||
|
"""Generate a random valid UID with the correct prefix for this IC type."""
|
||||||
|
prefix = cls._uid_prefix
|
||||||
|
random_bytes = os.urandom(8 - len(prefix))
|
||||||
|
return prefix + random_bytes
|
||||||
|
|
||||||
|
def decode_trace(self, direction: int, payload: bytes) -> str | None:
|
||||||
|
return decode_15693(direction, payload)
|
||||||
|
|
||||||
|
def set_dsfid(self, dsfid: int) -> None:
|
||||||
|
"""Change DSFID. Call sync() to push."""
|
||||||
|
self._dsfid = dsfid
|
||||||
|
self._uid_dirty = True # reuse uid_dirty to push header
|
||||||
|
|
||||||
|
def set_afi(self, afi: int) -> None:
|
||||||
|
"""Change AFI (Application Family Identifier). Call sync() to push."""
|
||||||
|
self._afi = afi
|
||||||
|
self._uid_dirty = True
|
||||||
|
|
||||||
|
def _sync_uid(self) -> None:
|
||||||
|
"""Write tag header (UID, DSFID, AFI, config) to firmware EML offset 0."""
|
||||||
|
from pm3py.core.transport import encode_ng_frame
|
||||||
|
from pm3py.core.protocol import Cmd
|
||||||
|
header = bytearray(15)
|
||||||
|
header[0:8] = self._uid[::-1] # UID reversed
|
||||||
|
header[8] = self._dsfid
|
||||||
|
header[9] = 0 # dsfidLock
|
||||||
|
header[10] = self._afi
|
||||||
|
header[11] = 0 # afiLock
|
||||||
|
header[12] = self._block_size
|
||||||
|
header[13] = self._num_blocks
|
||||||
|
header[14] = self._ic_reference
|
||||||
|
payload = struct.pack("<IH", 0, 15) + header
|
||||||
|
frame = encode_ng_frame(Cmd.HF_ISO15693_EML_SETMEM, payload)
|
||||||
|
self._serial.write(frame)
|
||||||
|
|
||||||
|
async def power_on(self) -> None:
|
||||||
|
self._state = State15693.READY
|
||||||
|
|
||||||
|
async def power_off(self) -> None:
|
||||||
|
self._state = State15693.POWER_OFF
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> str:
|
||||||
|
return self._state.name
|
||||||
|
|
||||||
|
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if len(frame.data) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
flags = frame.data[0]
|
||||||
|
cmd = frame.data[1]
|
||||||
|
|
||||||
|
# Check if this is an addressed command and verify UID
|
||||||
|
is_inventory = bool(flags & FLAG_INVENTORY)
|
||||||
|
if not is_inventory and (flags & FLAG_ADDRESS):
|
||||||
|
if len(frame.data) < 10:
|
||||||
|
return None
|
||||||
|
frame_uid = bytes(reversed(frame.data[2:10]))
|
||||||
|
if frame_uid != self._uid:
|
||||||
|
return None
|
||||||
|
|
||||||
|
match cmd:
|
||||||
|
case 0x01: # Inventory
|
||||||
|
if self._state == State15693.QUIET:
|
||||||
|
return None
|
||||||
|
return self._handle_inventory(frame)
|
||||||
|
case 0x02: # Stay Quiet
|
||||||
|
self._state = State15693.QUIET
|
||||||
|
return None # no response per spec
|
||||||
|
case 0x20: # Read Single Block
|
||||||
|
return self._handle_read_single(frame)
|
||||||
|
case 0x21: # Write Single Block
|
||||||
|
return self._handle_write_single(frame)
|
||||||
|
case 0x26: # Reset to Ready
|
||||||
|
self._state = State15693.READY
|
||||||
|
return self._make_response(b"")
|
||||||
|
case 0x2B: # Get System Information
|
||||||
|
return self._handle_system_info(frame)
|
||||||
|
case 0x30: # Extended Read Single Block
|
||||||
|
return self._handle_extended_read_single(frame)
|
||||||
|
case 0x31: # Extended Write Single Block
|
||||||
|
return self._handle_extended_write_single(frame)
|
||||||
|
case 0x33: # Extended Read Multiple Blocks
|
||||||
|
return self._handle_extended_read_multiple(frame)
|
||||||
|
case _ if cmd >= 0xA0:
|
||||||
|
return self._handle_custom_command(cmd, frame)
|
||||||
|
case _:
|
||||||
|
return self._make_error(0x01) # not supported
|
||||||
|
|
||||||
|
def _handle_inventory(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle inventory command. Returns flags + DSFID + UID.
|
||||||
|
|
||||||
|
Supports mask filtering: if mask_len > 0, only respond if our UID
|
||||||
|
matches the mask bits (compared LSB-first from UID byte 0).
|
||||||
|
"""
|
||||||
|
if len(frame.data) < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mask_len = frame.data[2] # number of mask bits
|
||||||
|
if mask_len > 0:
|
||||||
|
# Mask bytes follow mask_len
|
||||||
|
mask_bytes_count = (mask_len + 7) // 8
|
||||||
|
if len(frame.data) < 3 + mask_bytes_count:
|
||||||
|
return None
|
||||||
|
mask_data = frame.data[3:3 + mask_bytes_count]
|
||||||
|
uid_lsb = self._uid[::-1] # UID in LSB-first order
|
||||||
|
|
||||||
|
# Compare mask bytes against UID (byte-level comparison)
|
||||||
|
# Full bytes must match exactly, partial last byte checked bit-by-bit
|
||||||
|
full_bytes = mask_len // 8
|
||||||
|
for i in range(full_bytes):
|
||||||
|
if i < len(mask_data) and i < len(uid_lsb):
|
||||||
|
if mask_data[i] != uid_lsb[i]:
|
||||||
|
return None
|
||||||
|
# Check remaining bits in partial byte (MSB-first within byte
|
||||||
|
# to match bitarray ordering used by collision resolution)
|
||||||
|
remaining_bits = mask_len % 8
|
||||||
|
if remaining_bits > 0 and full_bytes < len(mask_data) and full_bytes < len(uid_lsb):
|
||||||
|
# Mask the relevant bits (MSB-first: top N bits of byte)
|
||||||
|
shift = 8 - remaining_bits
|
||||||
|
mask_val = mask_data[full_bytes] >> shift
|
||||||
|
uid_val = uid_lsb[full_bytes] >> shift
|
||||||
|
if mask_val != uid_val:
|
||||||
|
return None
|
||||||
|
|
||||||
|
resp = bytes([0x00, self._dsfid]) + self._uid[::-1]
|
||||||
|
return RFFrame.from_bytes(resp)
|
||||||
|
|
||||||
|
def _handle_read_single(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle Read Single Block."""
|
||||||
|
block = self._extract_block_number(frame)
|
||||||
|
if block is None:
|
||||||
|
return None
|
||||||
|
if block >= self._num_blocks:
|
||||||
|
return self._make_error(0x10) # block not available
|
||||||
|
|
||||||
|
offset = block * self._block_size
|
||||||
|
data = self._memory[offset:offset + self._block_size]
|
||||||
|
return self._make_response(bytes(data))
|
||||||
|
|
||||||
|
def _handle_write_single(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle Write Single Block."""
|
||||||
|
flags = frame.data[0]
|
||||||
|
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
|
||||||
|
|
||||||
|
if is_addressed:
|
||||||
|
block_offset = 10 # flags + cmd + UID(8)
|
||||||
|
else:
|
||||||
|
block_offset = 2 # flags + cmd
|
||||||
|
|
||||||
|
if len(frame.data) <= block_offset:
|
||||||
|
return None
|
||||||
|
block = frame.data[block_offset]
|
||||||
|
if block >= self._num_blocks:
|
||||||
|
return self._make_error(0x10)
|
||||||
|
|
||||||
|
data_offset = block_offset + 1
|
||||||
|
data = frame.data[data_offset:data_offset + self._block_size]
|
||||||
|
if len(data) < self._block_size:
|
||||||
|
return self._make_error(0x0F) # unknown error
|
||||||
|
|
||||||
|
offset = block * self._block_size
|
||||||
|
|
||||||
|
# Check for special write modes (e.g. counter)
|
||||||
|
access = self.regions["user"].access_for_block(block)
|
||||||
|
if access.write_mode == "counter":
|
||||||
|
import struct as _st
|
||||||
|
increment = _st.unpack_from("<H", data, 0)[0]
|
||||||
|
current = _st.unpack_from("<H", self._memory, offset)[0]
|
||||||
|
new_val = min(current + increment, 0xFFFF) # saturate
|
||||||
|
self._memory[offset:offset + 2] = _st.pack("<H", new_val)
|
||||||
|
elif access.write_mode == "counter24":
|
||||||
|
value = int.from_bytes(data[0:3], 'little')
|
||||||
|
if value == 1:
|
||||||
|
# Increment current counter by 1
|
||||||
|
current = int.from_bytes(self._memory[offset:offset + 3], 'little')
|
||||||
|
new_val = min(current + 1, 0xFFFFFF)
|
||||||
|
else:
|
||||||
|
# Preset (overwrite) counter to new value
|
||||||
|
new_val = value
|
||||||
|
self._memory[offset:offset + 3] = new_val.to_bytes(3, 'little')
|
||||||
|
# Write PROT byte (data[3]) to memory
|
||||||
|
self._memory[offset + 3] = data[3]
|
||||||
|
else:
|
||||||
|
self._memory[offset:offset + self._block_size] = data
|
||||||
|
|
||||||
|
return self._make_response(b"")
|
||||||
|
|
||||||
|
def _handle_system_info(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle Get System Information."""
|
||||||
|
info_flags = SYSINFO_DSFID | SYSINFO_AFI | SYSINFO_MEMSIZE | SYSINFO_ICREF
|
||||||
|
resp = bytes([0x00, info_flags])
|
||||||
|
resp += self._uid[::-1] # UID LSB first
|
||||||
|
resp += bytes([self._dsfid]) # DSFID
|
||||||
|
resp += bytes([self._afi]) # AFI
|
||||||
|
resp += bytes([self._num_blocks - 1, self._block_size - 1]) # mem size
|
||||||
|
resp += bytes([self._ic_reference]) # IC reference
|
||||||
|
return RFFrame.from_bytes(resp)
|
||||||
|
|
||||||
|
def _extract_block_number(self, frame: RFFrame) -> int | None:
|
||||||
|
"""Extract block number from frame, accounting for addressed mode."""
|
||||||
|
flags = frame.data[0]
|
||||||
|
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
|
||||||
|
if is_addressed:
|
||||||
|
if len(frame.data) < 11:
|
||||||
|
return None
|
||||||
|
return frame.data[10]
|
||||||
|
else:
|
||||||
|
if len(frame.data) < 3:
|
||||||
|
return None
|
||||||
|
return frame.data[2]
|
||||||
|
|
||||||
|
def _extract_block_number_ext(self, frame: RFFrame) -> int | None:
|
||||||
|
"""Extract 2-byte (LE16) block number for extended commands."""
|
||||||
|
flags = frame.data[0]
|
||||||
|
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
|
||||||
|
offset = 10 if is_addressed else 2
|
||||||
|
if len(frame.data) < offset + 2:
|
||||||
|
return None
|
||||||
|
return struct.unpack_from("<H", frame.data, offset)[0]
|
||||||
|
|
||||||
|
def _handle_extended_read_single(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle Extended Read Single Block (0x30) — 2-byte block address."""
|
||||||
|
block = self._extract_block_number_ext(frame)
|
||||||
|
if block is None:
|
||||||
|
return None
|
||||||
|
if block >= self._num_blocks:
|
||||||
|
return self._make_error(0x10) # block not available
|
||||||
|
|
||||||
|
offset = block * self._block_size
|
||||||
|
data = self._memory[offset:offset + self._block_size]
|
||||||
|
return self._make_response(bytes(data))
|
||||||
|
|
||||||
|
def _handle_extended_write_single(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle Extended Write Single Block (0x31) — 2-byte block address."""
|
||||||
|
flags = frame.data[0]
|
||||||
|
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
|
||||||
|
block_offset = 10 if is_addressed else 2
|
||||||
|
|
||||||
|
if len(frame.data) < block_offset + 2:
|
||||||
|
return None
|
||||||
|
block = struct.unpack_from("<H", frame.data, block_offset)[0]
|
||||||
|
if block >= self._num_blocks:
|
||||||
|
return self._make_error(0x10)
|
||||||
|
|
||||||
|
data_offset = block_offset + 2
|
||||||
|
data = frame.data[data_offset:data_offset + self._block_size]
|
||||||
|
if len(data) < self._block_size:
|
||||||
|
return self._make_error(0x0F)
|
||||||
|
|
||||||
|
mem_offset = block * self._block_size
|
||||||
|
self._memory[mem_offset:mem_offset + self._block_size] = data
|
||||||
|
return self._make_response(b"")
|
||||||
|
|
||||||
|
def _handle_extended_read_multiple(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle Extended Read Multiple Blocks (0x33) — 2-byte block address + count."""
|
||||||
|
flags = frame.data[0]
|
||||||
|
is_addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
|
||||||
|
offset = 10 if is_addressed else 2
|
||||||
|
|
||||||
|
if len(frame.data) < offset + 4:
|
||||||
|
return None
|
||||||
|
block = struct.unpack_from("<H", frame.data, offset)[0]
|
||||||
|
num_blocks = struct.unpack_from("<H", frame.data, offset + 2)[0] + 1
|
||||||
|
|
||||||
|
if block + num_blocks > self._num_blocks:
|
||||||
|
return self._make_error(0x10)
|
||||||
|
|
||||||
|
result = bytearray()
|
||||||
|
for i in range(num_blocks):
|
||||||
|
b = block + i
|
||||||
|
mem_off = b * self._block_size
|
||||||
|
result.extend(self._memory[mem_off:mem_off + self._block_size])
|
||||||
|
return self._make_response(bytes(result))
|
||||||
|
|
||||||
|
def _make_response(self, data: bytes) -> RFFrame:
|
||||||
|
"""Build a success response frame."""
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]) + data)
|
||||||
|
|
||||||
|
def _make_error(self, error_code: int) -> RFFrame:
|
||||||
|
"""Build an error response frame."""
|
||||||
|
return RFFrame.from_bytes(bytes([RESP_ERROR, error_code]))
|
||||||
|
|
||||||
|
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Extension point for vendor-specific commands (0xA0-0xDF).
|
||||||
|
Override in subclasses (e.g., NxpIcodeTag).
|
||||||
|
"""
|
||||||
|
return self._make_error(0x01) # not supported
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Reader15693 ----
|
||||||
|
|
||||||
|
class Reader15693(Reader):
|
||||||
|
"""ISO 15693 reader with inventory and block operations."""
|
||||||
|
|
||||||
|
async def run(self) -> dict:
|
||||||
|
tags = await self.inventory()
|
||||||
|
return {"tags": tags}
|
||||||
|
|
||||||
|
async def inventory(self, slots: int = 1) -> list[dict]:
|
||||||
|
"""Perform inventory. Returns list of {uid, dsfid}.
|
||||||
|
|
||||||
|
Uses 1-slot inventory with mask-based collision resolution:
|
||||||
|
when collision is detected, narrows the mask to isolate tags.
|
||||||
|
"""
|
||||||
|
tags: list[dict] = []
|
||||||
|
await self._inventory_recursive(tags, mask=b"", mask_bits=0)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
async def _inventory_recursive(self, tags: list[dict],
|
||||||
|
mask: bytes, mask_bits: int,
|
||||||
|
depth: int = 0) -> None:
|
||||||
|
"""Recursive inventory with mask narrowing on collision."""
|
||||||
|
if depth > 64: # max 64 UID bits
|
||||||
|
return
|
||||||
|
|
||||||
|
inv_flags = 0x26 # high data rate + inventory + 1 slot
|
||||||
|
mask_bytes_count = (mask_bits + 7) // 8
|
||||||
|
inv = bytes([inv_flags, CMD_INVENTORY, mask_bits]) + mask[:mask_bytes_count]
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(inv))
|
||||||
|
resp = await self._medium.receive_reader()
|
||||||
|
|
||||||
|
if resp is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if resp.has_collision and resp.collision_positions:
|
||||||
|
# Collision: find first collision bit in UID portion
|
||||||
|
# UID starts at byte 2 of response (bit 16 in MSB bitarray)
|
||||||
|
uid_bit_offset = 16 # flags(8) + dsfid(8)
|
||||||
|
col_in_uid = None
|
||||||
|
for pos in resp.collision_positions:
|
||||||
|
if pos >= uid_bit_offset:
|
||||||
|
col_in_uid = pos - uid_bit_offset
|
||||||
|
break
|
||||||
|
|
||||||
|
if col_in_uid is not None:
|
||||||
|
# Try both values at the collision bit
|
||||||
|
# Mask includes bits 0..col_in_uid (inclusive)
|
||||||
|
new_mask_bits = col_in_uid + 1
|
||||||
|
mask_byte_count = (new_mask_bits + 7) // 8
|
||||||
|
from bitarray import bitarray as ba
|
||||||
|
resp_bits = ba(resp.bits[:resp.bit_count])
|
||||||
|
|
||||||
|
for bit_val in (0, 1):
|
||||||
|
bits_copy = ba(resp_bits)
|
||||||
|
bits_copy[uid_bit_offset + col_in_uid] = bit_val
|
||||||
|
uid_bytes = bits_copy[uid_bit_offset:uid_bit_offset + mask_byte_count * 8].tobytes()
|
||||||
|
await self._inventory_recursive(tags, uid_bytes, new_mask_bits, depth + 1)
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(resp.data) >= 10 and resp.data[0] == 0x00:
|
||||||
|
uid = bytes(reversed(resp.data[2:10]))
|
||||||
|
dsfid = resp.data[1]
|
||||||
|
# Avoid duplicates
|
||||||
|
if not any(t["uid"] == uid for t in tags):
|
||||||
|
tags.append({"uid": uid, "dsfid": dsfid})
|
||||||
|
|
||||||
|
async def read_block(self, uid: bytes, block: int) -> dict:
|
||||||
|
"""Read a single block (addressed mode)."""
|
||||||
|
cmd = bytes([0x22, CMD_READ_SINGLE]) + uid[::-1] + bytes([block])
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(cmd))
|
||||||
|
resp = await self._medium.receive_reader()
|
||||||
|
if resp is None or resp.data[0] & RESP_ERROR:
|
||||||
|
return {"success": False, "data": None}
|
||||||
|
return {"success": True, "data": resp.data[1:]}
|
||||||
|
|
||||||
|
async def write_block(self, uid: bytes, block: int, data: bytes) -> dict:
|
||||||
|
"""Write a single block (addressed mode)."""
|
||||||
|
cmd = bytes([0x22, CMD_WRITE_SINGLE]) + uid[::-1] + bytes([block]) + data
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(cmd))
|
||||||
|
resp = await self._medium.receive_reader()
|
||||||
|
if resp is None or resp.data[0] & RESP_ERROR:
|
||||||
|
return {"success": False}
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
async def system_info(self, uid: bytes) -> dict:
|
||||||
|
"""Get System Information (addressed mode)."""
|
||||||
|
cmd = bytes([0x22, CMD_SYSTEM_INFO]) + uid[::-1]
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(cmd))
|
||||||
|
resp = await self._medium.receive_reader()
|
||||||
|
if resp is None or resp.data[0] & RESP_ERROR:
|
||||||
|
return {"uid": None}
|
||||||
|
|
||||||
|
data = resp.data
|
||||||
|
info_flags = data[1]
|
||||||
|
resp_uid = bytes(reversed(data[2:10]))
|
||||||
|
idx = 10
|
||||||
|
dsfid = data[idx] if info_flags & SYSINFO_DSFID else 0
|
||||||
|
idx += 1 if info_flags & SYSINFO_DSFID else 0
|
||||||
|
afi = data[idx] if info_flags & SYSINFO_AFI else 0
|
||||||
|
idx += 1 if info_flags & SYSINFO_AFI else 0
|
||||||
|
num_blocks = (data[idx] + 1) if info_flags & SYSINFO_MEMSIZE else 0
|
||||||
|
block_size = (data[idx + 1] + 1) if info_flags & SYSINFO_MEMSIZE else 0
|
||||||
|
idx += 2 if info_flags & SYSINFO_MEMSIZE else 0
|
||||||
|
ic_ref = data[idx] if info_flags & SYSINFO_ICREF else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"uid": resp_uid,
|
||||||
|
"dsfid": dsfid,
|
||||||
|
"afi": afi,
|
||||||
|
"num_blocks": num_blocks,
|
||||||
|
"block_size": block_size,
|
||||||
|
"ic_reference": ic_ref,
|
||||||
|
}
|
||||||
348
pm3py/transponders/hf/iso15693/nxp/auth_aes.py
Normal file
348
pm3py/transponders/hf/iso15693/nxp/auth_aes.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
"""NXP AES-128 key management mixin.
|
||||||
|
|
||||||
|
Reusable mixin for ICODE DNA and NTAG 5 Link/Boost AES auth:
|
||||||
|
- 4 key slots with key headers and privilege bitmasks
|
||||||
|
- Global Crypto Header (GCH) for access enforcement
|
||||||
|
- Authentication limit with failure counting
|
||||||
|
- TAM1 (Tag Authentication Method 1) — one-way tag authentication
|
||||||
|
- MAM1/MAM2 (Mutual Authentication Method) — two-phase mutual auth
|
||||||
|
- Helper methods for key state and privilege checks
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
from Crypto.Cipher import AES as _AES
|
||||||
|
_HAS_AES = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_AES = False
|
||||||
|
|
||||||
|
|
||||||
|
# Key header values
|
||||||
|
KEY_HEADER_NOT_ACTIVE = 0x81
|
||||||
|
KEY_HEADER_ACTIVE_LOCKED = 0xE7
|
||||||
|
KEY_HEADER_DISABLED = 0xFF
|
||||||
|
|
||||||
|
# Key privilege bits (Table 5)
|
||||||
|
PRIV_READ = 0x01
|
||||||
|
PRIV_WRITE = 0x02
|
||||||
|
PRIV_PRIVACY = 0x04
|
||||||
|
PRIV_DESTROY = 0x08
|
||||||
|
PRIV_EAS_AFI = 0x10
|
||||||
|
PRIV_CRYPTO_CONFIG = 0x20
|
||||||
|
PRIV_AREA1_READ = 0x40
|
||||||
|
PRIV_AREA1_WRITE = 0x80
|
||||||
|
|
||||||
|
# NFC Global Crypto Header values
|
||||||
|
GCH_DEACTIVATED = 0x81
|
||||||
|
GCH_DEACTIVATED_PRIV_LOCKED = 0x87
|
||||||
|
GCH_ACTIVATED = 0xC1
|
||||||
|
GCH_ACTIVATED_PRIV_LOCKED = 0xC7
|
||||||
|
GCH_FINAL = 0xE7
|
||||||
|
|
||||||
|
|
||||||
|
class NxpAesAuth:
|
||||||
|
"""Mixin providing AES-128 key management for NXP ICODE/NTAG 5 tags.
|
||||||
|
|
||||||
|
Call ``_init_aes_auth(aes_keys)`` from the concrete class's
|
||||||
|
``__init__`` to set up AES key state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _init_aes_auth(self, aes_keys: list[bytes | None] | None = None) -> None:
|
||||||
|
"""Initialize AES key management state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
aes_keys: optional list of up to 4 AES-128 keys (or None per slot).
|
||||||
|
"""
|
||||||
|
self._aes_keys: list[bytes | None] = list(aes_keys) if aes_keys else [None] * 4
|
||||||
|
self._aes_authenticated: set[int] = set()
|
||||||
|
|
||||||
|
# Key headers: controls whether key is active for authentication
|
||||||
|
self._key_headers: list[int] = [KEY_HEADER_NOT_ACTIVE] * 4
|
||||||
|
|
||||||
|
# Key privileges: bitmask per key defining what auth grants
|
||||||
|
self._key_privileges: list[int] = [0x00] * 4
|
||||||
|
|
||||||
|
# Global Crypto Header — controls enforcement of access conditions
|
||||||
|
self._nfc_gch: int = GCH_DEACTIVATED
|
||||||
|
|
||||||
|
# Authentication limit (0 = unlimited)
|
||||||
|
self._auth_limit: int = 0
|
||||||
|
self._auth_fail_count: int = 0
|
||||||
|
|
||||||
|
def is_key_active(self, key_id: int) -> bool:
|
||||||
|
"""Check if AES key slot is active (header == 0xE7)."""
|
||||||
|
return self._key_headers[key_id] == KEY_HEADER_ACTIVE_LOCKED
|
||||||
|
|
||||||
|
def has_privilege(self, key_id: int, privilege: int) -> bool:
|
||||||
|
"""Check if key_id is authenticated, active, and has the given privilege bit."""
|
||||||
|
if key_id not in self._aes_authenticated:
|
||||||
|
return False
|
||||||
|
if not self.is_key_active(key_id):
|
||||||
|
return False
|
||||||
|
return bool(self._key_privileges[key_id] & privilege)
|
||||||
|
|
||||||
|
def is_access_enforced(self) -> bool:
|
||||||
|
"""Check if GCH is activated (>= 0xC1), meaning access conditions are enforced."""
|
||||||
|
return self._nfc_gch >= GCH_ACTIVATED
|
||||||
|
|
||||||
|
# ----- TAM1 (Tag Authentication Method 1) -----
|
||||||
|
|
||||||
|
# Crypto constants — first 2 bytes of plaintext block
|
||||||
|
_C_TAM1 = b"\x96\xC5"
|
||||||
|
_C_MAM1 = b"\xDA\x83"
|
||||||
|
_C_MAM2_PURPOSE = b"\xDA\x80"
|
||||||
|
|
||||||
|
def _handle_challenge(self, frame) -> None:
|
||||||
|
"""Handle CHALLENGE (0x39) — compute and store TResponse.
|
||||||
|
|
||||||
|
Format: flags(1) cmd(1) CSI(1) AuthMethod(1) KeyID(1) IChallenge(10)
|
||||||
|
Tag computes: AES-ECB-ENC(key, C_TAM1 || TRnd || IChallenge)
|
||||||
|
Stores TResponse reversed (wire byte order).
|
||||||
|
Returns None — CHALLENGE has no RF response.
|
||||||
|
"""
|
||||||
|
self._tam1_response = None
|
||||||
|
|
||||||
|
if not _HAS_AES:
|
||||||
|
return
|
||||||
|
|
||||||
|
data = frame.data
|
||||||
|
if len(data) < 15: # flags + cmd + CSI + AuthMethod + KeyID + 10 IChallenge
|
||||||
|
return
|
||||||
|
|
||||||
|
auth_method = data[3]
|
||||||
|
if auth_method != 0x00: # TAM1 only
|
||||||
|
return
|
||||||
|
|
||||||
|
key_id = data[4]
|
||||||
|
if key_id > 3:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.is_key_active(key_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
key = self._aes_keys[key_id]
|
||||||
|
if key is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
ichallenge = data[5:15]
|
||||||
|
if len(ichallenge) != 10:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build plaintext: C_TAM1(2) + TRnd(4) + IChallenge(10) = 16 bytes
|
||||||
|
trnd = os.urandom(4)
|
||||||
|
plaintext = self._C_TAM1 + trnd + ichallenge
|
||||||
|
|
||||||
|
cipher = _AES.new(key, _AES.MODE_ECB)
|
||||||
|
tresponse = cipher.encrypt(plaintext)
|
||||||
|
|
||||||
|
# Store reversed for wire format
|
||||||
|
self._tam1_response = tresponse[::-1]
|
||||||
|
|
||||||
|
def _handle_readbuffer(self, frame):
|
||||||
|
"""Handle READBUFFER (0x3A) — return stored TResponse.
|
||||||
|
|
||||||
|
Format: flags(1) cmd(1)
|
||||||
|
Returns: flags(00) + TResponse_reversed(16 bytes)
|
||||||
|
Returns None if no TResponse is stored.
|
||||||
|
"""
|
||||||
|
from 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]))
|
||||||
116
pm3py/transponders/hf/iso15693/nxp/auth_password.py
Normal file
116
pm3py/transponders/hf/iso15693/nxp/auth_password.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""NXP XOR password authentication mixin.
|
||||||
|
|
||||||
|
Reusable mixin for ICODE SLIX/SLIX2/ICODE 3 password auth:
|
||||||
|
- Password storage, XOR verification, GET_RANDOM override
|
||||||
|
- SET_PASSWORD, WRITE_PASSWORD, LOCK_PASSWORD handlers
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from pm3py.sim.frame import RFFrame
|
||||||
|
|
||||||
|
|
||||||
|
class NxpPasswordAuth:
|
||||||
|
"""Mixin providing XOR password authentication for NXP ICODE tags.
|
||||||
|
|
||||||
|
Call ``_init_password_auth(passwords)`` from the concrete class's
|
||||||
|
``__init__`` to set up password state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _init_password_auth(self, passwords: dict[int, int | None]) -> None:
|
||||||
|
"""Initialize password authentication state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
passwords: mapping of password ID → 32-bit value (or None).
|
||||||
|
"""
|
||||||
|
self._passwords: dict[int, int | None] = dict(passwords)
|
||||||
|
self._authenticated_passwords: set[int] = set()
|
||||||
|
self._locked_passwords: set[int] = set()
|
||||||
|
self._last_random: bytes = b"\x00\x00"
|
||||||
|
|
||||||
|
# ----- GET_RANDOM (override to store last random) -----
|
||||||
|
|
||||||
|
def _handle_get_random(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
random_bytes = os.urandom(2)
|
||||||
|
self._last_random = random_bytes
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]) + random_bytes)
|
||||||
|
|
||||||
|
# ----- Password XOR helpers -----
|
||||||
|
|
||||||
|
def _verify_xor_password(self, pwd_id: int, xored_bytes: bytes) -> bool:
|
||||||
|
"""Verify XOR'd password against stored value."""
|
||||||
|
expected = self._passwords.get(pwd_id)
|
||||||
|
if expected is None:
|
||||||
|
return False
|
||||||
|
r = self._last_random
|
||||||
|
pwd_bytes = bytes([
|
||||||
|
xored_bytes[0] ^ r[0], xored_bytes[1] ^ r[1],
|
||||||
|
xored_bytes[2] ^ r[0], xored_bytes[3] ^ r[1],
|
||||||
|
])
|
||||||
|
pwd_value = struct.unpack("<I", pwd_bytes)[0]
|
||||||
|
return pwd_value == expected
|
||||||
|
|
||||||
|
def _decode_xor_password(self, xored_bytes: bytes) -> int:
|
||||||
|
"""Decode XOR'd password bytes to integer value."""
|
||||||
|
r = self._last_random
|
||||||
|
pwd_bytes = bytes([
|
||||||
|
xored_bytes[0] ^ r[0], xored_bytes[1] ^ r[1],
|
||||||
|
xored_bytes[2] ^ r[0], xored_bytes[3] ^ r[1],
|
||||||
|
])
|
||||||
|
return struct.unpack("<I", pwd_bytes)[0]
|
||||||
|
|
||||||
|
# ----- SET_PASSWORD -----
|
||||||
|
|
||||||
|
def _handle_set_password(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""SET_PASSWORD (0xB3): authenticate with XOR'd password."""
|
||||||
|
if len(frame.data) < 8:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
pwd_id = frame.data[3]
|
||||||
|
pwd_xored = frame.data[4:8]
|
||||||
|
|
||||||
|
if not self._verify_xor_password(pwd_id, pwd_xored):
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
self._authenticated_passwords.add(pwd_id)
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
# ----- WRITE PASSWORD -----
|
||||||
|
|
||||||
|
def _handle_write_password(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""WRITE PASSWORD (0xB4): change password value.
|
||||||
|
|
||||||
|
Requires prior SET_PASSWORD for the same pwd_id.
|
||||||
|
"""
|
||||||
|
if len(frame.data) < 8:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
pwd_id = frame.data[3]
|
||||||
|
|
||||||
|
if pwd_id not in self._authenticated_passwords:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
if pwd_id in self._locked_passwords:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
if self._passwords.get(pwd_id) is None:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
new_value = self._decode_xor_password(frame.data[4:8])
|
||||||
|
self._passwords[pwd_id] = new_value
|
||||||
|
self._authenticated_passwords.discard(pwd_id)
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
# ----- LOCK PASSWORD -----
|
||||||
|
|
||||||
|
def _handle_lock_password(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""LOCK PASSWORD (0xB5): permanently lock a password."""
|
||||||
|
if len(frame.data) < 4:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
pwd_id = frame.data[3]
|
||||||
|
if pwd_id not in self._authenticated_passwords:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
self._locked_passwords.add(pwd_id)
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
414
pm3py/transponders/hf/iso15693/nxp/icode3.py
Normal file
414
pm3py/transponders/hf/iso15693/nxp/icode3.py
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
"""ICODE 3 (SL2S3003) — NXP next-gen vicinity label IC.
|
||||||
|
|
||||||
|
Extends IcodeSlix2Tag with:
|
||||||
|
- 6th password (configuration, pwd_id 0x20)
|
||||||
|
- 76 user blocks, block 75 = 24-bit counter
|
||||||
|
- Configuration memory (128 blocks) via READ CONFIG / WRITE CONFIG
|
||||||
|
- TagTamper status via READ TT (TT variant)
|
||||||
|
- NFC ASCII mirror (UID, counter, TagTamper into user memory as hex)
|
||||||
|
- Privacy mode 2 (responds to inventory with zeroed/random UID)
|
||||||
|
- PICK RANDOM ID (0xC2) for privacy mode 2
|
||||||
|
- Richer feature flags in GET NXP SYSTEM INFO
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from pm3py.sim.frame import RFFrame
|
||||||
|
from .icode_slix2 import IcodeSlix2Tag, PWD_READ, PWD_WRITE
|
||||||
|
from .nxp_icode import NXP_MFG
|
||||||
|
|
||||||
|
# ICODE 3 password ID
|
||||||
|
PWD_CONFIG = 0x20
|
||||||
|
|
||||||
|
# Config memory password block addresses (read masks with 0x00)
|
||||||
|
_CONFIG_PWD_BLOCKS = {41, 42, 43, 44, 45, 46}
|
||||||
|
|
||||||
|
# ICODE 3 feature flags (Table 113)
|
||||||
|
_FEATURE_UM_PP = 1 << 0
|
||||||
|
_FEATURE_COUNTER = 1 << 1
|
||||||
|
_FEATURE_EAS_ID = 1 << 2
|
||||||
|
_FEATURE_EAS_PP = 1 << 3
|
||||||
|
_FEATURE_AFI_PP = 1 << 4
|
||||||
|
_FEATURE_INVENTORY_READ_EXT = 1 << 5
|
||||||
|
_FEATURE_EAS_IR = 1 << 6
|
||||||
|
_FEATURE_CID = 1 << 7
|
||||||
|
_FEATURE_ORIGINALITY_SIG = 1 << 8
|
||||||
|
_FEATURE_TAGTAMPER = 1 << 9
|
||||||
|
_FEATURE_P_QUIET = 1 << 10
|
||||||
|
_FEATURE_NFC_MIRROR = 1 << 11
|
||||||
|
_FEATURE_PRIVACY = 1 << 12
|
||||||
|
_FEATURE_DESTROY = 1 << 13
|
||||||
|
_FEATURE_WRITE_CID = 1 << 14
|
||||||
|
_FEATURE_HIGH_DATA_RATES = 1 << 15
|
||||||
|
|
||||||
|
|
||||||
|
class Icode3Tag(IcodeSlix2Tag):
|
||||||
|
"""ICODE 3 transponder (SL2S3003 / SL2S3003TT).
|
||||||
|
|
||||||
|
Extends IcodeSlix2Tag with configuration memory, 6th password,
|
||||||
|
24-bit counter, and TagTamper support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ICODE 3: TAG type 0x01, type indicator bits 39:36 = 0100 → byte bits 6:3 = 0100 → 0x20
|
||||||
|
_uid_prefix = b"\xE0\x04\x01\x20"
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes | str | None = None,
|
||||||
|
config_password: int | None = None,
|
||||||
|
tag_tamper: bool = False,
|
||||||
|
signature: bytes | None = None,
|
||||||
|
**kwargs):
|
||||||
|
kwargs.setdefault("num_blocks", 76)
|
||||||
|
super().__init__(uid=uid, signature=signature, **kwargs)
|
||||||
|
|
||||||
|
# 48-byte signature mode (OS_CFG_MODE bit, SL2S3003 8.5.3.20)
|
||||||
|
self._signature_48 = False
|
||||||
|
|
||||||
|
# 6th password
|
||||||
|
self._passwords[PWD_CONFIG] = config_password
|
||||||
|
|
||||||
|
# Configuration memory: 128 blocks × 4 bytes
|
||||||
|
self._config_memory = bytearray(128 * 4)
|
||||||
|
self._config_password_protected = False
|
||||||
|
self._config_locked = False
|
||||||
|
|
||||||
|
# Initialize config memory with defaults
|
||||||
|
self._init_config_defaults()
|
||||||
|
|
||||||
|
# TagTamper (TT variant only)
|
||||||
|
self._tag_tamper_supported = tag_tamper
|
||||||
|
self._tt_status_actual = 0x43 # 'C' = Closed
|
||||||
|
self._tt_status_stored = 0x43
|
||||||
|
|
||||||
|
# NFC ASCII mirror
|
||||||
|
self._nfc_mirror_sel = 0 # 0=off, 1=UID, 2=UID+counter, 3=UID+counter+TT
|
||||||
|
self._nfc_mirror_block = 0
|
||||||
|
self._nfc_mirror_byte = 0
|
||||||
|
|
||||||
|
# Privacy mode 2 support
|
||||||
|
self._privacy_mode_sel = 1 # 1=mode 1 (strict), 2=mode 2 (permissive)
|
||||||
|
self._random_id: bytes | None = None # set by PICK RANDOM ID
|
||||||
|
|
||||||
|
# Rebuild access map for 24-bit counter on block 75
|
||||||
|
self._build_access_map()
|
||||||
|
|
||||||
|
def set_nfc_mirror(self, sel: int, block: int, byte_offset: int = 0) -> None:
|
||||||
|
"""Configure NFC ASCII mirror.
|
||||||
|
|
||||||
|
sel: 0=off, 1=UID, 2=UID+counter, 3=UID+counter+TT msg (TT only)
|
||||||
|
block: starting user memory block
|
||||||
|
byte_offset: starting byte within the block (0-3)
|
||||||
|
"""
|
||||||
|
self._nfc_mirror_sel = sel
|
||||||
|
self._nfc_mirror_block = block
|
||||||
|
self._nfc_mirror_byte = byte_offset
|
||||||
|
|
||||||
|
def _compute_mirror_data(self) -> bytes:
|
||||||
|
"""Compute the ASCII hex mirror data based on current settings."""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
if self._nfc_mirror_sel >= 1:
|
||||||
|
# UID: 8 bytes → 16 ASCII hex chars, MSB first
|
||||||
|
parts.append(self._uid.hex().upper().encode())
|
||||||
|
|
||||||
|
if self._nfc_mirror_sel >= 2:
|
||||||
|
# Separator + counter: 3 bytes → 6 ASCII hex chars, MSB first
|
||||||
|
parts.append(b"x")
|
||||||
|
offset = 75 * self._block_size
|
||||||
|
counter_le = self._memory[offset:offset + 3]
|
||||||
|
counter_val = int.from_bytes(counter_le, 'little')
|
||||||
|
parts.append(f"{counter_val:06X}".encode())
|
||||||
|
|
||||||
|
if self._nfc_mirror_sel >= 3 and self._tag_tamper_supported:
|
||||||
|
# Separator + TT open message (4 bytes from config block 47) as hex
|
||||||
|
parts.append(b"x")
|
||||||
|
tt_msg = self._config_memory[47 * 4:47 * 4 + 4]
|
||||||
|
parts.append(tt_msg.hex().upper().encode())
|
||||||
|
|
||||||
|
return b"".join(parts)
|
||||||
|
|
||||||
|
# ----- Read override for NFC mirror -----
|
||||||
|
|
||||||
|
def _handle_read_single(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Override to apply NFC mirror overlay on reads."""
|
||||||
|
if self._nfc_mirror_sel == 0:
|
||||||
|
return super()._handle_read_single(frame)
|
||||||
|
|
||||||
|
block = self._extract_block_number(frame)
|
||||||
|
if block is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check read protection (inherited)
|
||||||
|
if self._protection_pointer is not None and block >= self._protection_pointer:
|
||||||
|
if PWD_READ not in self._authenticated_passwords:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
# Get physical data
|
||||||
|
offset = block * self._block_size
|
||||||
|
phys = bytearray(self._memory[offset:offset + self._block_size])
|
||||||
|
|
||||||
|
# Apply mirror overlay
|
||||||
|
mirror_data = self._compute_mirror_data()
|
||||||
|
mirror_start_abs = self._nfc_mirror_block * self._block_size + self._nfc_mirror_byte
|
||||||
|
mirror_end_abs = mirror_start_abs + len(mirror_data)
|
||||||
|
|
||||||
|
block_start_abs = block * self._block_size
|
||||||
|
block_end_abs = block_start_abs + self._block_size
|
||||||
|
|
||||||
|
# Check overlap
|
||||||
|
overlap_start = max(mirror_start_abs, block_start_abs)
|
||||||
|
overlap_end = min(mirror_end_abs, block_end_abs)
|
||||||
|
|
||||||
|
if overlap_start < overlap_end:
|
||||||
|
mirror_offset = overlap_start - mirror_start_abs
|
||||||
|
block_offset = overlap_start - block_start_abs
|
||||||
|
length = overlap_end - overlap_start
|
||||||
|
phys[block_offset:block_offset + length] = mirror_data[mirror_offset:mirror_offset + length]
|
||||||
|
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]) + bytes(phys))
|
||||||
|
|
||||||
|
# ----- Privacy mode 2 override -----
|
||||||
|
|
||||||
|
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if self._destroyed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(frame.data) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cmd = frame.data[1]
|
||||||
|
|
||||||
|
if self._privacy_mode:
|
||||||
|
if self._privacy_mode_sel == 2:
|
||||||
|
return await self._handle_privacy_mode2(frame, cmd)
|
||||||
|
else:
|
||||||
|
# Mode 1: strict — only GET_RANDOM + SET_PASSWORD
|
||||||
|
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
||||||
|
if cmd == 0xB2:
|
||||||
|
return self._handle_get_random(frame)
|
||||||
|
if cmd == 0xB3:
|
||||||
|
return self._handle_set_password(frame)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await super(IcodeSlix2Tag, self).handle_frame(frame)
|
||||||
|
|
||||||
|
async def _handle_privacy_mode2(self, frame: RFFrame, cmd: int) -> RFFrame | None:
|
||||||
|
"""Privacy mode 2: allows inventory (zeroed/random UID), reads, system info, etc."""
|
||||||
|
# Swap UID temporarily for inventory/system info
|
||||||
|
real_uid = self._uid
|
||||||
|
display_uid = self._random_id if self._random_id else b"\xE0\x04\x00\x00\x00\x00\x00\x00"
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._uid = display_uid
|
||||||
|
|
||||||
|
# Custom commands that are allowed
|
||||||
|
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
||||||
|
if cmd == 0xB2: # GET_RANDOM
|
||||||
|
return self._handle_get_random(frame)
|
||||||
|
if cmd == 0xB3: # SET_PASSWORD (unlocks privacy)
|
||||||
|
result = self._handle_set_password(frame)
|
||||||
|
if result and result.data[0] == 0x00 and frame.data[3] == 0x04:
|
||||||
|
self._privacy_mode = False
|
||||||
|
self._random_id = None
|
||||||
|
return result
|
||||||
|
if cmd == 0xBC: # STAY QUIET PERSISTENT
|
||||||
|
frame_n = self._normalize_custom_frame(frame)
|
||||||
|
return self._handle_stay_quiet_persistent(frame_n)
|
||||||
|
if cmd == 0xC2: # PICK RANDOM ID
|
||||||
|
return self._handle_pick_random_id(frame)
|
||||||
|
|
||||||
|
# Standard ISO commands allowed in mode 2
|
||||||
|
allowed_std = {0x01, 0x02, 0x20, 0x23, 0x25, 0x26, 0x2B}
|
||||||
|
if cmd in allowed_std:
|
||||||
|
return await super(IcodeSlix2Tag, self).handle_frame(frame)
|
||||||
|
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
self._uid = real_uid
|
||||||
|
|
||||||
|
def _handle_set_password(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Override: in privacy mode 2, SET_PASSWORD with privacy pwd exits privacy."""
|
||||||
|
result = super()._handle_set_password(frame)
|
||||||
|
if (result is not None and result.data[0] == 0x00 and
|
||||||
|
self._privacy_mode and frame.data[3] == 0x04):
|
||||||
|
self._privacy_mode = False
|
||||||
|
self._random_id = None
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _init_config_defaults(self):
|
||||||
|
"""Initialize config memory with factory defaults."""
|
||||||
|
# Block 16: DSFID
|
||||||
|
self._config_memory[16 * 4] = self._dsfid
|
||||||
|
# Block 17: AFI
|
||||||
|
self._config_memory[17 * 4] = self._afi
|
||||||
|
|
||||||
|
# Store passwords in config memory (write-only, read-masked)
|
||||||
|
for pwd_id, cfg_block in [(PWD_CONFIG, 41), (PWD_READ, 42),
|
||||||
|
(PWD_WRITE, 43), (0x04, 44),
|
||||||
|
(0x08, 45), (0x10, 46)]:
|
||||||
|
pwd = self._passwords.get(pwd_id)
|
||||||
|
if pwd is not None:
|
||||||
|
offset = cfg_block * 4
|
||||||
|
struct.pack_into("<I", self._config_memory, offset, pwd)
|
||||||
|
|
||||||
|
def _build_access_map(self) -> None:
|
||||||
|
"""Override: block 75 = 24-bit counter (not block 79 = 16-bit)."""
|
||||||
|
has_pp = self._protection_pointer is not None
|
||||||
|
from pm3py.sim.memory import BlockAccess
|
||||||
|
user = self.regions["user"]
|
||||||
|
access_map = []
|
||||||
|
for block in range(self._num_blocks):
|
||||||
|
ba = BlockAccess()
|
||||||
|
if has_pp and block >= self._protection_pointer:
|
||||||
|
ba = BlockAccess(read="password", read_key=PWD_READ)
|
||||||
|
if block == 75:
|
||||||
|
ba = BlockAccess(read=ba.read, read_key=ba.read_key,
|
||||||
|
write_mode="counter24")
|
||||||
|
access_map.append(ba)
|
||||||
|
user.access_map = access_map
|
||||||
|
|
||||||
|
# ----- 48-byte originality signature (SL2S3003 8.5.3.20) -----
|
||||||
|
|
||||||
|
def set_signature_mode_48(self, enabled: bool) -> None:
|
||||||
|
"""Enable/disable 48-byte originality signature mode (OS_CFG_MODE)."""
|
||||||
|
self._signature_48 = enabled
|
||||||
|
|
||||||
|
def _handle_read_signature(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""READ SIGNATURE: returns 32 or 48 bytes depending on OS_CFG_MODE."""
|
||||||
|
if self._signature_48:
|
||||||
|
# Pad/extend signature to 48 bytes
|
||||||
|
sig = (self._signature + bytes(48))[:48]
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]) + sig)
|
||||||
|
# Default 32-byte mode: truncate to 32 bytes
|
||||||
|
sig = (self._signature + bytes(32))[:32]
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]) + sig)
|
||||||
|
|
||||||
|
# ----- Command dispatch (ICODE 3-specific) -----
|
||||||
|
|
||||||
|
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
|
||||||
|
frame = self._normalize_custom_frame(frame)
|
||||||
|
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
||||||
|
match cmd:
|
||||||
|
case 0xC0: # READ CONFIG
|
||||||
|
return self._handle_read_config(frame)
|
||||||
|
case 0xC1: # WRITE CONFIG
|
||||||
|
return self._handle_write_config(frame)
|
||||||
|
case 0xC2: # PICK RANDOM ID
|
||||||
|
return self._handle_pick_random_id(frame)
|
||||||
|
case 0xC4: # READ TT
|
||||||
|
return self._handle_read_tt(frame)
|
||||||
|
case 0xAB: # GET NXP SYSTEM INFO — override with ICODE 3 data
|
||||||
|
return self._handle_get_nxp_system_info(frame)
|
||||||
|
return super()._handle_custom_command(cmd, frame)
|
||||||
|
|
||||||
|
# ----- PICK RANDOM ID (0xC2) -----
|
||||||
|
|
||||||
|
def _handle_pick_random_id(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""PICK RANDOM ID (0xC2): generate random UID for privacy mode 2.
|
||||||
|
|
||||||
|
Random ID format: E0 04 00 00 CID1 CID0 RID1 RID0
|
||||||
|
CID = customer ID from config, RID = random 16-bit.
|
||||||
|
"""
|
||||||
|
if not self._privacy_mode:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
# CID from config memory block 19 (CID0, CID1)
|
||||||
|
cid0 = self._config_memory[19 * 4]
|
||||||
|
cid1 = self._config_memory[19 * 4 + 1]
|
||||||
|
|
||||||
|
rid = os.urandom(2)
|
||||||
|
self._random_id = bytes([0xE0, 0x04, 0x00, 0x00, cid1, cid0, rid[0], rid[1]])
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
# ----- READ CONFIG (0xC0) -----
|
||||||
|
|
||||||
|
def _handle_read_config(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""READ CONFIG (0xC0): read config memory blocks.
|
||||||
|
|
||||||
|
Format: flags(1) + cmd(1) + mfg(1) + block_addr(1) + num_blocks(1)
|
||||||
|
Response: flags(1) + data(4 * (num_blocks+1))
|
||||||
|
Passwords (blocks 41-46) are masked with 0x00.
|
||||||
|
"""
|
||||||
|
if len(frame.data) < 5:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
block_addr = frame.data[3]
|
||||||
|
num_blocks = frame.data[4] + 1 # 0 = 1 block
|
||||||
|
|
||||||
|
if block_addr + num_blocks > 128:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
resp = bytearray([0x00]) # flags
|
||||||
|
for blk in range(block_addr, block_addr + num_blocks):
|
||||||
|
offset = blk * 4
|
||||||
|
if blk in _CONFIG_PWD_BLOCKS:
|
||||||
|
resp.extend(bytes(4)) # masked
|
||||||
|
else:
|
||||||
|
resp.extend(self._config_memory[offset:offset + 4])
|
||||||
|
|
||||||
|
return RFFrame.from_bytes(bytes(resp))
|
||||||
|
|
||||||
|
# ----- WRITE CONFIG (0xC1) -----
|
||||||
|
|
||||||
|
def _handle_write_config(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""WRITE CONFIG (0xC1): write 4 bytes to config memory block.
|
||||||
|
|
||||||
|
Format: flags(1) + cmd(1) + mfg(1) + block_addr(1) + data(4)
|
||||||
|
"""
|
||||||
|
if len(frame.data) < 8:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
if self._config_locked:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
if self._config_password_protected:
|
||||||
|
if PWD_CONFIG not in self._authenticated_passwords:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
block_addr = frame.data[3]
|
||||||
|
if block_addr >= 128:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
data = frame.data[4:8]
|
||||||
|
offset = block_addr * 4
|
||||||
|
self._config_memory[offset:offset + 4] = data
|
||||||
|
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
# ----- READ TT (0xC4) -----
|
||||||
|
|
||||||
|
def _handle_read_tt(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""READ TT (0xC4): TagTamper status.
|
||||||
|
|
||||||
|
Response: flags(1) + actual_status(1) + stored_status(1)
|
||||||
|
"""
|
||||||
|
if not self._tag_tamper_supported:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return RFFrame.from_bytes(bytes([
|
||||||
|
0x00, self._tt_status_actual, self._tt_status_stored
|
||||||
|
]))
|
||||||
|
|
||||||
|
# ----- GET NXP SYSTEM INFO override -----
|
||||||
|
|
||||||
|
def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""GET NXP SYSTEM INFO with ICODE 3 feature flags."""
|
||||||
|
pp = self._protection_pointer if self._protection_pointer is not None else 0
|
||||||
|
pp_cond = 0x00
|
||||||
|
if self._protection_pointer is not None:
|
||||||
|
pp_cond = 0x01 # read protection
|
||||||
|
|
||||||
|
lock_bits = 0x00
|
||||||
|
feature_flags = (
|
||||||
|
_FEATURE_UM_PP | _FEATURE_COUNTER | _FEATURE_EAS_ID |
|
||||||
|
_FEATURE_ORIGINALITY_SIG | _FEATURE_P_QUIET |
|
||||||
|
_FEATURE_PRIVACY | _FEATURE_DESTROY | _FEATURE_HIGH_DATA_RATES
|
||||||
|
)
|
||||||
|
if self._tag_tamper_supported:
|
||||||
|
feature_flags |= _FEATURE_TAGTAMPER
|
||||||
|
|
||||||
|
return RFFrame.from_bytes(
|
||||||
|
bytes([0x00, pp, pp_cond, lock_bits]) +
|
||||||
|
struct.pack("<I", feature_flags)
|
||||||
|
)
|
||||||
276
pm3py/transponders/hf/iso15693/nxp/icode_dna.py
Normal file
276
pm3py/transponders/hf/iso15693/nxp/icode_dna.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"""ICODE DNA (SL2S6002) — NXP IC with AES-128 mutual authentication.
|
||||||
|
|
||||||
|
Extends IcodeSlix2Tag with:
|
||||||
|
- AES-128 crypto coprocessor (4 keys: Key0-Key3, TAM/MAM)
|
||||||
|
- 64 blocks (63 user + block 63 = 16-bit counter)
|
||||||
|
- 48-block configuration memory (READ/WRITE CONFIG)
|
||||||
|
- Key headers, key privileges, authentication limit
|
||||||
|
- Flexible memory segmentation with AES-based access conditions
|
||||||
|
- No TagTamper, no NFC mirror (those are ICODE 3 features)
|
||||||
|
|
||||||
|
TAM/MAM authentication uses undocumented AuthMethod values:
|
||||||
|
0x00 = TAM1, 0x02 = MAM1, 0x06 = MAM2
|
||||||
|
Standard 0x80/0x90 NOT supported (Error 0x0F).
|
||||||
|
All data byte-reversed on wire. See docs/NTAG5_SECURITY.md.
|
||||||
|
|
||||||
|
The ICODE DNA shares the AES auth protocol with NTAG 5 Link (NTP5332)
|
||||||
|
and NTAG 5 Boost (NTA5332).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from .auth_aes import (
|
||||||
|
NxpAesAuth,
|
||||||
|
KEY_HEADER_NOT_ACTIVE,
|
||||||
|
KEY_HEADER_ACTIVE_LOCKED,
|
||||||
|
KEY_HEADER_DISABLED,
|
||||||
|
PRIV_READ,
|
||||||
|
PRIV_WRITE,
|
||||||
|
PRIV_PRIVACY,
|
||||||
|
PRIV_DESTROY,
|
||||||
|
PRIV_EAS_AFI,
|
||||||
|
PRIV_CRYPTO_CONFIG,
|
||||||
|
PRIV_AREA1_READ,
|
||||||
|
PRIV_AREA1_WRITE,
|
||||||
|
GCH_DEACTIVATED,
|
||||||
|
GCH_DEACTIVATED_PRIV_LOCKED,
|
||||||
|
GCH_ACTIVATED,
|
||||||
|
GCH_ACTIVATED_PRIV_LOCKED,
|
||||||
|
GCH_FINAL,
|
||||||
|
)
|
||||||
|
from pm3py.sim.frame import RFFrame
|
||||||
|
from .icode_slix2 import IcodeSlix2Tag, PWD_READ
|
||||||
|
from .nxp_icode import NXP_MFG
|
||||||
|
|
||||||
|
# Config memory password/key block addresses (read-masked with 0x00)
|
||||||
|
_CONFIG_KEY_BLOCKS = set(range(0x20, 0x30)) # KEY0-KEY3 (4 blocks each)
|
||||||
|
|
||||||
|
# Config Header values
|
||||||
|
CONFIG_HEADER_WRITABLE = 0x81
|
||||||
|
CONFIG_HEADER_LOCKED = 0xE7
|
||||||
|
|
||||||
|
|
||||||
|
class IcodeDnaTag(NxpAesAuth, IcodeSlix2Tag):
|
||||||
|
"""ICODE DNA transponder (SL2S6002).
|
||||||
|
|
||||||
|
Features beyond IcodeSlix2Tag:
|
||||||
|
- AES-128 tag/mutual authentication (4 keys)
|
||||||
|
- 64 user blocks, block 63 = 16-bit counter
|
||||||
|
- 48-block configuration memory
|
||||||
|
- Key headers, privileges, and authentication limit
|
||||||
|
|
||||||
|
Hierarchy: NxpIcodeTag → IcodeSlixTag → IcodeSlix2Tag → IcodeDnaTag
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Verified: real DNA UID E0 04 01 18 00 9C 78 2B → TagInfo = "ICODE DNA (SL2S6002)"
|
||||||
|
_uid_prefix = b"\xE0\x04\x01\x18"
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes | str | None = None,
|
||||||
|
aes_keys: list[bytes | None] | None = None,
|
||||||
|
**kwargs):
|
||||||
|
kwargs.setdefault("num_blocks", 64)
|
||||||
|
kwargs.setdefault("ic_reference", 0x01)
|
||||||
|
super().__init__(uid=uid, **kwargs)
|
||||||
|
|
||||||
|
# Configuration memory: 48 blocks × 4 bytes
|
||||||
|
self._config_memory = bytearray(48 * 4)
|
||||||
|
|
||||||
|
# AES key management (from NxpAesAuth mixin)
|
||||||
|
self._init_aes_auth(aes_keys=aes_keys)
|
||||||
|
|
||||||
|
# Config Header — controls lock state of signature + CID
|
||||||
|
self._config_header = CONFIG_HEADER_WRITABLE
|
||||||
|
|
||||||
|
# CID (Customer ID) — default 0xC000
|
||||||
|
self._cid = 0xC000
|
||||||
|
|
||||||
|
# Initialize config memory defaults
|
||||||
|
self._init_dna_config()
|
||||||
|
|
||||||
|
def _init_dna_config(self):
|
||||||
|
"""Initialize config memory with DNA factory defaults."""
|
||||||
|
# Blocks 0x00-0x07: Originality signature (32 bytes)
|
||||||
|
sig = getattr(self, '_signature', bytes(32))
|
||||||
|
self._config_memory[0:32] = sig
|
||||||
|
|
||||||
|
# Block 0x08: Config Header
|
||||||
|
self._config_memory[0x08 * 4] = self._config_header
|
||||||
|
|
||||||
|
# Block 0x09: CID
|
||||||
|
struct.pack_into("<H", self._config_memory, 0x09 * 4, self._cid)
|
||||||
|
|
||||||
|
# Blocks 0x10, 0x12, 0x14, 0x16: Key Headers (byte 1)
|
||||||
|
for i in range(4):
|
||||||
|
block = 0x10 + i * 2
|
||||||
|
self._config_memory[block * 4 + 1] = self._key_headers[i]
|
||||||
|
|
||||||
|
# Blocks 0x11, 0x13, 0x15, 0x17: Key Privileges (byte 0)
|
||||||
|
for i in range(4):
|
||||||
|
block = 0x11 + i * 2
|
||||||
|
self._config_memory[block * 4] = self._key_privileges[i]
|
||||||
|
|
||||||
|
# Blocks 0x20-0x2F: AES keys (4 × 16 bytes)
|
||||||
|
for i, key in enumerate(self._aes_keys):
|
||||||
|
if key is not None:
|
||||||
|
offset = (0x20 + i * 4) * 4
|
||||||
|
self._config_memory[offset:offset + 16] = key[:16]
|
||||||
|
|
||||||
|
def _build_access_map(self) -> None:
|
||||||
|
"""Override: counter on last block."""
|
||||||
|
has_pp = self._protection_pointer is not None
|
||||||
|
last_block = self._num_blocks - 1
|
||||||
|
|
||||||
|
if not has_pp and last_block < 0:
|
||||||
|
self.regions["user"].access_map = None
|
||||||
|
return
|
||||||
|
|
||||||
|
from pm3py.sim.memory import BlockAccess
|
||||||
|
user = self.regions["user"]
|
||||||
|
access_map = []
|
||||||
|
for block in range(self._num_blocks):
|
||||||
|
ba = BlockAccess()
|
||||||
|
if has_pp and block >= self._protection_pointer:
|
||||||
|
ba = BlockAccess(read="password", read_key=PWD_READ)
|
||||||
|
if block == last_block:
|
||||||
|
ba = BlockAccess(read=ba.read, read_key=ba.read_key,
|
||||||
|
write_mode="counter")
|
||||||
|
access_map.append(ba)
|
||||||
|
user.access_map = access_map
|
||||||
|
|
||||||
|
# ----- Command dispatch -----
|
||||||
|
|
||||||
|
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Override to intercept ISO 29167-10 CHALLENGE/READBUFFER/AUTHENTICATE.
|
||||||
|
|
||||||
|
Commands 0x35 (AUTHENTICATE), 0x39 (CHALLENGE) and 0x3A (READBUFFER)
|
||||||
|
are standard ISO commands, not NXP custom (>= 0xA0), so they must be
|
||||||
|
caught here before the base class returns "not supported".
|
||||||
|
"""
|
||||||
|
if len(frame.data) >= 2:
|
||||||
|
cmd = frame.data[1]
|
||||||
|
if cmd == 0x35: # AUTHENTICATE (MAM1/MAM2)
|
||||||
|
return self._handle_authenticate(frame)
|
||||||
|
if cmd == 0x39: # CHALLENGE
|
||||||
|
self._handle_challenge(frame)
|
||||||
|
return None # no RF response
|
||||||
|
if cmd == 0x3A: # READBUFFER
|
||||||
|
return self._handle_readbuffer(frame)
|
||||||
|
return await super().handle_frame(frame)
|
||||||
|
|
||||||
|
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
|
||||||
|
frame = self._normalize_custom_frame(frame)
|
||||||
|
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
||||||
|
match cmd:
|
||||||
|
case 0xC0: # READ CONFIG
|
||||||
|
return self._handle_read_config(frame)
|
||||||
|
case 0xC1: # WRITE CONFIG
|
||||||
|
return self._handle_write_config(frame)
|
||||||
|
return super()._handle_custom_command(cmd, frame)
|
||||||
|
|
||||||
|
# ----- READ CONFIG -----
|
||||||
|
|
||||||
|
def _handle_read_config(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""READ CONFIG (0xC0): read config memory blocks.
|
||||||
|
|
||||||
|
Key blocks (0x20-0x2F) masked with 0x00 when key header is active (0xE7).
|
||||||
|
"""
|
||||||
|
if len(frame.data) < 5:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
block_addr = frame.data[3]
|
||||||
|
num_blocks = frame.data[4] + 1
|
||||||
|
|
||||||
|
if block_addr + num_blocks > 48:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
resp = bytearray([0x00])
|
||||||
|
for blk in range(block_addr, block_addr + num_blocks):
|
||||||
|
offset = blk * 4
|
||||||
|
# Mask key storage when header is active/locked
|
||||||
|
if blk in _CONFIG_KEY_BLOCKS:
|
||||||
|
key_idx = (blk - 0x20) // 4
|
||||||
|
if key_idx < 4 and self._key_headers[key_idx] == KEY_HEADER_ACTIVE_LOCKED:
|
||||||
|
resp.extend(bytes(4))
|
||||||
|
continue
|
||||||
|
resp.extend(self._config_memory[offset:offset + 4])
|
||||||
|
|
||||||
|
return RFFrame.from_bytes(bytes(resp))
|
||||||
|
|
||||||
|
# ----- WRITE CONFIG -----
|
||||||
|
|
||||||
|
def _handle_write_config(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""WRITE CONFIG (0xC1): write 4 bytes to config block."""
|
||||||
|
if len(frame.data) < 8:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
block_addr = frame.data[3]
|
||||||
|
if block_addr >= 48:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
data = frame.data[4:8]
|
||||||
|
|
||||||
|
# Key storage: reject writes when header is active
|
||||||
|
if block_addr in _CONFIG_KEY_BLOCKS:
|
||||||
|
key_idx = (block_addr - 0x20) // 4
|
||||||
|
if key_idx < 4 and self._key_headers[key_idx] == KEY_HEADER_ACTIVE_LOCKED:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
offset = block_addr * 4
|
||||||
|
self._config_memory[offset:offset + 4] = data
|
||||||
|
|
||||||
|
# Track key header changes
|
||||||
|
kh_blocks = {0x10: 0, 0x12: 1, 0x14: 2, 0x16: 3}
|
||||||
|
if block_addr in kh_blocks:
|
||||||
|
key_idx = kh_blocks[block_addr]
|
||||||
|
new_header = data[1] # Key header is byte 1
|
||||||
|
# One-way: only allow lower → higher values
|
||||||
|
if new_header >= self._key_headers[key_idx]:
|
||||||
|
self._key_headers[key_idx] = new_header
|
||||||
|
|
||||||
|
# Track key privilege changes
|
||||||
|
kp_blocks = {0x11: 0, 0x13: 1, 0x15: 2, 0x17: 3}
|
||||||
|
if block_addr in kp_blocks:
|
||||||
|
key_idx = kp_blocks[block_addr]
|
||||||
|
self._key_privileges[key_idx] = data[0]
|
||||||
|
|
||||||
|
# Track GCH changes (block 0x0C, byte 1)
|
||||||
|
if block_addr == 0x0C:
|
||||||
|
new_gch = data[1]
|
||||||
|
if new_gch >= self._nfc_gch: # one-way
|
||||||
|
self._nfc_gch = new_gch
|
||||||
|
|
||||||
|
# Track config header changes (block 0x08, byte 0)
|
||||||
|
if block_addr == 0x08:
|
||||||
|
new_ch = data[0]
|
||||||
|
if new_ch >= self._config_header: # one-way
|
||||||
|
self._config_header = new_ch
|
||||||
|
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
# ----- GET NXP SYSTEM INFO override -----
|
||||||
|
|
||||||
|
def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""GET NXP SYSTEM INFO with DNA feature flags."""
|
||||||
|
pp = self._protection_pointer if self._protection_pointer is not None else 0
|
||||||
|
pp_cond = 0x01 if self._protection_pointer is not None else 0x00
|
||||||
|
lock_bits = 0x00
|
||||||
|
|
||||||
|
# DNA feature flags (similar to ICODE 3 Table 113 but without TagTamper/mirror)
|
||||||
|
feature_flags = (
|
||||||
|
(1 << 0) | # UM_PP
|
||||||
|
(1 << 1) | # COUNTER
|
||||||
|
(1 << 2) | # EAS_ID
|
||||||
|
(1 << 3) | # EAS_PP
|
||||||
|
(1 << 4) | # AFI_PP
|
||||||
|
(1 << 8) | # ORIGINALITY_SIG
|
||||||
|
(1 << 10) | # P_QUIET
|
||||||
|
(1 << 12) | # PRIVACY
|
||||||
|
(1 << 13) | # DESTROY
|
||||||
|
(1 << 15) # HIGH_DATA_RATES
|
||||||
|
)
|
||||||
|
|
||||||
|
return RFFrame.from_bytes(
|
||||||
|
bytes([0x00, pp, pp_cond, lock_bits]) +
|
||||||
|
struct.pack("<I", feature_flags)
|
||||||
|
)
|
||||||
169
pm3py/transponders/hf/iso15693/nxp/icode_slix.py
Normal file
169
pm3py/transponders/hf/iso15693/nxp/icode_slix.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""ICODE SLIX (SL2S2002) — NXP IC with EAS and single password.
|
||||||
|
|
||||||
|
This is the shared base for SLIX, SLIX2, and ICODE 3. Contains:
|
||||||
|
- EAS subsystem (SET/RESET/LOCK, ALARM, PROTECT EAS/AFI, WRITE EAS ID)
|
||||||
|
- INVENTORY READ / FAST INVENTORY READ
|
||||||
|
- Frame normalization for addressed custom commands
|
||||||
|
|
||||||
|
Password auth (SET_PASSWORD, WRITE_PASSWORD, LOCK_PASSWORD, GET_RANDOM,
|
||||||
|
XOR verification) is provided by the NxpPasswordAuth mixin.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from .auth_password import NxpPasswordAuth
|
||||||
|
from pm3py.sim.frame import RFFrame
|
||||||
|
from .nxp_icode import NxpIcodeTag, NXP_MFG
|
||||||
|
|
||||||
|
# Password ID shared across all SLIX variants
|
||||||
|
PWD_EAS_AFI = 0x10
|
||||||
|
|
||||||
|
|
||||||
|
class IcodeSlixTag(NxpPasswordAuth, NxpIcodeTag):
|
||||||
|
"""ICODE SLIX transponder (SL2S2002).
|
||||||
|
|
||||||
|
Features beyond NxpIcodeTag:
|
||||||
|
- 1 password: EAS/AFI (32-bit, XOR'd with GET_RANDOM)
|
||||||
|
- EAS (Electronic Article Surveillance) with lock
|
||||||
|
- INVENTORY READ / FAST INVENTORY READ
|
||||||
|
- WRITE PASSWORD, LOCK PASSWORD
|
||||||
|
|
||||||
|
Subclasses (SLIX2, ICODE 3) add more passwords and features.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_uid_prefix = b"\xE0\x04\x01\x10" # TAG type 0x01, bits 37:36 = 10 → byte bits 4:3 = 10
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes | str | None = None,
|
||||||
|
eas_afi_password: int | None = None,
|
||||||
|
**kwargs):
|
||||||
|
# Default to 28 blocks for SLIX OG; subclasses override
|
||||||
|
kwargs.setdefault("num_blocks", 28)
|
||||||
|
kwargs.setdefault("ic_reference", 0x01) # SLIX IC type per SL2S2002
|
||||||
|
super().__init__(uid=uid, **kwargs)
|
||||||
|
|
||||||
|
# Initialize password auth from mixin
|
||||||
|
self._init_password_auth({PWD_EAS_AFI: eas_afi_password})
|
||||||
|
|
||||||
|
# EAS state
|
||||||
|
self._eas_enabled = False
|
||||||
|
self._eas_locked = False
|
||||||
|
self._eas_password_protected = False
|
||||||
|
self._eas_id: int = 0
|
||||||
|
self._eas_sequence = bytes(32) # 256-bit EAS sequence
|
||||||
|
|
||||||
|
@property
|
||||||
|
def eas_enabled(self) -> bool:
|
||||||
|
return self._eas_enabled
|
||||||
|
|
||||||
|
def set_eas(self, enabled: bool) -> None:
|
||||||
|
self._eas_enabled = enabled
|
||||||
|
|
||||||
|
# ----- Frame normalization -----
|
||||||
|
|
||||||
|
def _normalize_custom_frame(self, frame: RFFrame) -> RFFrame:
|
||||||
|
"""Normalize addressed custom commands by stripping UID.
|
||||||
|
|
||||||
|
Addressed: flags(1) + cmd(1) + uid(8) + mfg(1) + data...
|
||||||
|
Unaddressed: flags(1) + cmd(1) + mfg(1) + data...
|
||||||
|
Returns unaddressed-format frame for uniform processing.
|
||||||
|
"""
|
||||||
|
flags = frame.data[0]
|
||||||
|
if (flags & 0x20) and not (flags & 0x04): # addressed, not inventory
|
||||||
|
normalized = bytes([flags & ~0x20]) + frame.data[1:2] + frame.data[10:]
|
||||||
|
return RFFrame.from_bytes(normalized)
|
||||||
|
return frame
|
||||||
|
|
||||||
|
# ----- Custom command dispatch -----
|
||||||
|
|
||||||
|
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle SLIX custom commands, fall through to NXP base."""
|
||||||
|
frame = self._normalize_custom_frame(frame)
|
||||||
|
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
||||||
|
match cmd:
|
||||||
|
case 0xA0 | 0xA1: # INVENTORY READ / FAST INVENTORY READ
|
||||||
|
return self._handle_inventory_read(frame)
|
||||||
|
case 0xA2: # SET EAS
|
||||||
|
return self._handle_set_eas(frame)
|
||||||
|
case 0xA3: # RESET EAS
|
||||||
|
return self._handle_reset_eas(frame)
|
||||||
|
case 0xA4: # LOCK EAS
|
||||||
|
return self._handle_lock_eas(frame)
|
||||||
|
case 0xA5: # EAS ALARM
|
||||||
|
return self._handle_eas_alarm(frame)
|
||||||
|
case 0xA6: # PASSWORD PROTECT EAS/AFI
|
||||||
|
return self._handle_password_protect_eas_afi(frame)
|
||||||
|
case 0xB2: # GET RANDOM — override to store random
|
||||||
|
return self._handle_get_random(frame)
|
||||||
|
case 0xB3: # SET_PASSWORD
|
||||||
|
return self._handle_set_password(frame)
|
||||||
|
case 0xB4: # WRITE PASSWORD
|
||||||
|
return self._handle_write_password(frame)
|
||||||
|
case 0xB5: # LOCK PASSWORD
|
||||||
|
return self._handle_lock_password(frame)
|
||||||
|
return super()._handle_custom_command(cmd, frame)
|
||||||
|
|
||||||
|
# ----- EAS auth check -----
|
||||||
|
|
||||||
|
def _check_eas_auth(self) -> bool:
|
||||||
|
"""Check if EAS/AFI operations are allowed."""
|
||||||
|
if not self._eas_password_protected:
|
||||||
|
return True
|
||||||
|
return PWD_EAS_AFI in self._authenticated_passwords
|
||||||
|
|
||||||
|
# ----- EAS subsystem -----
|
||||||
|
|
||||||
|
def _handle_set_eas(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if self._eas_locked:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
if not self._check_eas_auth():
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
self._eas_enabled = True
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
def _handle_reset_eas(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if self._eas_locked:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
if not self._check_eas_auth():
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
self._eas_enabled = False
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
def _handle_lock_eas(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if not self._check_eas_auth():
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
self._eas_locked = True
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
def _handle_eas_alarm(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if not self._eas_enabled:
|
||||||
|
return None
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]) + self._eas_sequence)
|
||||||
|
|
||||||
|
def _handle_password_protect_eas_afi(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if PWD_EAS_AFI not in self._authenticated_passwords:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
self._eas_password_protected = True
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
# ----- INVENTORY READ -----
|
||||||
|
|
||||||
|
def _handle_inventory_read(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""INVENTORY READ / FAST INVENTORY READ (0xA0/0xA1)."""
|
||||||
|
if len(frame.data) < 6:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
first_block = frame.data[4]
|
||||||
|
num_blocks = frame.data[5] + 1
|
||||||
|
|
||||||
|
resp = bytearray([0x00])
|
||||||
|
resp.append(self._dsfid)
|
||||||
|
resp.extend(self._uid)
|
||||||
|
|
||||||
|
for blk in range(first_block, first_block + num_blocks):
|
||||||
|
if blk >= self._num_blocks:
|
||||||
|
break
|
||||||
|
offset = blk * self._block_size
|
||||||
|
resp.extend(self._memory[offset:offset + self._block_size])
|
||||||
|
|
||||||
|
return RFFrame.from_bytes(bytes(resp))
|
||||||
257
pm3py/transponders/hf/iso15693/nxp/icode_slix2.py
Normal file
257
pm3py/transponders/hf/iso15693/nxp/icode_slix2.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"""ICODE SLIX2 — NXP IC with password protection and privacy mode.
|
||||||
|
|
||||||
|
Extends IcodeSlixTag with:
|
||||||
|
- 4 additional passwords (read, write, privacy, destroy)
|
||||||
|
- Privacy mode + ENABLE PRIVACY command
|
||||||
|
- Protection pointer + PROTECT/LOCK PAGE PROTECTION
|
||||||
|
- DESTROY, 64-BIT PASSWORD PROTECTION, STAY QUIET PERSISTENT
|
||||||
|
- Block 79 counter
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from pm3py.sim.frame import RFFrame
|
||||||
|
from .icode_slix import IcodeSlixTag, PWD_EAS_AFI
|
||||||
|
from .nxp_icode import NXP_MFG
|
||||||
|
|
||||||
|
# SLIX2-specific password IDs
|
||||||
|
PWD_READ = 0x01
|
||||||
|
PWD_WRITE = 0x02
|
||||||
|
PWD_PRIVACY = 0x04
|
||||||
|
PWD_DESTROY = 0x08
|
||||||
|
|
||||||
|
# Protection page condition bits
|
||||||
|
PP_COND_READ_PROTECTION = 0x01
|
||||||
|
PP_COND_WRITE_PROTECTION = 0x02
|
||||||
|
|
||||||
|
|
||||||
|
class IcodeSlix2Tag(IcodeSlixTag):
|
||||||
|
"""ICODE SLIX2 transponder (SL2S2602).
|
||||||
|
|
||||||
|
Features beyond IcodeSlixTag:
|
||||||
|
- 4 additional passwords: read, write, privacy, destroy
|
||||||
|
- Privacy mode: hides UID from inventory
|
||||||
|
- Protection pointer: blocks above this are password-protected
|
||||||
|
- ENABLE PRIVACY (0xBA), DESTROY (0xB9), 64-BIT PASSWORD PROTECTION (0xBB)
|
||||||
|
- STAY QUIET PERSISTENT (0xBC), block 79 counter
|
||||||
|
"""
|
||||||
|
|
||||||
|
_uid_prefix = b"\xE0\x04\x02" # SLIX2 IC type = 0x02
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes | str | None = None,
|
||||||
|
read_password: int | None = None,
|
||||||
|
write_password: int | None = None,
|
||||||
|
privacy_password: int | None = None,
|
||||||
|
destroy_password: int | None = None,
|
||||||
|
eas_afi_password: int | None = None,
|
||||||
|
protection_pointer: int | None = None,
|
||||||
|
**kwargs):
|
||||||
|
kwargs.setdefault("num_blocks", 80)
|
||||||
|
kwargs.setdefault("ic_reference", 0x01) # SLIX2 IC type per SL2S2602
|
||||||
|
super().__init__(uid=uid, eas_afi_password=eas_afi_password, **kwargs)
|
||||||
|
|
||||||
|
# Add SLIX2-specific passwords to the inherited dict
|
||||||
|
self._passwords[PWD_READ] = read_password
|
||||||
|
self._passwords[PWD_WRITE] = write_password
|
||||||
|
self._passwords[PWD_PRIVACY] = privacy_password
|
||||||
|
self._passwords[PWD_DESTROY] = destroy_password
|
||||||
|
|
||||||
|
# SLIX2-specific state
|
||||||
|
self._privacy_mode = False
|
||||||
|
self._protection_pointer = protection_pointer
|
||||||
|
self._page_protection_locked = False
|
||||||
|
self._extended_protection = False
|
||||||
|
self._destroyed = False
|
||||||
|
self._persistent_quiet = False
|
||||||
|
|
||||||
|
# Build per-block access map
|
||||||
|
self._build_access_map()
|
||||||
|
|
||||||
|
def _build_access_map(self) -> None:
|
||||||
|
"""Build per-block access map from protection pointer + counter."""
|
||||||
|
has_counter = self._num_blocks >= 80
|
||||||
|
has_pp = self._protection_pointer is not None
|
||||||
|
if not has_counter and not has_pp:
|
||||||
|
self.regions["user"].access_map = None
|
||||||
|
return
|
||||||
|
|
||||||
|
from pm3py.sim.memory import BlockAccess
|
||||||
|
user = self.regions["user"]
|
||||||
|
access_map = []
|
||||||
|
for block in range(self._num_blocks):
|
||||||
|
ba = BlockAccess()
|
||||||
|
if has_pp and block >= self._protection_pointer:
|
||||||
|
ba = BlockAccess(read="password", read_key=PWD_READ)
|
||||||
|
if has_counter and block == 79:
|
||||||
|
ba = BlockAccess(read=ba.read, read_key=ba.read_key,
|
||||||
|
write_mode="counter")
|
||||||
|
access_map.append(ba)
|
||||||
|
user.access_map = access_map
|
||||||
|
|
||||||
|
async def power_on(self) -> None:
|
||||||
|
await super().power_on()
|
||||||
|
if self._persistent_quiet:
|
||||||
|
from ..base import State15693
|
||||||
|
self._state = State15693.QUIET
|
||||||
|
|
||||||
|
@property
|
||||||
|
def privacy_mode(self) -> bool:
|
||||||
|
return self._privacy_mode
|
||||||
|
|
||||||
|
def enter_privacy_mode(self) -> None:
|
||||||
|
if self._passwords[PWD_PRIVACY] is not None:
|
||||||
|
self._privacy_mode = True
|
||||||
|
|
||||||
|
# ----- handle_frame override for destroyed/privacy -----
|
||||||
|
|
||||||
|
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if self._destroyed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(frame.data) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cmd = frame.data[1]
|
||||||
|
|
||||||
|
# In privacy mode: only respond to GET_RANDOM, SET_PASSWORD, and ENABLE_PRIVACY
|
||||||
|
if self._privacy_mode:
|
||||||
|
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
||||||
|
if cmd == 0xB2:
|
||||||
|
return self._handle_get_random(frame)
|
||||||
|
if cmd == 0xB3:
|
||||||
|
return self._handle_set_password(frame)
|
||||||
|
if cmd == 0xBA:
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await super().handle_frame(frame)
|
||||||
|
|
||||||
|
# ----- SLIX2-specific command dispatch -----
|
||||||
|
|
||||||
|
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Handle SLIX2-specific commands, delegate shared ones to parent."""
|
||||||
|
frame = self._normalize_custom_frame(frame)
|
||||||
|
if len(frame.data) >= 3 and frame.data[2] == NXP_MFG:
|
||||||
|
match cmd:
|
||||||
|
case 0xB6: # PROTECT PAGE
|
||||||
|
return self._handle_protect_page(frame)
|
||||||
|
case 0xB7: # LOCK PAGE PROTECTION
|
||||||
|
return self._handle_lock_page_protection(frame)
|
||||||
|
case 0xB9: # DESTROY
|
||||||
|
return self._handle_destroy(frame)
|
||||||
|
case 0xBA: # ENABLE_PRIVACY
|
||||||
|
return self._handle_enable_privacy(frame)
|
||||||
|
case 0xBB: # 64-BIT PASSWORD PROTECTION
|
||||||
|
return self._handle_64bit_password_protection(frame)
|
||||||
|
case 0xBC: # STAY QUIET PERSISTENT
|
||||||
|
return self._handle_stay_quiet_persistent(frame)
|
||||||
|
case 0xA7: # WRITE EAS ID (SLIX2+ only, EAS Selective per AN11809)
|
||||||
|
return self._handle_write_eas_id(frame)
|
||||||
|
case 0xAB: # GET NXP SYSTEM INFO — override with SLIX2 data
|
||||||
|
return self._handle_get_nxp_system_info(frame)
|
||||||
|
case 0xBD: # READ SIGNATURE
|
||||||
|
return self._handle_read_signature(frame)
|
||||||
|
# Delegate to parent (IcodeSlixTag) for shared commands
|
||||||
|
# Re-normalize already happened, but parent will normalize again (no-op for unaddressed)
|
||||||
|
return super()._handle_custom_command(cmd, frame)
|
||||||
|
|
||||||
|
# ----- SET_PASSWORD override (privacy unlock) -----
|
||||||
|
|
||||||
|
def _handle_set_password(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Override to unlock privacy mode on privacy password auth."""
|
||||||
|
result = super()._handle_set_password(frame)
|
||||||
|
if result is not None and result.data[0] == 0x00:
|
||||||
|
pwd_id = frame.data[3]
|
||||||
|
if pwd_id == PWD_PRIVACY:
|
||||||
|
self._privacy_mode = False
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ----- SLIX2-only commands -----
|
||||||
|
|
||||||
|
def _handle_write_eas_id(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""WRITE EAS ID (0xA7): SLIX2+ only (EAS Selective per AN11809)."""
|
||||||
|
if len(frame.data) < 5:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
if not self._check_eas_auth():
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
self._eas_id = struct.unpack_from("<H", frame.data, 3)[0]
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
def _handle_enable_privacy(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""ENABLE PRIVACY (0xBA): enter privacy mode using XOR'd password."""
|
||||||
|
if len(frame.data) < 7:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
if not self._verify_xor_password(PWD_PRIVACY, frame.data[3:7]):
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
self._privacy_mode = True
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
def _handle_get_nxp_system_info(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""GET NXP SYSTEM INFO (0xAB): SLIX2-specific response."""
|
||||||
|
pp = self._protection_pointer if self._protection_pointer is not None else 0
|
||||||
|
pp_cond = 0x00
|
||||||
|
if self._protection_pointer is not None:
|
||||||
|
pp_cond = PP_COND_READ_PROTECTION
|
||||||
|
lock_bits = 0x00
|
||||||
|
feature_flags = struct.pack("<I", 0x00000000)
|
||||||
|
return RFFrame.from_bytes(bytes([0x00, pp, pp_cond, lock_bits]) + feature_flags)
|
||||||
|
|
||||||
|
def _handle_protect_page(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""PROTECT PAGE (0xB6): set protection pointer and conditions."""
|
||||||
|
if len(frame.data) < 5:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
if self._page_protection_locked:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
if PWD_WRITE not in self._authenticated_passwords:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
self._protection_pointer = frame.data[3]
|
||||||
|
self._build_access_map()
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
def _handle_lock_page_protection(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""LOCK PAGE PROTECTION (0xB7): permanently lock protection config."""
|
||||||
|
if PWD_WRITE not in self._authenticated_passwords:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
self._page_protection_locked = True
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
def _handle_destroy(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""DESTROY (0xB9): irreversibly kill tag."""
|
||||||
|
if len(frame.data) < 7:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
if not self._verify_xor_password(PWD_DESTROY, frame.data[3:7]):
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
|
||||||
|
self._destroyed = True
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
def _handle_64bit_password_protection(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""64-BIT PASSWORD PROTECTION (0xBB): requires both read + write auth."""
|
||||||
|
if PWD_READ not in self._authenticated_passwords:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
if PWD_WRITE not in self._authenticated_passwords:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
self._extended_protection = True
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
def _handle_stay_quiet_persistent(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""STAY QUIET PERSISTENT (0xBC): persistent quiet survives power cycles."""
|
||||||
|
from ..base import State15693
|
||||||
|
self._persistent_quiet = True
|
||||||
|
self._state = State15693.QUIET
|
||||||
|
return RFFrame.from_bytes(bytes([0x00]))
|
||||||
|
|
||||||
|
def _handle_read_single(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""Override to enforce read protection."""
|
||||||
|
block = self._extract_block_number(frame)
|
||||||
|
if block is None:
|
||||||
|
return None
|
||||||
|
if self._protection_pointer is not None and block >= self._protection_pointer:
|
||||||
|
if PWD_READ not in self._authenticated_passwords:
|
||||||
|
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
|
||||||
|
return super()._handle_read_single(frame)
|
||||||
23
pm3py/transponders/hf/iso15693/nxp/ntag5_boost.py
Normal file
23
pm3py/transponders/hf/iso15693/nxp/ntag5_boost.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""NTAG 5 Boost (NTA5332) — compact I2C bridge with ALM and AES.
|
||||||
|
|
||||||
|
NTAG 5 Link + Active Load Modulation + AES always enabled.
|
||||||
|
Same memory layout and I2C interface as Link NTP5332.
|
||||||
|
512 user blocks (2048 bytes) + 256-byte SRAM + config memory.
|
||||||
|
|
||||||
|
ALM (Active Load Modulation) is an RF modulation technique that
|
||||||
|
reduces antenna size without compromising read range. It has no
|
||||||
|
protocol-level impact — the sim model is identical to Link NTP5332.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .ntag5_link import Ntag5LinkTag
|
||||||
|
|
||||||
|
|
||||||
|
class Ntag5BoostTag(Ntag5LinkTag):
|
||||||
|
"""NTAG 5 Boost transponder (NTA5332).
|
||||||
|
|
||||||
|
= NTAG 5 Link NTP5332 + ALM. AES always enabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes | str | None = None, **kwargs):
|
||||||
|
super().__init__(uid=uid, aes_capable=True, **kwargs)
|
||||||
261
pm3py/transponders/hf/iso15693/nxp/ntag5_link.py
Normal file
261
pm3py/transponders/hf/iso15693/nxp/ntag5_link.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""NTAG 5 Link (NTP53x2) — NXP dual-interface NFC + I2C bridge.
|
||||||
|
|
||||||
|
Extends Ntag5PlatformTag with:
|
||||||
|
- I2C slave interface (configurable address, default 0x54)
|
||||||
|
- 256-byte SRAM (shared between NFC and I2C)
|
||||||
|
- Arbiter for NFC/I2C memory access coordination
|
||||||
|
- SRAM modes: mirror, pass-through, PHDC
|
||||||
|
- Two variants: NTP5312 (password only), NTP5332 (+ AES + I2C master)
|
||||||
|
|
||||||
|
TAM/MAM authentication uses the same protocol as ICODE DNA.
|
||||||
|
See docs/NTAG5_SECURITY.md for verified implementation details.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from 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]))
|
||||||
446
pm3py/transponders/hf/iso15693/nxp/ntag5_platform.py
Normal file
446
pm3py/transponders/hf/iso15693/nxp/ntag5_platform.py
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
"""NTAG 5 platform base — shared across Switch, Link, and Boost.
|
||||||
|
|
||||||
|
All NTAG 5 variants share:
|
||||||
|
- Config memory with session registers (loaded from config on POR)
|
||||||
|
- Energy harvesting configuration
|
||||||
|
- Event detection pin (ED)
|
||||||
|
- PWM/GPIO configuration
|
||||||
|
- 16-bit counter on last user block
|
||||||
|
- READ/WRITE CONFIG commands
|
||||||
|
- Same NXP custom command set as SLIX2
|
||||||
|
|
||||||
|
The NTAG 5 platform extends SLIX2, not DNA. DNA adds AES crypto which
|
||||||
|
the Switch doesn't have. Link/Boost optionally add AES (and share the
|
||||||
|
same TAM/MAM protocol as DNA — see docs/NTAG5_SECURITY.md).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .auth_aes import NxpAesAuth
|
||||||
|
from 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
|
||||||
21
pm3py/transponders/hf/iso15693/nxp/ntag5_switch.py
Normal file
21
pm3py/transponders/hf/iso15693/nxp/ntag5_switch.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""NTAG 5 Switch (NTP5210) — NFC PWM/GPIO bridge, no I2C.
|
||||||
|
|
||||||
|
Simplest NTAG 5 variant: RF + PWM/GPIO outputs, no I2C, no SRAM, no AES.
|
||||||
|
128 user blocks (512 bytes), block 0x7F = 16-bit counter.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .ntag5_platform import Ntag5PlatformTag
|
||||||
|
|
||||||
|
|
||||||
|
class Ntag5SwitchTag(Ntag5PlatformTag):
|
||||||
|
"""NTAG 5 Switch (NTP5210).
|
||||||
|
|
||||||
|
128 blocks (512 bytes) + config memory.
|
||||||
|
PWM/GPIO outputs, energy harvesting, event detection.
|
||||||
|
No I2C, no SRAM, no AES authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes | str | None = None, **kwargs):
|
||||||
|
kwargs.setdefault("num_blocks", 128)
|
||||||
|
super().__init__(uid=uid, aes_capable=False, **kwargs)
|
||||||
86
pm3py/transponders/hf/iso15693/nxp/nxp_icode.py
Normal file
86
pm3py/transponders/hf/iso15693/nxp/nxp_icode.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""NXP ICODE/NTAG5 platform — shared NXP custom commands."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from 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
|
||||||
150
pm3py/transponders/hf/iso15693/type5.py
Normal file
150
pm3py/transponders/hf/iso15693/type5.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""NFC Forum Type 5 Tag — extends ISO 15693 with NDEF support."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .base import Tag15693
|
||||||
|
|
||||||
|
NDEF_MAGIC_T5 = 0xE1
|
||||||
|
NDEF_TLV_TYPE = 0x03
|
||||||
|
TERMINATOR_TLV = 0xFE
|
||||||
|
|
||||||
|
# NDEF record type name format flags
|
||||||
|
TNF_WELL_KNOWN = 0x01
|
||||||
|
TNF_MEDIA = 0x02
|
||||||
|
TNF_URI = 0x01 # well-known + RTD_URI
|
||||||
|
|
||||||
|
# Well-known RTD types
|
||||||
|
RTD_TEXT = b"T"
|
||||||
|
RTD_URI = b"U"
|
||||||
|
|
||||||
|
# URI identifier codes (prefix abbreviations)
|
||||||
|
URI_PREFIXES = {
|
||||||
|
0x00: "",
|
||||||
|
0x01: "http://www.",
|
||||||
|
0x02: "https://www.",
|
||||||
|
0x03: "http://",
|
||||||
|
0x04: "https://",
|
||||||
|
0x05: "tel:",
|
||||||
|
0x06: "mailto:",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ndef_text(text: str, lang: str = "en") -> bytes:
|
||||||
|
"""Build an NDEF Text record (well-known RTD_TEXT).
|
||||||
|
|
||||||
|
ndef_text("Hello") → raw NDEF message bytes
|
||||||
|
"""
|
||||||
|
lang_bytes = lang.encode("ascii")
|
||||||
|
payload = bytes([len(lang_bytes)]) + lang_bytes + text.encode("utf-8")
|
||||||
|
return _ndef_record(TNF_WELL_KNOWN, RTD_TEXT, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def ndef_uri(uri: str) -> bytes:
|
||||||
|
"""Build an NDEF URI record (well-known RTD_URI).
|
||||||
|
|
||||||
|
ndef_uri("https://example.com") → raw NDEF message bytes
|
||||||
|
"""
|
||||||
|
# Find longest matching prefix
|
||||||
|
code = 0x00
|
||||||
|
for c, prefix in URI_PREFIXES.items():
|
||||||
|
if prefix and uri.startswith(prefix) and len(prefix) > len(URI_PREFIXES.get(code, "")):
|
||||||
|
code = c
|
||||||
|
shortened = uri[len(URI_PREFIXES.get(code, "")):]
|
||||||
|
payload = bytes([code]) + shortened.encode("utf-8")
|
||||||
|
return _ndef_record(TNF_WELL_KNOWN, RTD_URI, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def ndef_mime(mime_type: str, data: bytes) -> bytes:
|
||||||
|
"""Build an NDEF Media record.
|
||||||
|
|
||||||
|
ndef_mime("text/plain", b"hello") → raw NDEF message bytes
|
||||||
|
"""
|
||||||
|
return _ndef_record(TNF_MEDIA, mime_type.encode("ascii"), data)
|
||||||
|
|
||||||
|
|
||||||
|
def _ndef_record(tnf: int, record_type: bytes, payload: bytes,
|
||||||
|
id_bytes: bytes = b"") -> bytes:
|
||||||
|
"""Build a single NDEF record (MB+ME set = standalone message)."""
|
||||||
|
flags = 0x80 | 0x40 | tnf # MB=1, ME=1, TNF
|
||||||
|
if len(payload) < 256:
|
||||||
|
flags |= 0x10 # SR (short record)
|
||||||
|
header = bytes([flags, len(record_type)]) + bytes([len(payload)])
|
||||||
|
else:
|
||||||
|
header = bytes([flags, len(record_type)]) + len(payload).to_bytes(4, "big")
|
||||||
|
if id_bytes:
|
||||||
|
header = bytes([flags | 0x08, len(record_type)]) + header[2:] + bytes([len(id_bytes)])
|
||||||
|
return header + record_type + id_bytes + payload
|
||||||
|
|
||||||
|
|
||||||
|
class NfcType5Tag(Tag15693):
|
||||||
|
"""NFC Forum Type 5 Tag.
|
||||||
|
|
||||||
|
Block 0: Capability Container (CC)
|
||||||
|
Block 1+: NDEF data area (TLV format)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes, ndef_message: bytes = b"",
|
||||||
|
dsfid: int = 0, afi: int = 0,
|
||||||
|
block_size: int = 4, num_blocks: int = 28,
|
||||||
|
**kwargs):
|
||||||
|
memory = bytearray(block_size * num_blocks)
|
||||||
|
super().__init__(uid=uid, dsfid=dsfid, afi=afi,
|
||||||
|
memory=memory, block_size=block_size,
|
||||||
|
num_blocks=num_blocks, **kwargs)
|
||||||
|
self._ndef_data_offset = self._block_size # block 1
|
||||||
|
self._ndef_capacity = (self._num_blocks - 1) * self._block_size - 3 # minus TLV overhead + terminator
|
||||||
|
self._write_cc()
|
||||||
|
# Always write NDEF TLV — real blank tags ship with empty NDEF (03 00 FE).
|
||||||
|
# Without this, NFC readers can't parse the data area.
|
||||||
|
self.set_ndef(ndef_message)
|
||||||
|
|
||||||
|
def _write_cc(self) -> None:
|
||||||
|
"""Write Capability Container to block 0.
|
||||||
|
|
||||||
|
CC byte 3 feature flags (NFC Forum T5T 1.0):
|
||||||
|
bit 0: MBREAD — tag supports READ MULTIPLE BLOCKS
|
||||||
|
bit 1: Lock block supported
|
||||||
|
bit 2: Special frame format required
|
||||||
|
Firmware sim handles READ MULTIPLE BLOCKS natively, so MBREAD=1.
|
||||||
|
"""
|
||||||
|
data_bytes = (self._num_blocks - 1) * self._block_size
|
||||||
|
cc = bytes([
|
||||||
|
NDEF_MAGIC_T5,
|
||||||
|
0x40, # version 1.0, read/write, single-byte CC length
|
||||||
|
data_bytes // 8, # size in 8-byte units
|
||||||
|
0x01, # MBREAD supported
|
||||||
|
])
|
||||||
|
self._memory[0:self._block_size] = cc[:self._block_size]
|
||||||
|
|
||||||
|
def set_ndef(self, message: bytes) -> None:
|
||||||
|
"""Write an NDEF message to the tag. Call sync() to push to firmware.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Raw NDEF message bytes (e.g. b"\\xD1\\x01\\x04\\x54\\x02enHi")
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If message is too large for available space.
|
||||||
|
"""
|
||||||
|
if len(message) > self._ndef_capacity:
|
||||||
|
raise ValueError(
|
||||||
|
f"NDEF message too large: {len(message)} bytes, "
|
||||||
|
f"capacity is {self._ndef_capacity} bytes")
|
||||||
|
|
||||||
|
# Clear data area
|
||||||
|
self._memory[self._ndef_data_offset:] = bytes(
|
||||||
|
len(self._memory) - self._ndef_data_offset)
|
||||||
|
|
||||||
|
# Write TLV: type(1) + length(1 or 3) + message + terminator
|
||||||
|
offset = self._ndef_data_offset
|
||||||
|
if len(message) < 0xFF:
|
||||||
|
tlv = bytes([NDEF_TLV_TYPE, len(message)]) + message
|
||||||
|
else:
|
||||||
|
tlv = bytes([NDEF_TLV_TYPE, 0xFF,
|
||||||
|
(len(message) >> 8) & 0xFF,
|
||||||
|
len(message) & 0xFF]) + message
|
||||||
|
tlv += bytes([TERMINATOR_TLV])
|
||||||
|
self._memory[offset:offset + len(tlv)] = tlv
|
||||||
|
|
||||||
|
def clear_ndef(self) -> None:
|
||||||
|
"""Remove NDEF message. Call sync() to push."""
|
||||||
|
self._memory[self._ndef_data_offset:] = bytes(
|
||||||
|
len(self._memory) - self._ndef_data_offset)
|
||||||
66
pm3py/transponders/implants.py
Normal file
66
pm3py/transponders/implants.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Implant preset profiles — pre-configured transponder instances."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .lf.atmel.t5577 import T5577Tag
|
||||||
|
from .hf.iso14443a.ndef import NfcType2Tag
|
||||||
|
from .hf.iso14443a.nxp.mifare_classic import MifareClassicTag
|
||||||
|
from .hf.iso14443a.nxp.desfire import DesfireTag
|
||||||
|
|
||||||
|
|
||||||
|
def _random_uid(length: int) -> bytes:
|
||||||
|
"""Generate a random UID starting with 0x04 (NXP manufacturer code)."""
|
||||||
|
return b"\x04" + os.urandom(length - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def xEM(tag_id: int = 0x0000000000) -> T5577Tag:
|
||||||
|
"""xEM implant — T5577 configured as EM4100."""
|
||||||
|
return T5577Tag.preset("em4100", tag_id=tag_id)
|
||||||
|
|
||||||
|
|
||||||
|
def xNT(uid: bytes | None = None, ndef_message: bytes = b"") -> NfcType2Tag:
|
||||||
|
"""xNT implant — NTAG216, 888 bytes, NDEF capable."""
|
||||||
|
if uid is None:
|
||||||
|
uid = _random_uid(7)
|
||||||
|
return NfcType2Tag(uid=uid, ndef_message=ndef_message,
|
||||||
|
total_pages=231, # NTAG216: 924 bytes / 4
|
||||||
|
atqa=b"\x44\x00", sak=0x00)
|
||||||
|
|
||||||
|
|
||||||
|
def xM1(uid: bytes | None = None) -> MifareClassicTag:
|
||||||
|
"""xM1 implant — MIFARE Classic 1K."""
|
||||||
|
if uid is None:
|
||||||
|
uid = _random_uid(4)
|
||||||
|
return MifareClassicTag(uid=uid, size="1k")
|
||||||
|
|
||||||
|
|
||||||
|
def FlexDF(uid: bytes | None = None) -> DesfireTag:
|
||||||
|
"""FlexDF implant — DESFire EV2, 8K."""
|
||||||
|
if uid is None:
|
||||||
|
uid = _random_uid(7)
|
||||||
|
return DesfireTag(uid=uid)
|
||||||
|
|
||||||
|
|
||||||
|
def NExT(tag_id: int = 0x0000000000,
|
||||||
|
uid: bytes | None = None) -> tuple[T5577Tag, NfcType2Tag]:
|
||||||
|
"""NExT implant — dual frequency (xEM LF + xNT HF)."""
|
||||||
|
lf = xEM(tag_id=tag_id)
|
||||||
|
hf = xNT(uid=uid)
|
||||||
|
return lf, hf
|
||||||
|
|
||||||
|
|
||||||
|
class MagicMifareClassicTag(MifareClassicTag):
|
||||||
|
"""Magic MIFARE Classic — gen1a or gen2/CUID variant.
|
||||||
|
|
||||||
|
gen1a: responds to special backdoor commands, allows direct block 0 write
|
||||||
|
gen2/CUID: allows block 0 write via standard write commands
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, uid: bytes, magic_type: str = "gen1a", **kwargs):
|
||||||
|
super().__init__(uid=uid, **kwargs)
|
||||||
|
self._magic_type = magic_type
|
||||||
|
|
||||||
|
@property
|
||||||
|
def magic_type(self) -> str:
|
||||||
|
return self._magic_type
|
||||||
163
pm3py/transponders/lf/atmel/t5577.py
Normal file
163
pm3py/transponders/lf/atmel/t5577.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""T5577/ATA5577 — programmable LF emulator transponder."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from pm3py.sim.frame import RFFrame
|
||||||
|
from ..base import TagLF, ReaderLF, Modulation
|
||||||
|
from ..em.em4100 import EM4100Tag
|
||||||
|
|
||||||
|
# T5577 commands (first byte of frame)
|
||||||
|
CMD_READ = 0x01
|
||||||
|
CMD_WRITE = 0x02
|
||||||
|
CMD_PWD_READ = 0x03
|
||||||
|
CMD_PWD_WRITE = 0x04
|
||||||
|
|
||||||
|
# T5577 block 0 config bits for common modes
|
||||||
|
CONFIG_EM4100 = 0x00148040 # ASK/Manchester, RF/64, 64 bits, maxblk=2
|
||||||
|
|
||||||
|
|
||||||
|
class T5577Tag(TagLF):
|
||||||
|
"""T5577 programmable LF transponder.
|
||||||
|
|
||||||
|
8 blocks of 32 bits each. Block 0 is configuration.
|
||||||
|
Can emulate EM4100, HID, Indala, AWID via block 0 config.
|
||||||
|
Optional password protection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, password: int | None = None):
|
||||||
|
super().__init__(modulation=Modulation.ASK)
|
||||||
|
self._blocks = [0] * 8
|
||||||
|
self._password = password
|
||||||
|
self._pwd_mode = password is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocks(self) -> list[int]:
|
||||||
|
return self._blocks
|
||||||
|
|
||||||
|
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if not self._powered or not frame.data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cmd = frame.data[0]
|
||||||
|
if cmd == CMD_READ:
|
||||||
|
return self._handle_read(frame)
|
||||||
|
elif cmd == CMD_WRITE:
|
||||||
|
return self._handle_write(frame)
|
||||||
|
elif cmd == CMD_PWD_READ:
|
||||||
|
return self._handle_pwd_read(frame)
|
||||||
|
elif cmd == CMD_PWD_WRITE:
|
||||||
|
return self._handle_pwd_write(frame)
|
||||||
|
else:
|
||||||
|
# Default: return block 1+2 data (like EM4100 emulation)
|
||||||
|
return self._get_response()
|
||||||
|
|
||||||
|
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if len(frame.data) < 2:
|
||||||
|
return None
|
||||||
|
block = frame.data[1]
|
||||||
|
if block >= 8:
|
||||||
|
return None
|
||||||
|
if self._pwd_mode:
|
||||||
|
return None # password required
|
||||||
|
return RFFrame.from_bytes(struct.pack(">I", self._blocks[block]))
|
||||||
|
|
||||||
|
def _handle_write(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if len(frame.data) < 6:
|
||||||
|
return None
|
||||||
|
block = frame.data[1]
|
||||||
|
if block >= 8:
|
||||||
|
return None
|
||||||
|
if self._pwd_mode:
|
||||||
|
return None
|
||||||
|
data = struct.unpack(">I", frame.data[2:6])[0]
|
||||||
|
self._blocks[block] = data
|
||||||
|
return RFFrame.from_bytes(b"\x00") # ACK
|
||||||
|
|
||||||
|
def _handle_pwd_read(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if len(frame.data) < 6:
|
||||||
|
return None
|
||||||
|
block = frame.data[1]
|
||||||
|
if block >= 8:
|
||||||
|
return None
|
||||||
|
pwd = struct.unpack(">I", frame.data[2:6])[0]
|
||||||
|
if pwd != self._password:
|
||||||
|
return None
|
||||||
|
return RFFrame.from_bytes(struct.pack(">I", self._blocks[block]))
|
||||||
|
|
||||||
|
def _handle_pwd_write(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
if len(frame.data) < 10:
|
||||||
|
return None
|
||||||
|
block = frame.data[1]
|
||||||
|
if block >= 8:
|
||||||
|
return None
|
||||||
|
pwd = struct.unpack(">I", frame.data[2:6])[0]
|
||||||
|
if pwd != self._password:
|
||||||
|
return None
|
||||||
|
data = struct.unpack(">I", frame.data[6:10])[0]
|
||||||
|
self._blocks[block] = data
|
||||||
|
return RFFrame.from_bytes(b"\x00")
|
||||||
|
|
||||||
|
def _get_response(self) -> RFFrame | None:
|
||||||
|
"""Return data blocks as continuous stream (for emulation mode)."""
|
||||||
|
# In emulation mode, T5577 outputs blocks 1+ based on block 0 config
|
||||||
|
data = b""
|
||||||
|
for i in range(1, 3): # blocks 1-2 for EM4100
|
||||||
|
data += struct.pack(">I", self._blocks[i])
|
||||||
|
return RFFrame.from_bytes(data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def preset(cls, mode: str, **kwargs) -> T5577Tag:
|
||||||
|
"""Create a T5577 pre-configured for a specific emulation mode."""
|
||||||
|
tag = cls()
|
||||||
|
if mode == "em4100":
|
||||||
|
tag_id = kwargs.get("tag_id", 0)
|
||||||
|
tag._blocks[0] = CONFIG_EM4100
|
||||||
|
# Encode EM4100 data into blocks 1-2
|
||||||
|
encoded = EM4100Tag._encode(tag_id)
|
||||||
|
# Pack 64 bits into two 32-bit blocks
|
||||||
|
val1 = 0
|
||||||
|
for i in range(32):
|
||||||
|
val1 = (val1 << 1) | encoded[i]
|
||||||
|
val2 = 0
|
||||||
|
for i in range(32, 64):
|
||||||
|
val2 = (val2 << 1) | encoded[i]
|
||||||
|
tag._blocks[1] = val1
|
||||||
|
tag._blocks[2] = val2
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
class T5577Reader(ReaderLF):
|
||||||
|
"""T5577 reader — read/write blocks."""
|
||||||
|
|
||||||
|
async def read_block(self, block: int, password: int | None = None) -> dict:
|
||||||
|
if password is not None:
|
||||||
|
cmd = bytes([CMD_PWD_READ, block]) + struct.pack(">I", password)
|
||||||
|
else:
|
||||||
|
cmd = bytes([CMD_READ, block])
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(cmd))
|
||||||
|
resp = await self._medium.receive_reader()
|
||||||
|
if resp is None or len(resp.data) < 4:
|
||||||
|
return {"data": None}
|
||||||
|
return {"data": struct.unpack(">I", resp.data[:4])[0]}
|
||||||
|
|
||||||
|
async def write_block(self, block: int, data: int,
|
||||||
|
password: int | None = None) -> dict:
|
||||||
|
if password is not None:
|
||||||
|
cmd = bytes([CMD_PWD_WRITE, block]) + struct.pack(">I", password) + struct.pack(">I", data)
|
||||||
|
else:
|
||||||
|
cmd = bytes([CMD_WRITE, block]) + struct.pack(">I", data)
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(cmd))
|
||||||
|
resp = await self._medium.receive_reader()
|
||||||
|
return {"success": resp is not None}
|
||||||
|
|
||||||
|
async def read_id(self) -> dict | None:
|
||||||
|
"""Read tag in emulation mode (default response)."""
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(b"\x00"))
|
||||||
|
resp = await self._medium.receive_reader()
|
||||||
|
if resp is None:
|
||||||
|
return None
|
||||||
|
return {"raw": resp.data}
|
||||||
|
|
||||||
|
def _decode_response(self, resp: RFFrame) -> dict | None:
|
||||||
|
return {"raw": resp.data}
|
||||||
69
pm3py/transponders/lf/base.py
Normal file
69
pm3py/transponders/lf/base.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""LF transponder and reader base classes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pm3py.sim.frame import RFFrame
|
||||||
|
from pm3py.sim.medium import Medium
|
||||||
|
from pm3py.sim.transponder import Transponder
|
||||||
|
from pm3py.sim.reader import Reader
|
||||||
|
|
||||||
|
|
||||||
|
class Modulation(Enum):
|
||||||
|
ASK = "ask"
|
||||||
|
FSK = "fsk"
|
||||||
|
PSK = "psk"
|
||||||
|
NRZ = "nrz"
|
||||||
|
|
||||||
|
|
||||||
|
class TagLF(Transponder):
|
||||||
|
"""Base class for LF (125/134 kHz) transponders.
|
||||||
|
|
||||||
|
LF tags are passive — they continuously transmit their data when
|
||||||
|
energized by the reader's field. The handle_frame() method responds
|
||||||
|
to any reader command with the tag's encoded data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, modulation: Modulation = Modulation.ASK):
|
||||||
|
super().__init__()
|
||||||
|
self._modulation = modulation
|
||||||
|
self._powered = False
|
||||||
|
|
||||||
|
async def power_on(self) -> None:
|
||||||
|
self._powered = True
|
||||||
|
|
||||||
|
async def power_off(self) -> None:
|
||||||
|
self._powered = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> str:
|
||||||
|
return "POWERED" if self._powered else "OFF"
|
||||||
|
|
||||||
|
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||||
|
"""LF tags respond to any energize/read command with their data."""
|
||||||
|
if not self._powered:
|
||||||
|
return None
|
||||||
|
return self._get_response()
|
||||||
|
|
||||||
|
def _get_response(self) -> RFFrame | None:
|
||||||
|
"""Override in subclasses to return encoded tag data."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ReaderLF(Reader):
|
||||||
|
"""Base class for LF readers."""
|
||||||
|
|
||||||
|
async def run(self) -> dict:
|
||||||
|
return await self.read_id()
|
||||||
|
|
||||||
|
async def read_id(self) -> dict | None:
|
||||||
|
"""Send energize command, receive tag response."""
|
||||||
|
await self._medium.transmit_reader(RFFrame.from_bytes(b"\x00"))
|
||||||
|
resp = await self._medium.receive_reader()
|
||||||
|
if resp is None:
|
||||||
|
return None
|
||||||
|
return self._decode_response(resp)
|
||||||
|
|
||||||
|
def _decode_response(self, resp: RFFrame) -> dict | None:
|
||||||
|
"""Override in subclasses to decode tag-specific response."""
|
||||||
|
return {"raw": resp.data}
|
||||||
99
pm3py/transponders/lf/em/em4100.py
Normal file
99
pm3py/transponders/lf/em/em4100.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""EM4100/EM4102 — read-only 64-bit Manchester ASK transponder."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pm3py.sim.frame import RFFrame
|
||||||
|
from ..base import TagLF, ReaderLF, Modulation
|
||||||
|
|
||||||
|
|
||||||
|
class EM4100Tag(TagLF):
|
||||||
|
"""EM4100/EM4102 read-only tag.
|
||||||
|
|
||||||
|
64-bit data format:
|
||||||
|
- 9 bits: header (all 1s)
|
||||||
|
- 10 rows of 5 bits: 4 data bits + 1 even row parity
|
||||||
|
- 4 bits: column parity (even)
|
||||||
|
- 1 bit: stop bit (0)
|
||||||
|
|
||||||
|
The 40 data bits encode a 5-byte tag ID (10 hex nibbles).
|
||||||
|
Manchester-encoded ASK modulation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, tag_id: int):
|
||||||
|
super().__init__(modulation=Modulation.ASK)
|
||||||
|
if tag_id < 0 or tag_id > 0xFFFFFFFFFF:
|
||||||
|
raise ValueError("tag_id must be 0-0xFFFFFFFFFF (40 bits)")
|
||||||
|
self._tag_id = tag_id
|
||||||
|
self._encoded = self._encode(tag_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tag_id(self) -> int:
|
||||||
|
return self._tag_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def encoded_data(self) -> list[int]:
|
||||||
|
"""Return 64-bit encoded data as list of 0/1 values."""
|
||||||
|
return list(self._encoded)
|
||||||
|
|
||||||
|
def _get_response(self) -> RFFrame:
|
||||||
|
"""Return encoded data as RFFrame."""
|
||||||
|
from bitarray import bitarray
|
||||||
|
bits = bitarray(self._encoded)
|
||||||
|
return RFFrame(bits=bits, bit_count=64)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _encode(tag_id: int) -> list[int]:
|
||||||
|
"""Encode 40-bit tag ID into 64-bit EM4100 format."""
|
||||||
|
# Extract 10 nibbles (4 bits each) from 40-bit ID, MSB first
|
||||||
|
nibbles = []
|
||||||
|
for i in range(9, -1, -1):
|
||||||
|
nibbles.append((tag_id >> (i * 4)) & 0x0F)
|
||||||
|
|
||||||
|
data = []
|
||||||
|
# Header: 9 ones
|
||||||
|
data.extend([1] * 9)
|
||||||
|
|
||||||
|
# 10 rows: 4 data bits + 1 even parity
|
||||||
|
for nibble in nibbles:
|
||||||
|
row = [(nibble >> (3 - b)) & 1 for b in range(4)]
|
||||||
|
parity = sum(row) % 2
|
||||||
|
data.extend(row)
|
||||||
|
data.append(parity)
|
||||||
|
|
||||||
|
# Column parity: 4 bits, even parity across each column
|
||||||
|
for col in range(4):
|
||||||
|
col_sum = sum(data[9 + row * 5 + col] for row in range(10))
|
||||||
|
data.append(col_sum % 2)
|
||||||
|
|
||||||
|
# Stop bit
|
||||||
|
data.append(0)
|
||||||
|
|
||||||
|
assert len(data) == 64
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_data(data: list[int]) -> int:
|
||||||
|
"""Decode 64-bit EM4100 data to 40-bit tag ID."""
|
||||||
|
if len(data) != 64:
|
||||||
|
raise ValueError(f"Expected 64 bits, got {len(data)}")
|
||||||
|
tag_id = 0
|
||||||
|
for row in range(10):
|
||||||
|
start = 9 + row * 5
|
||||||
|
nibble = 0
|
||||||
|
for b in range(4):
|
||||||
|
nibble = (nibble << 1) | data[start + b]
|
||||||
|
tag_id = (tag_id << 4) | nibble
|
||||||
|
return tag_id
|
||||||
|
|
||||||
|
|
||||||
|
class EM4100Reader(ReaderLF):
|
||||||
|
"""EM4100 reader — decode Manchester ASK response."""
|
||||||
|
|
||||||
|
def _decode_response(self, resp: RFFrame) -> dict | None:
|
||||||
|
if resp.bit_count < 64:
|
||||||
|
return None
|
||||||
|
data = [resp.bits[i] for i in range(64)]
|
||||||
|
try:
|
||||||
|
tag_id = EM4100Tag.decode_data(data)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
return {"tag_id": tag_id}
|
||||||
1
pm3py/transponders/lf/hid/__init__.py
Normal file
1
pm3py/transponders/lf/hid/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""HID Proximity transponders."""
|
||||||
121
pm3py/transponders/lf/hid/hid.py
Normal file
121
pm3py/transponders/lf/hid/hid.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""HID Proximity — FSK modulated Wiegand credential transponder."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pm3py.sim.frame import RFFrame
|
||||||
|
from ..base import TagLF, ReaderLF, Modulation
|
||||||
|
|
||||||
|
|
||||||
|
# Wiegand format definitions
|
||||||
|
FORMATS = {
|
||||||
|
"H10301": {"bits": 26, "fc_bits": 8, "cn_bits": 16},
|
||||||
|
"H10304": {"bits": 37, "fc_bits": 16, "cn_bits": 19},
|
||||||
|
"C1000_35": {"bits": 35, "fc_bits": 12, "cn_bits": 20},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HIDProxTag(TagLF):
|
||||||
|
"""HID Proximity card — FSK modulated, Wiegand-format credential."""
|
||||||
|
|
||||||
|
def __init__(self, facility_code: int, card_number: int,
|
||||||
|
format: str = "H10301"):
|
||||||
|
super().__init__(modulation=Modulation.FSK)
|
||||||
|
if format not in FORMATS:
|
||||||
|
raise ValueError(f"Unknown format: {format}. Use: {list(FORMATS)}")
|
||||||
|
self._facility_code = facility_code
|
||||||
|
self._card_number = card_number
|
||||||
|
self._format = format
|
||||||
|
self._wiegand = self._encode_wiegand(facility_code, card_number, format)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def facility_code(self) -> int:
|
||||||
|
return self._facility_code
|
||||||
|
|
||||||
|
@property
|
||||||
|
def card_number(self) -> int:
|
||||||
|
return self._card_number
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format(self) -> str:
|
||||||
|
return self._format
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wiegand_bits(self) -> list[int]:
|
||||||
|
return list(self._wiegand)
|
||||||
|
|
||||||
|
def _get_response(self) -> RFFrame:
|
||||||
|
from bitarray import bitarray
|
||||||
|
bits = bitarray(self._wiegand)
|
||||||
|
return RFFrame(bits=bits, bit_count=len(self._wiegand))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _encode_wiegand(fc: int, cn: int, fmt: str) -> list[int]:
|
||||||
|
"""Encode facility code + card number into Wiegand bit stream."""
|
||||||
|
f = FORMATS[fmt]
|
||||||
|
total = f["bits"]
|
||||||
|
fc_bits = f["fc_bits"]
|
||||||
|
cn_bits = f["cn_bits"]
|
||||||
|
|
||||||
|
# Data bits: FC + CN (MSB first)
|
||||||
|
data = []
|
||||||
|
for i in range(fc_bits - 1, -1, -1):
|
||||||
|
data.append((fc >> i) & 1)
|
||||||
|
for i in range(cn_bits - 1, -1, -1):
|
||||||
|
data.append((cn >> i) & 1)
|
||||||
|
|
||||||
|
if total == 26:
|
||||||
|
# H10301: EP(1) + FC(8) + CN(16) + OP(1)
|
||||||
|
even_parity = sum(data[:12]) % 2
|
||||||
|
odd_parity = 1 - (sum(data[12:]) % 2)
|
||||||
|
return [even_parity] + data + [odd_parity]
|
||||||
|
elif total == 37:
|
||||||
|
# H10304: EP(1) + FC(16) + CN(19) + OP(1)
|
||||||
|
even_parity = sum(data[:18]) % 2
|
||||||
|
odd_parity = 1 - (sum(data[18:]) % 2)
|
||||||
|
return [even_parity] + data + [odd_parity]
|
||||||
|
elif total == 35:
|
||||||
|
# C1000_35: EP(1) + FC(12) + CN(20) + EP(1) + OP(1)
|
||||||
|
# Simplified: EP + data + OP
|
||||||
|
even_parity = sum(data[:16]) % 2
|
||||||
|
odd_parity = 1 - (sum(data[16:]) % 2)
|
||||||
|
return [even_parity] + data + [odd_parity]
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_wiegand(bits: list[int], fmt: str) -> dict:
|
||||||
|
"""Decode Wiegand bits to facility code + card number."""
|
||||||
|
f = FORMATS[fmt]
|
||||||
|
fc_bits = f["fc_bits"]
|
||||||
|
cn_bits = f["cn_bits"]
|
||||||
|
|
||||||
|
# Strip parity bits (first and last)
|
||||||
|
data = bits[1:-1]
|
||||||
|
|
||||||
|
fc = 0
|
||||||
|
for i in range(fc_bits):
|
||||||
|
fc = (fc << 1) | data[i]
|
||||||
|
cn = 0
|
||||||
|
for i in range(cn_bits):
|
||||||
|
cn = (cn << 1) | data[fc_bits + i]
|
||||||
|
|
||||||
|
return {"facility_code": fc, "card_number": cn}
|
||||||
|
|
||||||
|
|
||||||
|
class HIDReader(ReaderLF):
|
||||||
|
"""HID Proximity reader — decode FSK + Wiegand credential."""
|
||||||
|
|
||||||
|
def __init__(self, medium, format: str = "H10301"):
|
||||||
|
super().__init__(medium)
|
||||||
|
self._format = format
|
||||||
|
|
||||||
|
async def read_credential(self) -> dict | None:
|
||||||
|
"""Read and decode HID credential."""
|
||||||
|
result = await self.read_id()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _decode_response(self, resp: RFFrame) -> dict | None:
|
||||||
|
f = FORMATS.get(self._format)
|
||||||
|
if f is None or resp.bit_count < f["bits"]:
|
||||||
|
return None
|
||||||
|
bits = [resp.bits[i] for i in range(f["bits"])]
|
||||||
|
return HIDProxTag.decode_wiegand(bits, self._format)
|
||||||
Reference in New Issue
Block a user