Files
pm3py/tests/test_sim_14443a.py
michael c1ab980036 refactor: remove all backward-compat shims from sim/
Deletes 24 shim files from sim/. All test imports now point directly
to canonical transponders/ and trace/ paths. sim/ contains only
infrastructure (15 files): frame, memory, transponder ABC, reader ABC,
medium, sim_session, table_compiler, replay, relay, fuzzer, pm3medium,
mcu_bridge, mcu_protocol, dual_session, __init__.

751 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:56:40 -07:00

420 lines
16 KiB
Python

"""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.transponders.hf.iso14443a.base 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