"""Tests for pm3py.sim.iso14443a — ISO 14443-A transponder and reader state machines.""" import asyncio import pytest from bitarray import bitarray from pm3py.sim.frame import RFFrame from pm3py.sim.medium import SoftwareMedium from pm3py.sim.iso14443a import ( Tag14443A, Tag14443A_3, Tag14443A_4, Reader14443A, State14443A, REQA, WUPA, HLTA, CL1, CL2, CL3, ) def run(coro): return asyncio.get_event_loop().run_until_complete(coro) # --------------------------------------------------------------------------- # Tag14443A — anticollision base (Part 2+3) # --------------------------------------------------------------------------- class TestTag14443AStates: """Test basic state transitions.""" def test_initial_state_is_idle(self): tag = Tag14443A_3(uid=b"\x01\x02\x03\x04") run(tag.power_on()) assert tag.state == "IDLE" def test_reqa_transitions_to_ready(self): tag = Tag14443A_3(uid=b"\x01\x02\x03\x04") run(tag.power_on()) resp = run(tag.handle_frame(RFFrame.from_hex("26"))) # REQA assert resp is not None assert tag.state == "READY" def test_reqa_returns_atqa(self): tag = Tag14443A_3(uid=b"\x01\x02\x03\x04", atqa=b"\x44\x00") run(tag.power_on()) resp = run(tag.handle_frame(RFFrame.from_hex("26"))) assert resp.data == b"\x44\x00" def test_wupa_transitions_to_ready(self): tag = Tag14443A_3(uid=b"\x01\x02\x03\x04") run(tag.power_on()) resp = run(tag.handle_frame(RFFrame.from_hex("52"))) # WUPA assert resp is not None assert tag.state == "READY" def test_idle_ignores_non_reqa_wupa(self): tag = Tag14443A_3(uid=b"\x01\x02\x03\x04") run(tag.power_on()) resp = run(tag.handle_frame(RFFrame.from_hex("9320"))) assert resp is None assert tag.state == "IDLE" class TestTag14443AAnticollision4Byte: """Test anticollision with 4-byte UID (single cascade level).""" def test_anticol_cl1_returns_uid_and_bcc(self): uid = b"\x01\x02\x03\x04" tag = Tag14443A_3(uid=uid) run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) # → READY # ANTICOL CL1: SEL=0x93, NVB=0x20 (2 bytes known = SEL+NVB only) resp = run(tag.handle_frame(RFFrame.from_hex("9320"))) assert resp is not None # Response: 4 UID bytes + BCC bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3] assert resp.data == uid + bytes([bcc]) def test_select_cl1_returns_sak_and_transitions_to_active(self): uid = b"\x01\x02\x03\x04" bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3] tag = Tag14443A_3(uid=uid, sak=0x08) run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) # → READY # SELECT CL1: SEL=0x93, NVB=0x70, UID(4), BCC select = b"\x93\x70" + uid + bytes([bcc]) resp = run(tag.handle_frame(RFFrame.from_bytes(select))) assert resp is not None # SAK response: 1 byte assert resp.data[0] == 0x08 assert tag.state == "ACTIVE" def test_select_wrong_uid_no_response(self): tag = Tag14443A_3(uid=b"\x01\x02\x03\x04") run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) wrong_uid = b"\xFF\xFF\xFF\xFF" bcc = wrong_uid[0] ^ wrong_uid[1] ^ wrong_uid[2] ^ wrong_uid[3] select = b"\x93\x70" + wrong_uid + bytes([bcc]) resp = run(tag.handle_frame(RFFrame.from_bytes(select))) assert resp is None class TestTag14443AAnticollision7Byte: """Test anticollision with 7-byte UID (two cascade levels).""" def test_cl1_returns_ct_plus_first3_with_cascade_sak(self): uid = b"\x01\x02\x03\x04\x05\x06\x07" tag = Tag14443A_3(uid=uid, sak=0x08) run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) # ANTICOL CL1 resp = run(tag.handle_frame(RFFrame.from_hex("9320"))) assert resp is not None # CL1: CT(0x88) + uid[0:3] + BCC ct_uid = b"\x88" + uid[0:3] bcc = ct_uid[0] ^ ct_uid[1] ^ ct_uid[2] ^ ct_uid[3] assert resp.data == ct_uid + bytes([bcc]) def test_select_cl1_sak_indicates_cascade(self): uid = b"\x01\x02\x03\x04\x05\x06\x07" tag = Tag14443A_3(uid=uid, sak=0x08) run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) ct_uid = b"\x88" + uid[0:3] bcc = ct_uid[0] ^ ct_uid[1] ^ ct_uid[2] ^ ct_uid[3] select = b"\x93\x70" + ct_uid + bytes([bcc]) resp = run(tag.handle_frame(RFFrame.from_bytes(select))) # SAK bit 2 set = cascade not complete assert resp.data[0] & 0x04 != 0 assert tag.state == "READY" # still in anticollision, not ACTIVE def test_cl2_returns_remaining_uid(self): uid = b"\x01\x02\x03\x04\x05\x06\x07" tag = Tag14443A_3(uid=uid, sak=0x08) run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) # Complete CL1 ct_uid = b"\x88" + uid[0:3] bcc1 = ct_uid[0] ^ ct_uid[1] ^ ct_uid[2] ^ ct_uid[3] run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1])))) # ANTICOL CL2 resp = run(tag.handle_frame(RFFrame.from_hex("9520"))) assert resp is not None cl2_uid = uid[3:7] bcc2 = cl2_uid[0] ^ cl2_uid[1] ^ cl2_uid[2] ^ cl2_uid[3] assert resp.data == cl2_uid + bytes([bcc2]) def test_select_cl2_completes_with_final_sak(self): uid = b"\x01\x02\x03\x04\x05\x06\x07" tag = Tag14443A_3(uid=uid, sak=0x08) run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) # CL1 ct_uid = b"\x88" + uid[0:3] bcc1 = ct_uid[0] ^ ct_uid[1] ^ ct_uid[2] ^ ct_uid[3] run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1])))) # CL2 SELECT cl2_uid = uid[3:7] bcc2 = cl2_uid[0] ^ cl2_uid[1] ^ cl2_uid[2] ^ cl2_uid[3] resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2_uid + bytes([bcc2])))) assert resp.data[0] == 0x08 # final SAK, no cascade bit assert resp.data[0] & 0x04 == 0 assert tag.state == "ACTIVE" class TestTag14443AAnticollision10Byte: """Test anticollision with 10-byte UID (three cascade levels).""" def test_full_10byte_anticollision(self): uid = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A" tag = Tag14443A_3(uid=uid, sak=0x20) run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) # CL1: CT + uid[0:3] ct1 = b"\x88" + uid[0:3] bcc1 = ct1[0] ^ ct1[1] ^ ct1[2] ^ ct1[3] resp = run(tag.handle_frame(RFFrame.from_hex("9320"))) assert resp.data == ct1 + bytes([bcc1]) resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct1 + bytes([bcc1])))) assert resp.data[0] & 0x04 != 0 # cascade # CL2: CT + uid[3:6] ct2 = b"\x88" + uid[3:6] bcc2 = ct2[0] ^ ct2[1] ^ ct2[2] ^ ct2[3] resp = run(tag.handle_frame(RFFrame.from_hex("9520"))) assert resp.data == ct2 + bytes([bcc2]) resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + ct2 + bytes([bcc2])))) assert resp.data[0] & 0x04 != 0 # cascade # CL3: uid[6:10] cl3 = uid[6:10] bcc3 = cl3[0] ^ cl3[1] ^ cl3[2] ^ cl3[3] resp = run(tag.handle_frame(RFFrame.from_hex("9720"))) assert resp.data == cl3 + bytes([bcc3]) resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x97\x70" + cl3 + bytes([bcc3])))) # Tag14443A_3 clears SAK bit 5, so 0x20 & ~0x20 = 0x00 assert resp.data[0] == 0x00 # final SAK (Part 3, bit 5 cleared) assert tag.state == "ACTIVE" class TestTag14443AHalt: """Test HALT behavior.""" def test_hlta_transitions_to_halt(self): tag = Tag14443A_3(uid=b"\x01\x02\x03\x04") run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) # → READY # Select to ACTIVE uid = b"\x01\x02\x03\x04" bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3] run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc])))) # HLTA resp = run(tag.handle_frame(RFFrame.from_hex("5000"))) assert resp is None # no response to HLTA assert tag.state == "HALT" def test_halted_tag_ignores_reqa(self): tag = Tag14443A_3(uid=b"\x01\x02\x03\x04") run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) uid = b"\x01\x02\x03\x04" bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3] run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc])))) run(tag.handle_frame(RFFrame.from_hex("5000"))) # HALT resp = run(tag.handle_frame(RFFrame.from_hex("26"))) # REQA assert resp is None assert tag.state == "HALT" def test_halted_tag_wakes_on_wupa(self): tag = Tag14443A_3(uid=b"\x01\x02\x03\x04") run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) uid = b"\x01\x02\x03\x04" bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3] run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc])))) run(tag.handle_frame(RFFrame.from_hex("5000"))) # HALT resp = run(tag.handle_frame(RFFrame.from_hex("52"))) # WUPA assert resp is not None assert tag.state == "READY" # --------------------------------------------------------------------------- # Tag14443A_3 — Part 3 only (rejects RATS) # --------------------------------------------------------------------------- class TestTag14443A_3: """Tag14443A_3 rejects RATS (no ISO-DEP).""" def test_rejects_rats(self): tag = Tag14443A_3(uid=b"\x01\x02\x03\x04", sak=0x08) run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) uid = b"\x01\x02\x03\x04" bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3] run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc])))) # RATS (0xE0, CID=0) resp = run(tag.handle_frame(RFFrame.from_hex("E050"))) assert resp is None # Part 3 only, no RATS def test_sak_bit5_is_zero(self): tag = Tag14443A_3(uid=b"\x01\x02\x03\x04", sak=0x08) run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) uid = b"\x01\x02\x03\x04" bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3] resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc])))) # SAK bit 5 = 0 (no ISO-DEP) assert resp.data[0] & 0x20 == 0 # --------------------------------------------------------------------------- # Tag14443A_4 — Part 3 + Part 4 (ISO-DEP capable) # --------------------------------------------------------------------------- class TestTag14443A_4: """Tag14443A_4 supports RATS and I-block exchange.""" def test_sak_bit5_is_set(self): tag = Tag14443A_4(uid=b"\x01\x02\x03\x04", sak=0x20, ats=b"\x05\x78\x80\x70\x02") run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) uid = b"\x01\x02\x03\x04" bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3] resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc])))) assert resp.data[0] & 0x20 != 0 def test_rats_returns_ats(self): ats = b"\x05\x78\x80\x70\x02" tag = Tag14443A_4(uid=b"\x01\x02\x03\x04", sak=0x20, ats=ats) run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) uid = b"\x01\x02\x03\x04" bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3] run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc])))) # RATS: 0xE0, FSD/CID byte resp = run(tag.handle_frame(RFFrame.from_hex("E050"))) assert resp is not None assert resp.data == ats assert tag.state == "PROTOCOL" def test_iblock_exchange(self): """After RATS, I-blocks carry APDUs.""" tag = Tag14443A_4(uid=b"\x01\x02\x03\x04", sak=0x20, ats=b"\x05\x78\x80\x70\x02") run(tag.power_on()) run(tag.handle_frame(RFFrame.from_hex("26"))) uid = b"\x01\x02\x03\x04" bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3] run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc])))) run(tag.handle_frame(RFFrame.from_hex("E050"))) # RATS → PROTOCOL # I-block: PCB=0x02 (block 0), payload = SELECT APDU apdu = b"\x00\xA4\x04\x00\x07\xD2\x76\x00\x00\x85\x01\x01" iblock = b"\x02" + apdu resp = run(tag.handle_frame(RFFrame.from_bytes(iblock))) # Default handler should return something (at minimum an I-block response) assert resp is not None # Response PCB should be I-block with toggled block number assert resp.data[0] & 0xC0 == 0x00 # I-block: bits 7-6 = 00 # --------------------------------------------------------------------------- # Reader14443A — anticollision tree walk # --------------------------------------------------------------------------- class TestReader14443AInventory: """Test reader anticollision with single and multiple tags.""" def test_inventory_single_4byte_tag(self): medium = SoftwareMedium() uid = b"\x01\x02\x03\x04" run(medium.attach(Tag14443A_3(uid=uid, sak=0x08))) reader = Reader14443A(medium) uids = run(reader.inventory()) assert len(uids) == 1 assert uids[0] == uid def test_inventory_single_7byte_tag(self): medium = SoftwareMedium() uid = b"\x01\x02\x03\x04\x05\x06\x07" run(medium.attach(Tag14443A_3(uid=uid, sak=0x08))) reader = Reader14443A(medium) uids = run(reader.inventory()) assert len(uids) == 1 assert uids[0] == uid def test_inventory_two_tags_different_uids(self): medium = SoftwareMedium() uid1 = b"\x01\x02\x03\x04" uid2 = b"\x05\x06\x07\x08" run(medium.attach(Tag14443A_3(uid=uid1, sak=0x08))) run(medium.attach(Tag14443A_3(uid=uid2, sak=0x08))) reader = Reader14443A(medium) uids = run(reader.inventory()) assert len(uids) == 2 assert set(uids) == {uid1, uid2} def test_inventory_four_tags(self): medium = SoftwareMedium() tag_uids = [bytes([i, i+1, i+2, i+3]) for i in range(0, 16, 4)] for uid in tag_uids: run(medium.attach(Tag14443A_3(uid=uid, sak=0x08))) reader = Reader14443A(medium) uids = run(reader.inventory()) assert len(uids) == 4 assert set(uids) == set(tag_uids) class TestReader14443ASelect: """Test reader SELECT sequence.""" def test_select_known_4byte_uid(self): medium = SoftwareMedium() uid = b"\x01\x02\x03\x04" run(medium.attach(Tag14443A_3(uid=uid, sak=0x08))) reader = Reader14443A(medium) result = run(reader.select_tag(uid)) assert result["uid"] == uid assert result["sak"] == 0x08 def test_select_known_7byte_uid(self): medium = SoftwareMedium() uid = b"\x01\x02\x03\x04\x05\x06\x07" run(medium.attach(Tag14443A_3(uid=uid, sak=0x08))) reader = Reader14443A(medium) result = run(reader.select_tag(uid)) assert result["uid"] == uid assert result["sak"] == 0x08 class TestReader14443ARATS: """Test reader RATS for Layer 4.""" def test_rats_returns_ats(self): medium = SoftwareMedium() ats = b"\x05\x78\x80\x70\x02" uid = b"\x01\x02\x03\x04" run(medium.attach(Tag14443A_4(uid=uid, sak=0x20, ats=ats))) reader = Reader14443A(medium) run(reader.select_tag(uid)) result = run(reader.rats()) assert result["ats"] == ats