"""Tests for NFC Type 5, NXP ICODE platform, and ICODE SLIX2.""" import asyncio import struct import pytest from pm3py.sim.frame import RFFrame from pm3py.sim.medium import SoftwareMedium from pm3py.transponders.hf.iso15693.type5 import NfcType5Tag 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, PWD_EAS_AFI, PWD_READ, PWD_WRITE, PWD_PRIVACY, PWD_DESTROY, ) from pm3py.transponders.hf.iso15693.nxp.nxp_icode import ( CMD_SET_EAS, CMD_RESET_EAS, CMD_LOCK_EAS, CMD_EAS_ALARM, CMD_PASSWORD_PROTECT_EAS_AFI, CMD_WRITE_EAS_ID, CMD_INVENTORY_READ, CMD_FAST_INVENTORY_READ, ) from pm3py.transponders.hf.iso15693.base import Reader15693 def run(coro): return asyncio.get_event_loop().run_until_complete(coro) # --------------------------------------------------------------------------- # NFC Type 5 Tag # --------------------------------------------------------------------------- class TestNfcType5Tag: def test_cc_in_block_0(self): """Block 0 contains capability container with MBREAD flag.""" tag = NfcType5Tag(uid=bytes(8), ndef_message=b"\xD1\x01\x04\x54\x02enHi", block_size=4, num_blocks=28) run(tag.power_on()) read_cmd = bytes([0x02, 0x20, 0x00]) resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd))) cc = resp.data[1:5] assert cc[0] == 0xE1 # NDEF magic assert cc[1] == 0x40 # version 1.0, read/write assert cc[3] & 0x01 # MBREAD supported — required for phone NDEF reads def test_ndef_tlv_in_block_1(self): """NDEF TLV starts in block 1.""" msg = b"\xD1\x01\x04\x54\x02enHi" tag = NfcType5Tag(uid=bytes(8), ndef_message=msg, block_size=4, num_blocks=28) run(tag.power_on()) read_cmd = bytes([0x02, 0x20, 0x01]) resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd))) data = resp.data[1:5] assert data[0] == 0x03 # NDEF TLV type assert data[1] == len(msg) def test_inherits_15693_inventory(self): """Type 5 tags respond to ISO 15693 inventory.""" medium = SoftwareMedium() tag = NfcType5Tag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06", ndef_message=b"") run(medium.attach(tag)) reader = Reader15693(medium) tags = run(reader.inventory()) assert len(tags) == 1 # --------------------------------------------------------------------------- # NXP ICODE platform (shared features) # --------------------------------------------------------------------------- class TestNxpIcodeTag: def test_inherits_type5(self): tag = NxpIcodeTag(uid=bytes(8)) run(tag.power_on()) # Should respond to inventory inv = bytes([0x26, 0x01, 0x00]) resp = run(tag.handle_frame(RFFrame.from_bytes(inv))) assert resp is not None def test_get_random_number(self): """NXP GET_RANDOM_NUMBER (0xB2) returns 2 random bytes.""" tag = NxpIcodeTag(uid=b"\xE0\x04" + bytes(6)) run(tag.power_on()) cmd = bytes([0x02, 0xB2, 0x04]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert len(resp.data) >= 3 # flags + 2 random bytes def test_get_nxp_system_info(self): """GET NXP SYSTEM INFO (0xAB) returns PP + feature data on SLIX2.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) run(tag.power_on()) cmd = bytes([0x02, 0xAB, 0x04]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert resp.data[0] == 0x00 # success flags assert len(resp.data) >= 8 # flags + PP + PP_cond + lock + feature_flags def test_read_signature(self): """READ SIGNATURE (0xBD) returns 32-byte ECC signature on SLIX2.""" sig = bytes(range(32)) tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), signature=sig) run(tag.power_on()) cmd = bytes([0x02, 0xBD, 0x04]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert resp.data[0] == 0x00 # success assert resp.data[1:] == sig def test_read_signature_default_zeros(self): """READ SIGNATURE returns 32 zero bytes when no signature configured on SLIX2.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) run(tag.power_on()) cmd = bytes([0x02, 0xBD, 0x04]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert len(resp.data) == 33 # flags + 32 bytes assert resp.data[1:] == bytes(32) def test_wrong_mfg_code_ignored(self): """Commands with wrong manufacturer code return None.""" tag = NxpIcodeTag(uid=bytes(8)) run(tag.power_on()) cmd = bytes([0x02, 0xB2, 0x05]) # mfg=0x05, not NXP resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) # Should fall through (not an NXP custom command match) # Depending on base class behavior, may return None or error # The key is it shouldn't return a valid GET_RANDOM response def test_slix_rejects_get_nxp_system_info(self): """SLIX (SL2S2002) does NOT support GET NXP SYSTEM INFO (0xAB).""" tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4)) run(tag.power_on()) cmd = bytes([0x02, 0xAB, 0x04]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is None def test_slix_rejects_read_signature(self): """SLIX (SL2S2002) does NOT support READ SIGNATURE (0xBD).""" tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4)) run(tag.power_on()) cmd = bytes([0x02, 0xBD, 0x04]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is None # --------------------------------------------------------------------------- # ICODE SLIX2 # --------------------------------------------------------------------------- class TestIcodeSlix2Tag: def test_inherits_nxp_icode(self): tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) run(tag.power_on()) inv = bytes([0x26, 0x01, 0x00]) resp = run(tag.handle_frame(RFFrame.from_bytes(inv))) assert resp is not None def test_privacy_mode_hides_uid(self): """In privacy mode, tag doesn't respond to inventory.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), privacy_password=0xDEADBEEF) run(tag.power_on()) tag.enter_privacy_mode() assert tag.privacy_mode inv = bytes([0x26, 0x01, 0x00]) resp = run(tag.handle_frame(RFFrame.from_bytes(inv))) assert resp is None # hidden def test_privacy_mode_allows_get_random(self): """In privacy mode, GET_RANDOM still works (needed for password XOR).""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), privacy_password=0xDEADBEEF) run(tag.power_on()) tag.enter_privacy_mode() cmd = bytes([0x02, 0xB2, 0x04]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert len(resp.data) >= 3 def test_privacy_mode_unlock_with_xor(self): """SET_PASSWORD with correct XOR'd privacy password disables privacy mode.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), privacy_password=0xDEADBEEF) run(tag.power_on()) tag.enter_privacy_mode() # First get random for XOR cmd = bytes([0x02, 0xB2, 0x04]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) r = resp.data[1:3] # XOR the password pwd = struct.pack(" bytes: """Get random from tag, return XOR'd password bytes.""" resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0xB2, 0x04])))) r = resp.data[1:3] pwd = struct.pack("= 33 # flags + 32-byte EAS sequence def test_eas_alarm_when_disabled(self): """EAS ALARM returns no response when EAS is disabled.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) run(tag.power_on()) assert not tag.eas_enabled cmd = bytes([0x02, CMD_EAS_ALARM, 0x04]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is None def test_eas_alarm_custom_id(self): """EAS ALARM includes EAS ID when set.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) run(tag.power_on()) tag.set_eas(True) tag._eas_id = 0x1234 cmd = bytes([0x02, CMD_EAS_ALARM, 0x04]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert resp.data[0] == 0x00 # --------------------------------------------------------------------------- # PASSWORD PROTECT EAS/AFI (0xA6) # --------------------------------------------------------------------------- class TestPasswordProtectEasAfi: def test_enable_eas_afi_protection(self): """PASSWORD PROTECT EAS/AFI (0xA6) enables password protection.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), eas_afi_password=0xAABBCCDD) run(tag.power_on()) assert not tag._eas_password_protected _authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD) cmd = bytes([0x02, CMD_PASSWORD_PROTECT_EAS_AFI, 0x04]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert resp.data[0] == 0x00 assert tag._eas_password_protected def test_eas_afi_protection_requires_auth(self): """PASSWORD PROTECT EAS/AFI fails without auth.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), eas_afi_password=0xAABBCCDD) run(tag.power_on()) cmd = bytes([0x02, CMD_PASSWORD_PROTECT_EAS_AFI, 0x04]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert resp.data[0] & 0x01 # --------------------------------------------------------------------------- # WRITE EAS ID (0xA7) # --------------------------------------------------------------------------- class TestWriteEasId: def test_write_eas_id(self): """WRITE EAS ID (0xA7) sets 16-bit EAS identifier.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), eas_afi_password=0xAABBCCDD) run(tag.power_on()) _authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD) cmd = bytes([0x02, CMD_WRITE_EAS_ID, 0x04, 0x34, 0x12]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert resp.data[0] == 0x00 assert tag._eas_id == 0x1234 def test_write_eas_id_requires_auth_when_protected(self): """WRITE EAS ID fails without auth when protected.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), eas_afi_password=0xAABBCCDD) run(tag.power_on()) tag._eas_password_protected = True cmd = bytes([0x02, CMD_WRITE_EAS_ID, 0x04, 0x34, 0x12]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert resp.data[0] & 0x01 # --------------------------------------------------------------------------- # STAY QUIET PERSISTENT (0xBC) # --------------------------------------------------------------------------- class TestStayQuietPersistent: @staticmethod def _sqp_cmd(uid: bytes) -> bytes: """Build addressed STAY QUIET PERSISTENT command.""" # flags=0x22 (addressed + high data rate), cmd=0xBC, UID reversed, mfg=0x04 return bytes([0x22, 0xBC]) + uid[::-1] + bytes([0x04]) def test_stay_quiet_persistent(self): """STAY QUIET PERSISTENT (0xBC) silences tag persistently.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) run(tag.power_on()) cmd = self._sqp_cmd(tag._uid) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert resp.data[0] == 0x00 # Tag should not respond to inventory inv = bytes([0x26, 0x01, 0x00]) resp = run(tag.handle_frame(RFFrame.from_bytes(inv))) assert resp is None def test_stay_quiet_persistent_survives_power_cycle(self): """STAY QUIET PERSISTENT survives power-off/on.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5)) run(tag.power_on()) cmd = self._sqp_cmd(tag._uid) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp.data[0] == 0x00 # Simulate power cycle run(tag.power_off()) run(tag.power_on()) # Still quiet inv = bytes([0x26, 0x01, 0x00]) resp = run(tag.handle_frame(RFFrame.from_bytes(inv))) assert resp is None def test_stay_quiet_persistent_reset_by_password(self): """SET_PASSWORD still works even in persistent quiet (addressed).""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), privacy_password=0xDEADBEEF) run(tag.power_on()) cmd = self._sqp_cmd(tag._uid) run(tag.handle_frame(RFFrame.from_bytes(cmd))) # SET_PASSWORD should still work (unaddressed custom cmds still handled) xored = _xor_password(tag, 0xDEADBEEF) cmd = bytes([0x02, 0xB3, 0x04, PWD_PRIVACY]) + xored resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert resp.data[0] == 0x00 # --------------------------------------------------------------------------- # INVENTORY READ (0xA0) / FAST INVENTORY READ (0xA1) # --------------------------------------------------------------------------- class TestInventoryRead: def test_inventory_read_returns_data(self): """INVENTORY READ (0xA0) returns UID + block data.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06", block_size=4, num_blocks=28) run(tag.power_on()) # Write known data to block 0 write_cmd = bytes([0x02, 0x21, 0x00, 0xAA, 0xBB, 0xCC, 0xDD]) run(tag.handle_frame(RFFrame.from_bytes(write_cmd))) # INVENTORY READ: flags(1) + cmd(1) + mfg(1) + mask_len(1) + first_block(1) + num_blocks(1) cmd = bytes([0x06, CMD_INVENTORY_READ, 0x04, 0x00, 0x00, 0x00]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert resp.data[0] == 0x00 # success flags # Response contains DSFID + UID + block data assert len(resp.data) > 10 def test_fast_inventory_read(self): """FAST INVENTORY READ (0xA1) same behavior as INVENTORY READ.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06", block_size=4, num_blocks=28) run(tag.power_on()) cmd = bytes([0x06, CMD_FAST_INVENTORY_READ, 0x04, 0x00, 0x00, 0x00]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert resp.data[0] == 0x00 def test_inventory_read_specific_blocks(self): """INVENTORY READ reads specific block range.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06", block_size=4, num_blocks=28) run(tag.power_on()) # Write known data write_cmd = bytes([0x02, 0x21, 0x02, 0x11, 0x22, 0x33, 0x44]) run(tag.handle_frame(RFFrame.from_bytes(write_cmd))) # Read block 2, 1 block cmd = bytes([0x06, CMD_INVENTORY_READ, 0x04, 0x00, 0x02, 0x00]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None # Response should contain the written data assert b"\x11\x22\x33\x44" in resp.data # --------------------------------------------------------------------------- # Block 79 counter (increment-only write semantics) # --------------------------------------------------------------------------- class TestBlock79Counter: def test_counter_increments(self): """WRITE to block 79 increments the 16-bit counter, not overwrites.""" tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), block_size=4, num_blocks=80) run(tag.power_on()) # Write increment value of 1 cmd = bytes([0x02, 0x21, 79, 0x01, 0x00, 0x00, 0x00]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) assert resp is not None assert resp.data[0] == 0x00 # Read back — counter should be 1 cmd = bytes([0x02, 0x20, 79]) resp = run(tag.handle_frame(RFFrame.from_bytes(cmd))) counter = struct.unpack_from("