feat: migrate sim framework from worktree to master
Migrates all 43 sim modules and 23 test files (~7.8k LOC, 686 tests) from .worktrees/sim-framework/ into pm3py/sim/. Import fixes: - 4 files: ..protocol/..transport → ..core.protocol/..core.transport - trace_fmt.py: pm3py.hf_15 → pm3py.trace.ndef - test_sim_pm3medium.py: flat imports → core.* - test_sim_trace_fmt.py: flat imports → core.* - Added sim Cmd entries to core/protocol.py (EML_SETMEM, SIM_TABLE_*) 751 tests passing (686 sim + 65 core). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
419
tests/test_sim_14443a.py
Normal file
419
tests/test_sim_14443a.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""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
|
||||
285
tests/test_sim_15693.py
Normal file
285
tests/test_sim_15693.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""Tests for pm3py.sim.iso15693 — ISO 15693 transponder and reader."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.medium import SoftwareMedium
|
||||
from pm3py.sim.iso15693 import Tag15693, Reader15693, State15693
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tag15693 — basic state machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTag15693States:
|
||||
def test_initial_state_is_ready(self):
|
||||
tag = Tag15693(uid=bytes(8))
|
||||
run(tag.power_on())
|
||||
assert tag.state == "READY"
|
||||
|
||||
def test_stay_quiet_transitions_to_quiet(self):
|
||||
tag = Tag15693(uid=b"\x01" * 8)
|
||||
run(tag.power_on())
|
||||
# Stay Quiet (cmd=0x02) with addressed flag and UID
|
||||
frame_data = bytes([0x22, 0x02]) + tag._uid[::-1] # flags + cmd + UID (LSB first)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(frame_data)))
|
||||
assert tag.state == "QUIET"
|
||||
|
||||
def test_quiet_tag_ignores_inventory(self):
|
||||
tag = Tag15693(uid=b"\x01" * 8)
|
||||
run(tag.power_on())
|
||||
# Stay Quiet
|
||||
frame_data = bytes([0x22, 0x02]) + tag._uid[::-1]
|
||||
run(tag.handle_frame(RFFrame.from_bytes(frame_data)))
|
||||
assert tag.state == "QUIET"
|
||||
|
||||
# Inventory should be ignored
|
||||
inv = bytes([0x26, 0x01, 0x00]) # flags=0x26, cmd=0x01, mask_len=0
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
|
||||
assert resp is None
|
||||
|
||||
def test_reset_to_ready(self):
|
||||
tag = Tag15693(uid=b"\x01" * 8)
|
||||
run(tag.power_on())
|
||||
# Stay Quiet
|
||||
frame_data = bytes([0x22, 0x02]) + tag._uid[::-1]
|
||||
run(tag.handle_frame(RFFrame.from_bytes(frame_data)))
|
||||
assert tag.state == "QUIET"
|
||||
|
||||
# Reset to Ready (cmd=0x26)
|
||||
reset = bytes([0x22, 0x26]) + tag._uid[::-1]
|
||||
run(tag.handle_frame(RFFrame.from_bytes(reset)))
|
||||
assert tag.state == "READY"
|
||||
|
||||
|
||||
class TestTag15693Inventory:
|
||||
def test_1slot_inventory_returns_uid(self):
|
||||
uid = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x03"
|
||||
tag = Tag15693(uid=uid, dsfid=0x00)
|
||||
run(tag.power_on())
|
||||
|
||||
# Inventory: flags=0x26 (high data rate + inventory), cmd=0x01, mask_len=0
|
||||
inv = bytes([0x26, 0x01, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
|
||||
assert resp is not None
|
||||
# Response: flags(1) + DSFID(1) + UID(8, LSB first)
|
||||
assert len(resp.data) == 10
|
||||
assert resp.data[0] == 0x00 # no error
|
||||
assert resp.data[1] == 0x00 # DSFID
|
||||
# UID is LSB first in response
|
||||
assert resp.data[2:10] == uid[::-1]
|
||||
|
||||
def test_16slot_inventory_responds_in_correct_slot(self):
|
||||
"""With 16-slot flag, tag should only respond in its hashed slot."""
|
||||
uid = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x03"
|
||||
tag = Tag15693(uid=uid)
|
||||
run(tag.power_on())
|
||||
|
||||
# 16-slot inventory: flags=0x06 (no inventory flag bit 5 clear = 16 slots)
|
||||
# Actually: flag bit 5 = 0 means 16 slots, bit 5 = 1 means 1 slot
|
||||
inv = bytes([0x06, 0x01, 0x00]) # flags=0x06, cmd=0x01, mask_len=0
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
|
||||
# Tag responds (slot assignment handled by medium, not tag directly)
|
||||
assert resp is not None
|
||||
|
||||
|
||||
class TestTag15693ReadWrite:
|
||||
def test_read_single_block(self):
|
||||
memory = bytearray(112) # 28 blocks * 4 bytes
|
||||
memory[0:4] = b"\xDE\xAD\xBE\xEF"
|
||||
tag = Tag15693(uid=bytes(8), memory=memory, block_size=4, num_blocks=28)
|
||||
run(tag.power_on())
|
||||
|
||||
# Read Single Block (cmd=0x20), unaddressed, block 0
|
||||
read_cmd = bytes([0x02, 0x20, 0x00]) # flags=0x02, cmd=0x20, block=0
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x00 # flags: no error
|
||||
assert resp.data[1:5] == b"\xDE\xAD\xBE\xEF"
|
||||
|
||||
def test_read_single_block_addressed(self):
|
||||
uid = b"\x01\x02\x03\x04\x05\x06\x07\x08"
|
||||
memory = bytearray(112)
|
||||
memory[4:8] = b"\xCA\xFE\xBA\xBE"
|
||||
tag = Tag15693(uid=uid, memory=memory, block_size=4, num_blocks=28)
|
||||
run(tag.power_on())
|
||||
|
||||
# Addressed read: flags=0x22 (addressed), cmd=0x20, UID(8 LSB), block=1
|
||||
read_cmd = bytes([0x22, 0x20]) + uid[::-1] + bytes([0x01])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[1:5] == b"\xCA\xFE\xBA\xBE"
|
||||
|
||||
def test_read_wrong_uid_no_response(self):
|
||||
tag = Tag15693(uid=b"\x01" * 8)
|
||||
run(tag.power_on())
|
||||
|
||||
wrong_uid = b"\xFF" * 8
|
||||
read_cmd = bytes([0x22, 0x20]) + wrong_uid[::-1] + bytes([0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd)))
|
||||
assert resp is None
|
||||
|
||||
def test_write_single_block(self):
|
||||
tag = Tag15693(uid=bytes(8), block_size=4, num_blocks=28)
|
||||
run(tag.power_on())
|
||||
|
||||
# Write Single Block (cmd=0x21), unaddressed, block 0, data
|
||||
write_cmd = bytes([0x02, 0x21, 0x00]) + b"\xAA\xBB\xCC\xDD"
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(write_cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x00 # no error
|
||||
|
||||
# Verify
|
||||
read_cmd = bytes([0x02, 0x20, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd)))
|
||||
assert resp.data[1:5] == b"\xAA\xBB\xCC\xDD"
|
||||
|
||||
def test_read_out_of_range_block(self):
|
||||
tag = Tag15693(uid=bytes(8), block_size=4, num_blocks=4)
|
||||
run(tag.power_on())
|
||||
|
||||
read_cmd = bytes([0x02, 0x20, 0x10]) # block 16, out of range
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] & 0x01 != 0 # error flag set
|
||||
|
||||
|
||||
class TestTag15693SystemInfo:
|
||||
def test_get_system_info(self):
|
||||
uid = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x03"
|
||||
tag = Tag15693(uid=uid, dsfid=0x42, block_size=4, num_blocks=28)
|
||||
run(tag.power_on())
|
||||
|
||||
# Get System Information (cmd=0x2B), unaddressed
|
||||
sysinfo = bytes([0x02, 0x2B])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(sysinfo)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x00 # no error
|
||||
# Info flags(1) + UID(8) + DSFID(1) + AFI(1) + mem_size(2) + IC_ref(1)
|
||||
assert len(resp.data) >= 14
|
||||
|
||||
def test_custom_command_extension_point(self):
|
||||
"""_handle_custom_command returns error for unrecognized commands."""
|
||||
tag = Tag15693(uid=bytes(8))
|
||||
run(tag.power_on())
|
||||
|
||||
# Custom command 0xA0 (NXP range)
|
||||
custom = bytes([0x02, 0xA0, 0x04]) # flags, cmd, mfg code
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(custom)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] & 0x01 # error flag set
|
||||
assert resp.data[1] == 0x01 # not supported
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reader15693 — inventory and block operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReader15693Inventory:
|
||||
def test_inventory_single_tag(self):
|
||||
medium = SoftwareMedium()
|
||||
uid = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x03"
|
||||
run(medium.attach(Tag15693(uid=uid, dsfid=0x00)))
|
||||
reader = Reader15693(medium)
|
||||
|
||||
tags = run(reader.inventory())
|
||||
assert len(tags) == 1
|
||||
assert tags[0]["uid"] == uid
|
||||
|
||||
def test_inventory_two_tags_collision(self):
|
||||
medium = SoftwareMedium()
|
||||
uid1 = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x03"
|
||||
uid2 = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x04"
|
||||
run(medium.attach(Tag15693(uid=uid1)))
|
||||
run(medium.attach(Tag15693(uid=uid2)))
|
||||
reader = Reader15693(medium)
|
||||
|
||||
tags = run(reader.inventory())
|
||||
assert len(tags) == 2
|
||||
found_uids = {t["uid"] for t in tags}
|
||||
assert found_uids == {uid1, uid2}
|
||||
|
||||
def test_inventory_no_tags(self):
|
||||
medium = SoftwareMedium()
|
||||
reader = Reader15693(medium)
|
||||
tags = run(reader.inventory())
|
||||
assert len(tags) == 0
|
||||
|
||||
|
||||
class TestReader15693ReadWrite:
|
||||
def test_read_block(self):
|
||||
medium = SoftwareMedium()
|
||||
memory = bytearray(112)
|
||||
memory[0:4] = b"\xDE\xAD\xBE\xEF"
|
||||
uid = b"\x01" * 8
|
||||
run(medium.attach(Tag15693(uid=uid, memory=memory, block_size=4, num_blocks=28)))
|
||||
reader = Reader15693(medium)
|
||||
|
||||
result = run(reader.read_block(uid=uid, block=0))
|
||||
assert result["success"]
|
||||
assert result["data"] == b"\xDE\xAD\xBE\xEF"
|
||||
|
||||
def test_write_block(self):
|
||||
medium = SoftwareMedium()
|
||||
uid = b"\x01" * 8
|
||||
run(medium.attach(Tag15693(uid=uid, block_size=4, num_blocks=28)))
|
||||
reader = Reader15693(medium)
|
||||
|
||||
result = run(reader.write_block(uid=uid, block=0, data=b"\x11\x22\x33\x44"))
|
||||
assert result["success"]
|
||||
|
||||
result = run(reader.read_block(uid=uid, block=0))
|
||||
assert result["data"] == b"\x11\x22\x33\x44"
|
||||
|
||||
def test_system_info(self):
|
||||
medium = SoftwareMedium()
|
||||
uid = b"\xE0\x04\x01\x50\x5C\x6A\x1B\x03"
|
||||
run(medium.attach(Tag15693(uid=uid, dsfid=0x42, block_size=4, num_blocks=28)))
|
||||
reader = Reader15693(medium)
|
||||
|
||||
result = run(reader.system_info(uid=uid))
|
||||
assert result["uid"] == uid
|
||||
assert result["dsfid"] == 0x42
|
||||
assert result["num_blocks"] == 28
|
||||
assert result["block_size"] == 4
|
||||
|
||||
|
||||
class TestReader15693DynamicInjection:
|
||||
"""Test ISO 15693 anti-collision tricks via dynamic attach/detach."""
|
||||
|
||||
def test_detach_mid_inventory(self):
|
||||
medium = SoftwareMedium()
|
||||
uid1 = b"\x01" * 8
|
||||
uid2 = b"\x02" * 8
|
||||
run(medium.attach(Tag15693(uid=uid1)))
|
||||
tid2 = run(medium.attach(Tag15693(uid=uid2)))
|
||||
reader = Reader15693(medium)
|
||||
|
||||
# Both present
|
||||
tags = run(reader.inventory())
|
||||
assert len(tags) == 2
|
||||
|
||||
# Remove one
|
||||
run(medium.detach(tid2))
|
||||
tags = run(reader.inventory())
|
||||
assert len(tags) == 1
|
||||
assert tags[0]["uid"] == uid1
|
||||
|
||||
def test_attach_new_tag(self):
|
||||
medium = SoftwareMedium()
|
||||
uid1 = b"\x01" * 8
|
||||
run(medium.attach(Tag15693(uid=uid1)))
|
||||
reader = Reader15693(medium)
|
||||
|
||||
tags = run(reader.inventory())
|
||||
assert len(tags) == 1
|
||||
|
||||
# Add new tag
|
||||
uid2 = b"\x02" * 8
|
||||
run(medium.attach(Tag15693(uid=uid2)))
|
||||
tags = run(reader.inventory())
|
||||
assert len(tags) == 2
|
||||
160
tests/test_sim_access_control.py
Normal file
160
tests/test_sim_access_control.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Tests for access control I/O: credentials, Wiegand, OSDP."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.access_control.credential import (
|
||||
Credential, encode_wiegand, decode_wiegand, from_uid,
|
||||
)
|
||||
from pm3py.sim.access_control.wiegand import (
|
||||
WiegandOutput, WiegandInput,
|
||||
)
|
||||
from pm3py.sim.access_control.osdp import (
|
||||
OSDPFrame, OSDPChannel, osdp_crc16,
|
||||
)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCredential:
|
||||
def test_construction(self):
|
||||
c = Credential(facility_code=42, card_number=12345, format="H10301")
|
||||
assert c.facility_code == 42
|
||||
assert c.card_number == 12345
|
||||
assert c.format == "H10301"
|
||||
|
||||
def test_equality(self):
|
||||
c1 = Credential(42, 12345, "H10301")
|
||||
c2 = Credential(42, 12345, "H10301")
|
||||
assert c1 == c2
|
||||
|
||||
def test_from_uid_derives_credential(self):
|
||||
"""Derive a credential from a tag UID."""
|
||||
cred = from_uid(b"\x01\x02\x03\x04", format="H10301")
|
||||
assert cred.facility_code >= 0
|
||||
assert cred.card_number >= 0
|
||||
assert cred.format == "H10301"
|
||||
|
||||
|
||||
class TestWiegandEncoding:
|
||||
def test_encode_26bit(self):
|
||||
bits = encode_wiegand(Credential(42, 12345, "H10301"))
|
||||
assert len(bits) == 26
|
||||
|
||||
def test_decode_26bit(self):
|
||||
cred = Credential(42, 12345, "H10301")
|
||||
bits = encode_wiegand(cred)
|
||||
decoded = decode_wiegand(bits, "H10301")
|
||||
assert decoded.facility_code == 42
|
||||
assert decoded.card_number == 12345
|
||||
|
||||
def test_encode_37bit(self):
|
||||
bits = encode_wiegand(Credential(1000, 50000, "H10304"))
|
||||
assert len(bits) == 37
|
||||
|
||||
def test_decode_37bit_roundtrip(self):
|
||||
cred = Credential(1000, 50000, "H10304")
|
||||
bits = encode_wiegand(cred)
|
||||
decoded = decode_wiegand(bits, "H10304")
|
||||
assert decoded == cred
|
||||
|
||||
def test_26bit_parity(self):
|
||||
bits = encode_wiegand(Credential(42, 12345, "H10301"))
|
||||
assert sum(bits[:13]) % 2 == 0 # even parity first half
|
||||
assert sum(bits[13:]) % 2 == 1 # odd parity second half
|
||||
|
||||
def test_encode_35bit_corp1000(self):
|
||||
bits = encode_wiegand(Credential(100, 5000, "C1000_35"))
|
||||
assert len(bits) == 35
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wiegand I/O (software mode)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWiegandOutput:
|
||||
def test_software_mode_encode(self):
|
||||
"""WiegandOutput in software mode stores bit stream."""
|
||||
out = WiegandOutput()
|
||||
cred = Credential(42, 12345, "H10301")
|
||||
out.send(cred)
|
||||
assert len(out.last_bits) == 26
|
||||
|
||||
def test_software_mode_timing(self):
|
||||
"""Verify pulse timing parameters exist."""
|
||||
out = WiegandOutput(pulse_width_us=50, interval_us=2000)
|
||||
assert out.pulse_width_us == 50
|
||||
assert out.interval_us == 2000
|
||||
|
||||
|
||||
class TestWiegandInput:
|
||||
def test_software_mode_decode(self):
|
||||
"""WiegandInput in software mode decodes bit stream."""
|
||||
inp = WiegandInput()
|
||||
cred = Credential(42, 12345, "H10301")
|
||||
bits = encode_wiegand(cred)
|
||||
decoded = inp.decode(bits, "H10301")
|
||||
assert decoded.facility_code == 42
|
||||
assert decoded.card_number == 12345
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OSDP protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestOSDPFrame:
|
||||
def test_frame_construction(self):
|
||||
frame = OSDPFrame(address=0, command=0x60, data=b"")
|
||||
raw = frame.encode()
|
||||
assert raw[0] == 0x53 # SOM
|
||||
assert len(raw) >= 7 # SOM + ADDR + LEN(2) + CMD + CRC(2)
|
||||
|
||||
def test_frame_parse_roundtrip(self):
|
||||
frame = OSDPFrame(address=1, command=0x61, data=b"\x01\x02")
|
||||
raw = frame.encode()
|
||||
parsed = OSDPFrame.decode(raw)
|
||||
assert parsed.address == 1
|
||||
assert parsed.command == 0x61
|
||||
assert parsed.data == b"\x01\x02"
|
||||
|
||||
def test_crc16(self):
|
||||
"""OSDP uses CRC-16/AUG-CCITT."""
|
||||
crc = osdp_crc16(b"\x53\x00\x08\x00\x60")
|
||||
assert isinstance(crc, int)
|
||||
assert 0 <= crc <= 0xFFFF
|
||||
|
||||
|
||||
class TestOSDPChannel:
|
||||
def test_software_loopback(self):
|
||||
"""Channel in software mode: controller polls, reader replies."""
|
||||
channel = OSDPChannel() # software loopback
|
||||
|
||||
# Controller sends POLL
|
||||
poll = OSDPFrame(address=0, command=0x60, data=b"")
|
||||
channel.send(poll)
|
||||
|
||||
# Reader responds with ACK
|
||||
ack = OSDPFrame(address=0, command=0x40, data=b"")
|
||||
channel.send(ack)
|
||||
|
||||
# Both frames in buffer
|
||||
assert len(channel.buffer) == 2
|
||||
|
||||
def test_card_present_notification(self):
|
||||
"""Simulate card present event via OSDP."""
|
||||
channel = OSDPChannel()
|
||||
cred = Credential(42, 12345, "H10301")
|
||||
bits = encode_wiegand(cred)
|
||||
|
||||
# Reader sends RAW (card data) — cmd 0x73
|
||||
raw_data = bytes(bits) # simplified
|
||||
frame = OSDPFrame(address=0, command=0x73, data=raw_data)
|
||||
channel.send(frame)
|
||||
|
||||
last = channel.buffer[-1]
|
||||
assert last.command == 0x73
|
||||
189
tests/test_sim_advanced.py
Normal file
189
tests/test_sim_advanced.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Tests for advanced applications: fuzzer, relay, replay."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.medium import SoftwareMedium
|
||||
from pm3py.sim.iso14443a import Tag14443A_3, Reader14443A
|
||||
from pm3py.sim.fuzzer import MutationFuzzer, GrammarFuzzer
|
||||
from pm3py.sim.relay import RelayTransponder, MitMProxy
|
||||
from pm3py.sim.replay import TraceRecorder, TraceReplayer, TraceEntry
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MutationFuzzer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMutationFuzzer:
|
||||
def test_mutate_produces_different_frame(self):
|
||||
seed = RFFrame.from_hex("26")
|
||||
fuzzer = MutationFuzzer([seed], seed=42)
|
||||
mutated = fuzzer.mutate(seed)
|
||||
# At least sometimes should differ (with enough attempts)
|
||||
found_different = False
|
||||
for _ in range(20):
|
||||
m = fuzzer.mutate(seed)
|
||||
if m.data != seed.data or m.bit_count != seed.bit_count:
|
||||
found_different = True
|
||||
break
|
||||
assert found_different
|
||||
|
||||
def test_generate_yields_frames(self):
|
||||
seeds = [RFFrame.from_hex("26"), RFFrame.from_hex("9320")]
|
||||
fuzzer = MutationFuzzer(seeds, seed=42)
|
||||
frames = list(fuzzer.generate(count=10))
|
||||
assert len(frames) == 10
|
||||
assert all(isinstance(f, RFFrame) for f in frames)
|
||||
|
||||
def test_deterministic_with_seed(self):
|
||||
seeds = [RFFrame.from_hex("A5B6")]
|
||||
f1 = list(MutationFuzzer(seeds, seed=123).generate(5))
|
||||
f2 = list(MutationFuzzer(seeds, seed=123).generate(5))
|
||||
assert [f.data for f in f1] == [f.data for f in f2]
|
||||
|
||||
def test_strategies_include_flip_truncate_extend(self):
|
||||
fuzzer = MutationFuzzer([RFFrame.from_hex("DEADBEEF")], seed=0)
|
||||
strategies_seen = set()
|
||||
for _ in range(100):
|
||||
frame = RFFrame.from_hex("DEADBEEF")
|
||||
m = fuzzer.mutate(frame)
|
||||
if m.bit_count < 32:
|
||||
strategies_seen.add("truncate")
|
||||
elif m.bit_count > 32:
|
||||
strategies_seen.add("extend")
|
||||
elif m.data != b"\xDE\xAD\xBE\xEF":
|
||||
strategies_seen.add("flip")
|
||||
assert len(strategies_seen) >= 2 # at least 2 strategies observed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GrammarFuzzer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGrammarFuzzer:
|
||||
def test_generate_14443a_reqa(self):
|
||||
fuzzer = GrammarFuzzer("14443a")
|
||||
frame = fuzzer.generate("REQA")
|
||||
assert frame is not None
|
||||
assert frame.bit_count > 0
|
||||
|
||||
def test_generate_with_overrides(self):
|
||||
fuzzer = GrammarFuzzer("14443a")
|
||||
frame = fuzzer.generate("SELECT", uid=b"\xFF\xFF\xFF\xFF")
|
||||
assert frame is not None
|
||||
|
||||
def test_unknown_command_returns_none(self):
|
||||
fuzzer = GrammarFuzzer("14443a")
|
||||
frame = fuzzer.generate("NONEXISTENT")
|
||||
assert frame is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RelayTransponder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRelayTransponder:
|
||||
def test_relay_forwards_frames(self):
|
||||
"""RelayTransponder forwards reader commands to upstream reader."""
|
||||
# Set up "real card" on upstream medium
|
||||
upstream_medium = SoftwareMedium()
|
||||
uid = b"\x01\x02\x03\x04"
|
||||
run(upstream_medium.attach(Tag14443A_3(uid=uid, sak=0x08)))
|
||||
upstream_reader = Reader14443A(upstream_medium)
|
||||
|
||||
# Set up relay on local medium
|
||||
relay = RelayTransponder(upstream_reader)
|
||||
local_medium = SoftwareMedium()
|
||||
run(local_medium.attach(relay))
|
||||
|
||||
# Send REQA through local medium
|
||||
run(local_medium.transmit_reader(RFFrame.from_hex("26")))
|
||||
resp = run(local_medium.receive_reader())
|
||||
# Should get ATQA back from the real card
|
||||
assert resp is not None
|
||||
assert len(resp.data) == 2 # ATQA
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MitMProxy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMitMProxy:
|
||||
def test_proxy_logs_traffic(self):
|
||||
reader_medium = SoftwareMedium()
|
||||
tag_medium = SoftwareMedium()
|
||||
run(tag_medium.attach(Tag14443A_3(uid=b"\x01\x02\x03\x04")))
|
||||
|
||||
proxy = MitMProxy(reader_medium, tag_medium)
|
||||
|
||||
# Simulate reader → proxy → tag
|
||||
run(proxy.forward_to_tag(RFFrame.from_hex("26")))
|
||||
assert len(proxy.trace) == 2 # request + response
|
||||
|
||||
def test_proxy_with_intercept(self):
|
||||
reader_medium = SoftwareMedium()
|
||||
tag_medium = SoftwareMedium()
|
||||
run(tag_medium.attach(Tag14443A_3(uid=b"\x01\x02\x03\x04")))
|
||||
|
||||
modified = []
|
||||
def intercept(frame, direction):
|
||||
modified.append(direction)
|
||||
return frame # pass through
|
||||
|
||||
proxy = MitMProxy(reader_medium, tag_medium, intercept=intercept)
|
||||
run(proxy.forward_to_tag(RFFrame.from_hex("26")))
|
||||
assert "reader" in modified
|
||||
assert "tag" in modified
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TraceRecorder / TraceReplayer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTraceRecorder:
|
||||
def test_records_traffic(self):
|
||||
medium = SoftwareMedium()
|
||||
run(medium.attach(Tag14443A_3(uid=b"\x01\x02\x03\x04")))
|
||||
recorder = TraceRecorder(medium)
|
||||
|
||||
# Exchange through recorder
|
||||
run(recorder.transmit_reader(RFFrame.from_hex("26")))
|
||||
run(recorder.receive_reader())
|
||||
|
||||
assert len(recorder.trace) >= 1
|
||||
|
||||
def test_trace_entries_have_direction(self):
|
||||
medium = SoftwareMedium()
|
||||
run(medium.attach(Tag14443A_3(uid=b"\x01\x02\x03\x04")))
|
||||
recorder = TraceRecorder(medium)
|
||||
|
||||
run(recorder.transmit_reader(RFFrame.from_hex("26")))
|
||||
resp = run(recorder.receive_reader())
|
||||
|
||||
directions = [e.direction for e in recorder.trace]
|
||||
assert "reader" in directions
|
||||
if resp is not None:
|
||||
assert "tag" in directions
|
||||
|
||||
|
||||
class TestTraceReplayer:
|
||||
def test_replay_against_tag(self):
|
||||
"""Record a trace, then replay it against a fresh tag."""
|
||||
# Record
|
||||
medium1 = SoftwareMedium()
|
||||
run(medium1.attach(Tag14443A_3(uid=b"\x01\x02\x03\x04")))
|
||||
recorder = TraceRecorder(medium1)
|
||||
run(recorder.transmit_reader(RFFrame.from_hex("26")))
|
||||
run(recorder.receive_reader())
|
||||
|
||||
# Replay against fresh tag
|
||||
medium2 = SoftwareMedium()
|
||||
run(medium2.attach(Tag14443A_3(uid=b"\x01\x02\x03\x04")))
|
||||
replayer = TraceReplayer(recorder.trace, medium2)
|
||||
results = run(replayer.replay())
|
||||
assert len(results) > 0
|
||||
assert all(r["matched"] for r in results)
|
||||
420
tests/test_sim_aes_auth.py
Normal file
420
tests/test_sim_aes_auth.py
Normal file
@@ -0,0 +1,420 @@
|
||||
"""Tests for AES TAM1/MAM authentication in NxpAesAuth mixin."""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
class TestTam1:
|
||||
def test_challenge_readbuffer(self):
|
||||
"""Full TAM1 flow: CHALLENGE computes TResponse, READBUFFER returns it."""
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
||||
|
||||
key = b"\xAA" * 16
|
||||
tag = IcodeDnaTag(aes_keys=[key, None, None, None])
|
||||
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
|
||||
run(tag.power_on())
|
||||
|
||||
ichallenge = bytes(range(10))
|
||||
|
||||
# CHALLENGE: flags(02) cmd(39) CSI(00) AuthMethod(00) KeyID(00) IChallenge(10)
|
||||
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + ichallenge
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None # CHALLENGE has no RF response
|
||||
|
||||
# READBUFFER: flags(02) cmd(3A)
|
||||
cmd = bytes([0x02, 0x3A])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert len(resp.data) >= 17 # flags + 16 bytes TResponse
|
||||
|
||||
tresponse_wire = resp.data[1:17]
|
||||
tresponse = tresponse_wire[::-1] # reverse for decryption
|
||||
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(tresponse)
|
||||
|
||||
# Verify C_TAM1 constant
|
||||
assert decrypted[0:2] == bytes([0x96, 0xC5])
|
||||
# Verify echoed IChallenge (bytes 6-15, reversed back)
|
||||
echoed = decrypted[6:16]
|
||||
assert echoed == ichallenge
|
||||
|
||||
def test_challenge_key1(self):
|
||||
"""TAM1 with key slot 1."""
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
||||
|
||||
key = b"\xCC" * 16
|
||||
tag = IcodeDnaTag(aes_keys=[None, key, None, None])
|
||||
tag._key_headers[1] = KEY_HEADER_ACTIVE_LOCKED
|
||||
run(tag.power_on())
|
||||
|
||||
ichallenge = b"\xFF" * 10
|
||||
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x01]) + ichallenge
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None
|
||||
|
||||
cmd = bytes([0x02, 0x3A])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
|
||||
tresponse = resp.data[1:17][::-1]
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(tresponse)
|
||||
assert decrypted[0:2] == bytes([0x96, 0xC5])
|
||||
assert decrypted[6:16] == ichallenge
|
||||
|
||||
def test_challenge_inactive_key_silent(self):
|
||||
"""CHALLENGE with inactive key: tag stays silent, READBUFFER returns nothing."""
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
|
||||
tag = IcodeDnaTag(aes_keys=[b"\xBB" * 16, None, None, None])
|
||||
# Key header NOT active (default 0x81)
|
||||
run(tag.power_on())
|
||||
|
||||
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + bytes(10)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None
|
||||
|
||||
# READBUFFER should have nothing
|
||||
cmd = bytes([0x02, 0x3A])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None
|
||||
|
||||
def test_readbuffer_without_challenge(self):
|
||||
"""READBUFFER without prior CHALLENGE returns nothing."""
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
|
||||
tag = IcodeDnaTag()
|
||||
run(tag.power_on())
|
||||
|
||||
cmd = bytes([0x02, 0x3A])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None
|
||||
|
||||
def test_readbuffer_one_shot(self):
|
||||
"""TResponse is cleared after first READBUFFER (one-shot)."""
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
||||
|
||||
key = b"\xDD" * 16
|
||||
tag = IcodeDnaTag(aes_keys=[key, None, None, None])
|
||||
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
|
||||
run(tag.power_on())
|
||||
|
||||
# CHALLENGE
|
||||
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + bytes(10)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
|
||||
# First READBUFFER — should succeed
|
||||
cmd = bytes([0x02, 0x3A])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
|
||||
# Second READBUFFER — should return nothing (consumed)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None
|
||||
|
||||
def test_challenge_no_key_in_slot(self):
|
||||
"""CHALLENGE with None key slot stays silent."""
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
||||
|
||||
tag = IcodeDnaTag(aes_keys=[None, None, None, None])
|
||||
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
|
||||
run(tag.power_on())
|
||||
|
||||
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + bytes(10)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None
|
||||
|
||||
cmd = bytes([0x02, 0x3A])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None
|
||||
|
||||
def test_challenge_invalid_key_id(self):
|
||||
"""CHALLENGE with key_id > 3 stays silent."""
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
||||
|
||||
tag = IcodeDnaTag(aes_keys=[b"\xAA" * 16, None, None, None])
|
||||
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
|
||||
run(tag.power_on())
|
||||
|
||||
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x04]) + bytes(10)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None
|
||||
|
||||
def test_challenge_wrong_auth_method(self):
|
||||
"""CHALLENGE with AuthMethod != 0x00 (not TAM1) stays silent."""
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
||||
|
||||
tag = IcodeDnaTag(aes_keys=[b"\xAA" * 16, None, None, None])
|
||||
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
|
||||
run(tag.power_on())
|
||||
|
||||
# AuthMethod = 0x02 (MAM1, not TAM1)
|
||||
cmd = bytes([0x02, 0x39, 0x00, 0x02, 0x00]) + bytes(10)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None
|
||||
|
||||
rb_cmd = bytes([0x02, 0x3A])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(rb_cmd)))
|
||||
assert resp is None
|
||||
|
||||
|
||||
class TestMam:
|
||||
"""Tests for MAM1/MAM2 mutual authentication via AUTHENTICATE (0x35)."""
|
||||
|
||||
def _make_tag(self, key=b"\xAA" * 16, key_slot=0):
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
||||
|
||||
keys = [None] * 4
|
||||
keys[key_slot] = key
|
||||
tag = IcodeDnaTag(aes_keys=keys)
|
||||
tag._key_headers[key_slot] = KEY_HEADER_ACTIVE_LOCKED
|
||||
run(tag.power_on())
|
||||
return tag
|
||||
|
||||
def _do_mam1(self, tag, key, ichallenge, key_id=0):
|
||||
"""Send MAM1 and return (resp, tchallenge)."""
|
||||
cmd = bytes([0x02, 0x35, 0x00, 0x02, key_id]) + ichallenge[::-1]
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert len(resp.data) == 24
|
||||
assert resp.data[0] == 0x04
|
||||
assert resp.data[1] == 0xA7
|
||||
|
||||
tc_high_reversed = resp.data[2:8]
|
||||
encrypted_reversed = resp.data[8:24]
|
||||
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(encrypted_reversed[::-1])
|
||||
|
||||
assert decrypted[0:2] == bytes([0xDA, 0x83])
|
||||
tc_low = decrypted[2:6]
|
||||
echoed_ichallenge = decrypted[6:16]
|
||||
assert echoed_ichallenge == ichallenge
|
||||
|
||||
tc_high = tc_high_reversed[::-1]
|
||||
tchallenge = tc_high + tc_low
|
||||
return resp, tchallenge
|
||||
|
||||
def _compute_iresponse(self, key, ichallenge, tchallenge):
|
||||
"""Compute IResponse for MAM2."""
|
||||
C_MAM2_PURPOSE = bytes([0xDA, 0x80])
|
||||
ich_31_0 = ichallenge[6:10]
|
||||
plaintext = C_MAM2_PURPOSE + ich_31_0 + tchallenge
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
iresponse = cipher.decrypt(plaintext)
|
||||
return iresponse[::-1]
|
||||
|
||||
def test_mam_complete_flow(self):
|
||||
"""Complete MAM1 -> MAM2 flow results in authenticated key."""
|
||||
key = b"\xAA" * 16
|
||||
tag = self._make_tag(key)
|
||||
ichallenge = os.urandom(10)
|
||||
|
||||
resp, tchallenge = self._do_mam1(tag, key, ichallenge)
|
||||
|
||||
iresponse_wire = self._compute_iresponse(key, ichallenge, tchallenge)
|
||||
|
||||
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + iresponse_wire
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x00
|
||||
assert 0 in tag._aes_authenticated
|
||||
|
||||
def test_mam1_response_structure(self):
|
||||
"""MAM1 response has correct flags, header, and 24-byte length."""
|
||||
key = b"\xBB" * 16
|
||||
tag = self._make_tag(key)
|
||||
ichallenge = bytes(range(10))
|
||||
|
||||
cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x00]) + ichallenge[::-1]
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
|
||||
assert resp is not None
|
||||
assert len(resp.data) == 24
|
||||
assert resp.data[0] == 0x04
|
||||
assert resp.data[1] == 0xA7
|
||||
|
||||
def test_mam2_wrong_iresponse(self):
|
||||
"""MAM2 with incorrect IResponse returns error."""
|
||||
key = b"\xCC" * 16
|
||||
tag = self._make_tag(key)
|
||||
ichallenge = os.urandom(10)
|
||||
|
||||
self._do_mam1(tag, key, ichallenge)
|
||||
|
||||
bad_iresponse = os.urandom(16)
|
||||
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + bad_iresponse
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x01
|
||||
assert 0 not in tag._aes_authenticated
|
||||
|
||||
def test_mam2_without_prior_mam1(self):
|
||||
"""MAM2 without prior MAM1 returns error."""
|
||||
key = b"\xDD" * 16
|
||||
tag = self._make_tag(key)
|
||||
|
||||
iresponse = os.urandom(16)
|
||||
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + iresponse
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x01
|
||||
assert 0 not in tag._aes_authenticated
|
||||
|
||||
def test_mam_key1(self):
|
||||
"""MAM flow with key slot 1."""
|
||||
key = b"\xEE" * 16
|
||||
tag = self._make_tag(key, key_slot=1)
|
||||
ichallenge = os.urandom(10)
|
||||
|
||||
resp, tchallenge = self._do_mam1(tag, key, ichallenge, key_id=1)
|
||||
|
||||
iresponse_wire = self._compute_iresponse(key, ichallenge, tchallenge)
|
||||
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + iresponse_wire
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
assert 1 in tag._aes_authenticated
|
||||
|
||||
def test_mam1_inactive_key_error(self):
|
||||
"""MAM1 with inactive key returns error."""
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
|
||||
tag = IcodeDnaTag(aes_keys=[b"\xAA" * 16, None, None, None])
|
||||
run(tag.power_on())
|
||||
|
||||
cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x00]) + bytes(10)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x01
|
||||
|
||||
def test_mam1_invalid_key_id(self):
|
||||
"""MAM1 with key_id > 3 returns error."""
|
||||
tag = self._make_tag()
|
||||
|
||||
cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x05]) + bytes(10)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x01
|
||||
|
||||
def test_unsupported_auth_method(self):
|
||||
"""AUTHENTICATE with AuthMethod 0x80 returns error (not supported)."""
|
||||
tag = self._make_tag()
|
||||
|
||||
cmd = bytes([0x02, 0x35, 0x00, 0x80]) + bytes(16)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x01
|
||||
|
||||
def test_mam_state_cleared_after_mam2_failure(self):
|
||||
"""After MAM2 failure, state is cleared -- second MAM2 also fails."""
|
||||
key = b"\xAA" * 16
|
||||
tag = self._make_tag(key)
|
||||
ichallenge = os.urandom(10)
|
||||
|
||||
self._do_mam1(tag, key, ichallenge)
|
||||
|
||||
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + os.urandom(16)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x01
|
||||
|
||||
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + os.urandom(16)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x01
|
||||
|
||||
def test_mam1_ichallenge_byte_reversal(self):
|
||||
"""Verify IChallenge is de-reversed correctly from wire format."""
|
||||
key = b"\xAA" * 16
|
||||
tag = self._make_tag(key)
|
||||
|
||||
ichallenge = bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A])
|
||||
|
||||
cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x00]) + ichallenge[::-1]
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
|
||||
encrypted_reversed = resp.data[8:24]
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(encrypted_reversed[::-1])
|
||||
assert decrypted[6:16] == ichallenge
|
||||
|
||||
def _do_mam_full(self, tag, key, ichallenge, key_id=0, purpose_nibble=0x00):
|
||||
"""Complete MAM1+MAM2 flow with a specific purpose nibble. Returns MAM2 response."""
|
||||
_, tchallenge = self._do_mam1(tag, key, ichallenge, key_id=key_id)
|
||||
|
||||
purpose_byte = 0x80 | purpose_nibble
|
||||
ich_31_0 = ichallenge[6:10]
|
||||
plaintext = bytes([0xDA, purpose_byte]) + ich_31_0 + tchallenge
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
iresponse = cipher.decrypt(plaintext)
|
||||
iresponse_reversed = iresponse[::-1]
|
||||
|
||||
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + iresponse_reversed
|
||||
return run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
|
||||
def test_mam2_purpose_enable_privacy(self):
|
||||
"""MAM2 with purpose=0x09 sets privacy mode."""
|
||||
from pm3py.sim.auth_aes import PRIV_PRIVACY
|
||||
|
||||
key = b"\xAA" * 16
|
||||
tag = self._make_tag(key)
|
||||
tag._key_privileges[0] = PRIV_PRIVACY
|
||||
ichallenge = os.urandom(10)
|
||||
|
||||
resp = self._do_mam_full(tag, key, ichallenge, purpose_nibble=0x09)
|
||||
assert resp.data[0] == 0x00
|
||||
assert 0 in tag._aes_authenticated
|
||||
assert tag._privacy_mode is True
|
||||
|
||||
def test_mam2_purpose_disable_privacy(self):
|
||||
"""MAM2 with purpose=0x0A clears privacy mode."""
|
||||
from pm3py.sim.auth_aes import PRIV_PRIVACY
|
||||
|
||||
key = b"\xAA" * 16
|
||||
tag = self._make_tag(key)
|
||||
tag._key_privileges[0] = PRIV_PRIVACY
|
||||
tag._privacy_mode = True
|
||||
ichallenge = os.urandom(10)
|
||||
|
||||
resp = self._do_mam_full(tag, key, ichallenge, purpose_nibble=0x0A)
|
||||
assert resp.data[0] == 0x00
|
||||
assert 0 in tag._aes_authenticated
|
||||
assert tag._privacy_mode is False
|
||||
|
||||
def test_mam2_purpose_destroy(self):
|
||||
"""MAM2 with purpose=0x0B sets destroyed."""
|
||||
from pm3py.sim.auth_aes import PRIV_DESTROY
|
||||
|
||||
key = b"\xAA" * 16
|
||||
tag = self._make_tag(key)
|
||||
tag._key_privileges[0] = PRIV_DESTROY
|
||||
ichallenge = os.urandom(10)
|
||||
|
||||
resp = self._do_mam_full(tag, key, ichallenge, purpose_nibble=0x0B)
|
||||
assert resp.data[0] == 0x00
|
||||
assert 0 in tag._aes_authenticated
|
||||
assert tag._destroyed is True
|
||||
|
||||
def test_mam2_purpose_requires_privilege(self):
|
||||
"""MAM2 with privacy purpose but no PRIV_PRIVACY returns error."""
|
||||
key = b"\xAA" * 16
|
||||
tag = self._make_tag(key)
|
||||
# No privileges set (default 0x00)
|
||||
ichallenge = os.urandom(10)
|
||||
|
||||
resp = self._do_mam_full(tag, key, ichallenge, purpose_nibble=0x09)
|
||||
assert resp.data[0] == 0x01
|
||||
assert 0 not in tag._aes_authenticated
|
||||
assert tag._privacy_mode is False
|
||||
181
tests/test_sim_desfire.py
Normal file
181
tests/test_sim_desfire.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Tests for pm3py.sim.desfire — DESFire EV1/EV2 transponder and reader."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.medium import SoftwareMedium
|
||||
from pm3py.sim.iso14443a import _compute_bcc
|
||||
from pm3py.sim.desfire import DesfireTag, DesfireReader
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DesfireTag — basics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDesfireTagBasics:
|
||||
def test_sak_indicates_isodep(self):
|
||||
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
|
||||
run(tag.power_on())
|
||||
resp = run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
assert resp.data == b"\x44\x03" # DESFire ATQA
|
||||
|
||||
# SELECT (7-byte UID)
|
||||
ct_uid = b"\x88" + tag._uid[0:3]
|
||||
bcc1 = _compute_bcc(ct_uid)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1]))))
|
||||
cl2_uid = tag._uid[3:7]
|
||||
bcc2 = _compute_bcc(cl2_uid)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2_uid + bytes([bcc2]))))
|
||||
assert resp.data[0] & 0x20 != 0 # SAK bit 5 = ISO-DEP
|
||||
|
||||
def test_rats_returns_ats(self):
|
||||
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
|
||||
run(tag.power_on())
|
||||
self._select(tag)
|
||||
resp = run(tag.handle_frame(RFFrame.from_hex("E050")))
|
||||
assert resp is not None
|
||||
assert tag.state == "PROTOCOL"
|
||||
|
||||
def _select(self, tag):
|
||||
run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
ct_uid = b"\x88" + tag._uid[0:3]
|
||||
bcc1 = _compute_bcc(ct_uid)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1]))))
|
||||
cl2_uid = tag._uid[3:7]
|
||||
bcc2 = _compute_bcc(cl2_uid)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2_uid + bytes([bcc2]))))
|
||||
|
||||
|
||||
class TestDesfireTagApplications:
|
||||
"""Test DESFire application/file structure."""
|
||||
|
||||
def test_get_version(self):
|
||||
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
|
||||
self._activate(tag)
|
||||
|
||||
resp = self._send_desfire(tag, b"\x60") # GetVersion
|
||||
assert resp is not None
|
||||
assert resp[0] == 0xAF # AF = additional frames follow
|
||||
|
||||
def test_get_application_ids_default(self):
|
||||
"""Default tag has only PICC application (0x000000)."""
|
||||
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
|
||||
self._activate(tag)
|
||||
|
||||
resp = self._send_desfire(tag, b"\x6A") # GetApplicationIDs
|
||||
assert resp is not None
|
||||
assert resp[0] == 0x00 # OK
|
||||
|
||||
def test_create_application(self):
|
||||
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
|
||||
self._activate(tag)
|
||||
|
||||
# CreateApplication: AID=0x010203, key_settings=0x0F, num_keys=1
|
||||
resp = self._send_desfire(tag, b"\xCA\x01\x02\x03\x0F\x01")
|
||||
assert resp is not None
|
||||
assert resp[0] == 0x00 # OK
|
||||
|
||||
def test_select_application(self):
|
||||
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
|
||||
self._activate(tag)
|
||||
|
||||
# Create app
|
||||
self._send_desfire(tag, b"\xCA\x01\x02\x03\x0F\x01")
|
||||
# Select app
|
||||
resp = self._send_desfire(tag, b"\x5A\x01\x02\x03")
|
||||
assert resp is not None
|
||||
assert resp[0] == 0x00
|
||||
|
||||
def test_create_and_read_standard_file(self):
|
||||
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
|
||||
self._activate(tag)
|
||||
|
||||
self._send_desfire(tag, b"\xCA\x01\x02\x03\x0F\x01") # CreateApp
|
||||
self._send_desfire(tag, b"\x5A\x01\x02\x03") # SelectApp
|
||||
|
||||
# CreateStdDataFile: file_no=0, comm_settings=0, access_rights=0xEEEE, size=16
|
||||
resp = self._send_desfire(tag, b"\xCD\x00\x00\xEE\xEE\x10\x00\x00")
|
||||
assert resp[0] == 0x00
|
||||
|
||||
# WriteData: file_no=0, offset=0, length=4, data
|
||||
resp = self._send_desfire(tag, b"\x3D\x00\x00\x00\x00\x04\x00\x00\xDE\xAD\xBE\xEF")
|
||||
assert resp[0] == 0x00
|
||||
|
||||
# ReadData: file_no=0, offset=0, length=4
|
||||
resp = self._send_desfire(tag, b"\xBD\x00\x00\x00\x00\x04\x00\x00")
|
||||
assert resp[0] == 0x00
|
||||
assert resp[1:5] == b"\xDE\xAD\xBE\xEF"
|
||||
|
||||
def test_delete_application(self):
|
||||
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
|
||||
self._activate(tag)
|
||||
|
||||
self._send_desfire(tag, b"\xCA\x01\x02\x03\x0F\x01")
|
||||
resp = self._send_desfire(tag, b"\xDA\x01\x02\x03") # DeleteApplication
|
||||
assert resp[0] == 0x00
|
||||
|
||||
def test_authenticate_default_key(self):
|
||||
"""Authenticate with default all-zero AES key."""
|
||||
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
|
||||
self._activate(tag)
|
||||
|
||||
self._send_desfire(tag, b"\xCA\x01\x02\x03\x0F\x81") # CreateApp, AES keys
|
||||
self._send_desfire(tag, b"\x5A\x01\x02\x03")
|
||||
|
||||
# AuthenticateAES: key_no=0
|
||||
resp = self._send_desfire(tag, b"\xAA\x00")
|
||||
assert resp is not None
|
||||
# Response: status + encrypted challenge (16 bytes for AES)
|
||||
assert resp[0] == 0xAF # AF = additional frame expected
|
||||
|
||||
def _activate(self, tag):
|
||||
run(tag.power_on())
|
||||
run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
ct_uid = b"\x88" + tag._uid[0:3]
|
||||
bcc1 = _compute_bcc(ct_uid)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1]))))
|
||||
cl2_uid = tag._uid[3:7]
|
||||
bcc2 = _compute_bcc(cl2_uid)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2_uid + bytes([bcc2]))))
|
||||
run(tag.handle_frame(RFFrame.from_hex("E050"))) # RATS
|
||||
|
||||
def _send_desfire(self, tag, cmd: bytes) -> bytes | None:
|
||||
"""Send DESFire command via I-block, return response (status + data)."""
|
||||
pcb = 0x02
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([pcb]) + cmd)))
|
||||
if resp is None:
|
||||
return None
|
||||
return resp.data[1:] # strip PCB
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DesfireReader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDesfireReader:
|
||||
def test_get_version(self):
|
||||
medium = SoftwareMedium()
|
||||
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
|
||||
run(medium.attach(tag))
|
||||
reader = DesfireReader(medium)
|
||||
|
||||
result = run(reader.get_version(uid=b"\x04\x01\x02\x03\x04\x05\x06"))
|
||||
assert result is not None
|
||||
assert "hw_vendor" in result
|
||||
|
||||
def test_create_and_select_app(self):
|
||||
medium = SoftwareMedium()
|
||||
tag = DesfireTag(uid=b"\x04\x01\x02\x03\x04\x05\x06")
|
||||
run(medium.attach(tag))
|
||||
reader = DesfireReader(medium)
|
||||
uid = b"\x04\x01\x02\x03\x04\x05\x06"
|
||||
|
||||
result = run(reader.create_application(uid, aid=b"\x01\x02\x03", num_keys=1))
|
||||
assert result["success"]
|
||||
|
||||
result = run(reader.select_application(uid, aid=b"\x01\x02\x03"))
|
||||
assert result["success"]
|
||||
211
tests/test_sim_dual_session.py
Normal file
211
tests/test_sim_dual_session.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Tests for DualInterfaceSession coordinator."""
|
||||
import time
|
||||
import pytest
|
||||
from pm3py.sim.dual_session import DualInterfaceSession
|
||||
from pm3py.sim.mcu_bridge import McuBridge
|
||||
from pm3py.sim.mcu_protocol import MsgType, build_frame, cobs_decode, parse_frame
|
||||
from pm3py.sim.ntag5_link import Ntag5LinkTag
|
||||
|
||||
|
||||
class MockSimSession:
|
||||
"""Mock SimSession for testing — no real PM3 hardware."""
|
||||
def __init__(self):
|
||||
self.on_field_strength = None
|
||||
self._started = False
|
||||
self._stopped = False
|
||||
self._tag = None
|
||||
self._trace = False
|
||||
|
||||
def start_15693(self, tag, trace=False):
|
||||
self._started = True
|
||||
self._tag = tag
|
||||
self._trace = trace
|
||||
|
||||
def stop(self):
|
||||
self._stopped = True
|
||||
|
||||
|
||||
class MockSerial:
|
||||
"""Mock serial port for McuBridge."""
|
||||
def __init__(self):
|
||||
self._rx_buf = bytearray()
|
||||
self._tx_buf = bytearray()
|
||||
self.is_open = True
|
||||
self.timeout = 0.1
|
||||
|
||||
def read(self, size=1):
|
||||
if not self._rx_buf:
|
||||
time.sleep(0.01)
|
||||
return b""
|
||||
data = bytes(self._rx_buf[:size])
|
||||
self._rx_buf = self._rx_buf[size:]
|
||||
return data
|
||||
|
||||
def write(self, data):
|
||||
self._tx_buf.extend(data)
|
||||
return len(data)
|
||||
|
||||
def inject(self, data: bytes):
|
||||
self._rx_buf.extend(data)
|
||||
|
||||
def get_sent(self) -> bytes:
|
||||
data = bytes(self._tx_buf)
|
||||
self._tx_buf.clear()
|
||||
return data
|
||||
|
||||
def close(self):
|
||||
self.is_open = False
|
||||
|
||||
|
||||
def _decode_sent(mock_serial) -> list[tuple[int, bytes]]:
|
||||
"""Decode all COBS-framed messages sent to mock serial."""
|
||||
raw = mock_serial.get_sent()
|
||||
messages = []
|
||||
while raw:
|
||||
zero_idx = raw.find(b"\x00")
|
||||
if zero_idx == -1:
|
||||
break
|
||||
frame = raw[:zero_idx]
|
||||
raw = raw[zero_idx + 1:]
|
||||
if frame:
|
||||
decoded = cobs_decode(frame)
|
||||
messages.append(parse_frame(decoded))
|
||||
return messages
|
||||
|
||||
|
||||
class TestDualInterfaceSession:
|
||||
def _make_session(self):
|
||||
"""Create a DualInterfaceSession with mocks."""
|
||||
mock_serial = MockSerial()
|
||||
sim = MockSimSession()
|
||||
mcu = McuBridge(port=mock_serial)
|
||||
tag = Ntag5LinkTag()
|
||||
tag.sram_enable = True
|
||||
tag.arbiter_mode = 2 # PASSTHROUGH
|
||||
session = DualInterfaceSession(sim, mcu, tag)
|
||||
return session, sim, mcu, mock_serial, tag
|
||||
|
||||
def test_start_wires_callbacks(self):
|
||||
session, sim, mcu, mock_serial, tag = self._make_session()
|
||||
session.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
assert sim._started
|
||||
assert mcu.on_i2c_write is not None
|
||||
assert mcu.on_i2c_read_req is not None
|
||||
assert tag.ed_pin_callback is not None
|
||||
assert sim.on_field_strength is not None
|
||||
|
||||
session.stop()
|
||||
|
||||
def test_start_pushes_initial_sram(self):
|
||||
session, sim, mcu, mock_serial, tag = self._make_session()
|
||||
tag._sram[0:4] = b"\xDE\xAD\xBE\xEF"
|
||||
session.start()
|
||||
time.sleep(0.05)
|
||||
session.stop()
|
||||
|
||||
msgs = _decode_sent(mock_serial)
|
||||
# Should have WRITE_SRAM with full 256-byte SRAM
|
||||
sram_msgs = [m for m in msgs if m[0] == MsgType.WRITE_SRAM]
|
||||
assert len(sram_msgs) == 1
|
||||
assert sram_msgs[0][1][0] == 0x00 # offset 0
|
||||
assert sram_msgs[0][1][1:5] == b"\xDE\xAD\xBE\xEF"
|
||||
|
||||
def test_i2c_write_updates_tag_sram(self):
|
||||
session, sim, mcu, mock_serial, tag = self._make_session()
|
||||
tag.config_pt_transfer_dir = False # I2C→NFC
|
||||
session.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
# Simulate MCU pushing an I2C write event
|
||||
mock_serial.inject(build_frame(MsgType.I2C_WRITE,
|
||||
bytes([0x20, 0x00, 0xCA, 0xFE])))
|
||||
time.sleep(0.15)
|
||||
session.stop()
|
||||
|
||||
# Tag SRAM should have the data
|
||||
assert tag._sram[0:2] == b"\xCA\xFE"
|
||||
|
||||
def test_i2c_read_req_responds(self):
|
||||
session, sim, mcu, mock_serial, tag = self._make_session()
|
||||
# Preload SRAM
|
||||
tag._sram[0:4] = b"\x01\x02\x03\x04"
|
||||
session.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
# Clear initial WRITE_SRAM from get_sent
|
||||
mock_serial.get_sent()
|
||||
|
||||
# Simulate MCU requesting I2C read
|
||||
mock_serial.inject(build_frame(MsgType.I2C_READ_REQ,
|
||||
bytes([0x20, 0x00, 0x04])))
|
||||
time.sleep(0.15)
|
||||
session.stop()
|
||||
|
||||
msgs = _decode_sent(mock_serial)
|
||||
resp_msgs = [m for m in msgs if m[0] == MsgType.I2C_READ_RESPONSE]
|
||||
assert len(resp_msgs) == 1
|
||||
assert resp_msgs[0][1] == b"\x01\x02\x03\x04"
|
||||
|
||||
def test_ed_pin_relayed_to_mcu(self):
|
||||
session, sim, mcu, mock_serial, tag = self._make_session()
|
||||
session.start()
|
||||
time.sleep(0.05)
|
||||
mock_serial.get_sent() # clear initial WRITE_SRAM
|
||||
|
||||
# Trigger ED on tag
|
||||
tag.assert_ed()
|
||||
time.sleep(0.05)
|
||||
session.stop()
|
||||
|
||||
msgs = _decode_sent(mock_serial)
|
||||
ed_msgs = [m for m in msgs if m[0] == MsgType.SET_ED]
|
||||
assert len(ed_msgs) == 1
|
||||
assert ed_msgs[0][1] == bytes([0x01])
|
||||
|
||||
def test_field_strength_above_threshold_activates_eh(self):
|
||||
session, sim, mcu, mock_serial, tag = self._make_session()
|
||||
session.eh_threshold = 500
|
||||
session.start()
|
||||
time.sleep(0.05)
|
||||
mock_serial.get_sent() # clear initial
|
||||
|
||||
# Simulate field strength above threshold
|
||||
sim.on_field_strength(600)
|
||||
time.sleep(0.05)
|
||||
session.stop()
|
||||
|
||||
msgs = _decode_sent(mock_serial)
|
||||
eh_msgs = [m for m in msgs if m[0] == MsgType.SET_EH]
|
||||
assert len(eh_msgs) == 1
|
||||
assert eh_msgs[0][1] == bytes([0x01])
|
||||
assert tag.nfc_field_ok
|
||||
|
||||
def test_field_strength_below_threshold_deactivates_eh(self):
|
||||
session, sim, mcu, mock_serial, tag = self._make_session()
|
||||
session.eh_threshold = 500
|
||||
session._eh_active = True # pretend already active
|
||||
session.start()
|
||||
time.sleep(0.05)
|
||||
mock_serial.get_sent()
|
||||
|
||||
sim.on_field_strength(300)
|
||||
time.sleep(0.05)
|
||||
session.stop()
|
||||
|
||||
msgs = _decode_sent(mock_serial)
|
||||
eh_msgs = [m for m in msgs if m[0] == MsgType.SET_EH]
|
||||
assert len(eh_msgs) == 1
|
||||
assert eh_msgs[0][1] == bytes([0x00])
|
||||
|
||||
def test_stop_unhooks_callbacks(self):
|
||||
session, sim, mcu, mock_serial, tag = self._make_session()
|
||||
session.start()
|
||||
time.sleep(0.05)
|
||||
session.stop()
|
||||
|
||||
assert mcu.on_i2c_write is None
|
||||
assert mcu.on_i2c_read_req is None
|
||||
assert tag.ed_pin_callback is None
|
||||
assert sim.on_field_strength is None
|
||||
167
tests/test_sim_frame.py
Normal file
167
tests/test_sim_frame.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Tests for pm3py.sim.frame — RFFrame with bit-level granularity."""
|
||||
import pytest
|
||||
from bitarray import bitarray
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
|
||||
|
||||
class TestRFFrameConstruction:
|
||||
"""Test RFFrame creation from various inputs."""
|
||||
|
||||
def test_from_bytes_creates_frame_with_correct_bits(self):
|
||||
frame = RFFrame.from_bytes(b"\xA5")
|
||||
assert frame.bit_count == 8
|
||||
assert frame.data == b"\xA5"
|
||||
|
||||
def test_from_bytes_multi_byte(self):
|
||||
frame = RFFrame.from_bytes(b"\x01\x02\x03")
|
||||
assert frame.bit_count == 24
|
||||
assert frame.data == b"\x01\x02\x03"
|
||||
|
||||
def test_from_bytes_empty(self):
|
||||
frame = RFFrame.from_bytes(b"")
|
||||
assert frame.bit_count == 0
|
||||
assert frame.data == b""
|
||||
|
||||
def test_from_hex_creates_frame(self):
|
||||
frame = RFFrame.from_hex("A5B6")
|
||||
assert frame.bit_count == 16
|
||||
assert frame.data == b"\xA5\xB6"
|
||||
|
||||
def test_from_hex_case_insensitive(self):
|
||||
frame = RFFrame.from_hex("a5b6")
|
||||
assert frame.data == b"\xA5\xB6"
|
||||
|
||||
def test_from_bits_with_partial_byte(self):
|
||||
"""7-bit NVB response during anticollision."""
|
||||
bits = bitarray("1010101")
|
||||
frame = RFFrame(bits=bits, bit_count=7)
|
||||
assert frame.bit_count == 7
|
||||
# data property truncates to full bytes
|
||||
assert frame.data == b""
|
||||
|
||||
def test_from_bits_with_full_and_partial(self):
|
||||
"""12 bits: 8 full + 4 partial."""
|
||||
bits = bitarray("101010110011")
|
||||
frame = RFFrame(bits=bits, bit_count=12)
|
||||
assert frame.bit_count == 12
|
||||
assert frame.data == b"\xAB" # first 8 bits = 10101011
|
||||
|
||||
|
||||
class TestRFFrameImmutability:
|
||||
"""RFFrame should be frozen/immutable."""
|
||||
|
||||
def test_cannot_set_attributes(self):
|
||||
frame = RFFrame.from_bytes(b"\x00")
|
||||
with pytest.raises(AttributeError):
|
||||
frame.bit_count = 99
|
||||
|
||||
def test_cannot_set_bits(self):
|
||||
frame = RFFrame.from_bytes(b"\x00")
|
||||
with pytest.raises(AttributeError):
|
||||
frame.bits = bitarray("11111111")
|
||||
|
||||
|
||||
class TestRFFrameOptionalFields:
|
||||
"""Test optional fields: parity, crc, collision_positions, timestamp."""
|
||||
|
||||
def test_defaults_are_none_and_zero(self):
|
||||
frame = RFFrame.from_bytes(b"\x00")
|
||||
assert frame.parity is None
|
||||
assert frame.crc is None
|
||||
assert frame.collision_positions is None
|
||||
assert frame.timestamp_us == 0
|
||||
|
||||
def test_with_parity(self):
|
||||
parity = bitarray("1")
|
||||
frame = RFFrame.from_bytes(b"\xA5", parity=parity)
|
||||
assert frame.parity == parity
|
||||
|
||||
def test_with_crc(self):
|
||||
frame = RFFrame.from_bytes(b"\xA5", crc=b"\x12\x34")
|
||||
assert frame.crc == b"\x12\x34"
|
||||
|
||||
def test_with_collision_positions(self):
|
||||
frame = RFFrame.from_bytes(b"\xA5", collision_positions=[2, 5])
|
||||
assert frame.collision_positions == [2, 5]
|
||||
|
||||
def test_with_timestamp(self):
|
||||
frame = RFFrame.from_bytes(b"\xA5", timestamp_us=12345)
|
||||
assert frame.timestamp_us == 12345
|
||||
|
||||
def test_has_collision_property(self):
|
||||
clean = RFFrame.from_bytes(b"\xA5")
|
||||
assert not clean.has_collision
|
||||
|
||||
collided = RFFrame.from_bytes(b"\xA5", collision_positions=[3])
|
||||
assert collided.has_collision
|
||||
|
||||
|
||||
class TestRFFrameCollisionMerge:
|
||||
"""Test bit-level collision merging of multiple frames."""
|
||||
|
||||
def test_merge_identical_frames_no_collision(self):
|
||||
f1 = RFFrame.from_bytes(b"\xA5")
|
||||
f2 = RFFrame.from_bytes(b"\xA5")
|
||||
merged = RFFrame.merge([f1, f2])
|
||||
assert merged.data == b"\xA5"
|
||||
assert merged.collision_positions == []
|
||||
|
||||
def test_merge_different_frames_detects_collision(self):
|
||||
f1 = RFFrame.from_bytes(b"\xFF") # 11111111
|
||||
f2 = RFFrame.from_bytes(b"\x00") # 00000000
|
||||
merged = RFFrame.merge([f1, f2])
|
||||
# All 8 bit positions should have collisions
|
||||
assert len(merged.collision_positions) == 8
|
||||
|
||||
def test_merge_partial_difference(self):
|
||||
f1 = RFFrame.from_hex("A0") # 10100000
|
||||
f2 = RFFrame.from_hex("A5") # 10100101
|
||||
merged = RFFrame.merge([f1, f2])
|
||||
# Bits 5 and 7 differ (0-indexed from MSB: positions 5 and 7)
|
||||
assert merged.collision_positions is not None
|
||||
assert len(merged.collision_positions) == 2
|
||||
|
||||
def test_merge_single_frame_returns_copy(self):
|
||||
f1 = RFFrame.from_bytes(b"\xAB")
|
||||
merged = RFFrame.merge([f1])
|
||||
assert merged.data == b"\xAB"
|
||||
assert merged.collision_positions == []
|
||||
|
||||
def test_merge_empty_list_returns_none(self):
|
||||
result = RFFrame.merge([])
|
||||
assert result is None
|
||||
|
||||
def test_merge_three_frames(self):
|
||||
f1 = RFFrame.from_hex("FF") # 11111111
|
||||
f2 = RFFrame.from_hex("FE") # 11111110
|
||||
f3 = RFFrame.from_hex("FF") # 11111111
|
||||
merged = RFFrame.merge([f1, f2, f3])
|
||||
# Only bit 7 (LSB) has collision (1 vs 0 vs 1)
|
||||
assert len(merged.collision_positions) == 1
|
||||
|
||||
def test_merge_different_lengths(self):
|
||||
"""Shorter frame treated as not responding for extra bits."""
|
||||
short = RFFrame(bits=bitarray("1010"), bit_count=4)
|
||||
long = RFFrame.from_bytes(b"\xAB") # 10101011, 8 bits
|
||||
merged = RFFrame.merge([short, long])
|
||||
# First 4 bits: both respond, check for collisions
|
||||
# Bits 4-7: only long responds, no collision
|
||||
assert merged.bit_count == 8
|
||||
|
||||
def test_collision_bit_defaults_to_one(self):
|
||||
"""Per ISO 14443-A: collision defaults to 1 (Manchester)."""
|
||||
f1 = RFFrame.from_bytes(b"\x00") # 00000000
|
||||
f2 = RFFrame.from_bytes(b"\xFF") # 11111111
|
||||
merged = RFFrame.merge([f1, f2])
|
||||
# All collision bits should be 1
|
||||
assert all(merged.bits[i] == 1 for i in merged.collision_positions)
|
||||
|
||||
|
||||
class TestRFFrameRepr:
|
||||
"""Test string representation."""
|
||||
|
||||
def test_repr_shows_hex(self):
|
||||
frame = RFFrame.from_hex("A5B6")
|
||||
r = repr(frame)
|
||||
assert "a5b6" in r.lower() or "A5B6" in r
|
||||
638
tests/test_sim_icode3.py
Normal file
638
tests/test_sim_icode3.py
Normal file
@@ -0,0 +1,638 @@
|
||||
"""Tests for ICODE 3 (SL2S3003) transponder model."""
|
||||
import asyncio
|
||||
import struct
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.nxp_icode import (
|
||||
CMD_SET_EAS, CMD_READ_CONFIG, CMD_WRITE_CONFIG, CMD_READ_TT,
|
||||
)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _xor_password(tag, pwd_value: int) -> bytes:
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0xB2, 0x04]))))
|
||||
r = resp.data[1:3]
|
||||
pwd = struct.pack("<I", pwd_value)
|
||||
return bytes([pwd[0] ^ r[0], pwd[1] ^ r[1], pwd[2] ^ r[0], pwd[3] ^ r[1]])
|
||||
|
||||
|
||||
def _authenticate(tag, pwd_id: int, pwd_value: int):
|
||||
xored = _xor_password(tag, pwd_value)
|
||||
cmd = bytes([0x02, 0xB3, 0x04, pwd_id]) + xored
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00, f"auth failed for pwd_id {pwd_id:#x}"
|
||||
|
||||
|
||||
PWD_READ = 0x01
|
||||
PWD_WRITE = 0x02
|
||||
PWD_PRIVACY = 0x04
|
||||
PWD_DESTROY = 0x08
|
||||
PWD_EAS_AFI = 0x10
|
||||
PWD_CONFIG = 0x20
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcode3Basics:
|
||||
def test_import(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
|
||||
def test_inherits_slix2(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
|
||||
assert issubclass(Icode3Tag, IcodeSlix2Tag)
|
||||
|
||||
def test_default_76_blocks(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
assert tag._num_blocks == 76
|
||||
|
||||
def test_responds_to_inventory(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
run(tag.power_on())
|
||||
inv = bytes([0x26, 0x01, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
|
||||
assert resp is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6th password (config, pwd_id 0x20)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcode3ConfigPassword:
|
||||
def test_config_password_auth(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4),
|
||||
config_password=0xDEADBEEF)
|
||||
run(tag.power_on())
|
||||
_authenticate(tag, PWD_CONFIG, 0xDEADBEEF)
|
||||
|
||||
def test_config_password_wrong(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4),
|
||||
config_password=0xDEADBEEF)
|
||||
run(tag.power_on())
|
||||
xored = _xor_password(tag, 0x00000000)
|
||||
cmd = bytes([0x02, 0xB3, 0x04, PWD_CONFIG]) + xored
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] & 0x01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inherited SLIX2 features still work
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcode3InheritsSlix2:
|
||||
def test_eas(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
run(tag.power_on())
|
||||
cmd = bytes([0x02, CMD_SET_EAS, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
assert tag.eas_enabled
|
||||
|
||||
def test_privacy(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4),
|
||||
privacy_password=0xAAAAAAAA)
|
||||
run(tag.power_on())
|
||||
tag.enter_privacy_mode()
|
||||
assert tag.privacy_mode
|
||||
|
||||
def test_destroy(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4),
|
||||
destroy_password=0xBBBBBBBB)
|
||||
run(tag.power_on())
|
||||
xored = _xor_password(tag, 0xBBBBBBBB)
|
||||
cmd = bytes([0x02, 0xB9, 0x04]) + xored
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(bytes([0x26, 0x01, 0x00])))) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 24-bit counter (block 75)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcode3Counter:
|
||||
def test_counter_increments_24bit(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
run(tag.power_on())
|
||||
|
||||
# Write increment of 1 to block 75
|
||||
cmd = bytes([0x02, 0x21, 75, 0x01, 0x00, 0x00, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
|
||||
# Read back
|
||||
cmd = bytes([0x02, 0x20, 75])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
counter = int.from_bytes(resp.data[1:4], 'little')
|
||||
assert counter == 1
|
||||
|
||||
def test_counter_accumulates(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
run(tag.power_on())
|
||||
|
||||
for _ in range(10):
|
||||
cmd = bytes([0x02, 0x21, 75, 0x01, 0x00, 0x00, 0x00])
|
||||
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
|
||||
cmd = bytes([0x02, 0x20, 75])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
counter = int.from_bytes(resp.data[1:4], 'little')
|
||||
assert counter == 10
|
||||
|
||||
def test_counter_saturates_at_24bit(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
run(tag.power_on())
|
||||
|
||||
# Set counter near max
|
||||
offset = 75 * 4
|
||||
tag._memory[offset:offset + 3] = (0xFFFFFE).to_bytes(3, 'little')
|
||||
|
||||
cmd = bytes([0x02, 0x21, 75, 0x01, 0x00, 0x00, 0x00])
|
||||
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
# Now at 0xFFFFFF
|
||||
|
||||
cmd = bytes([0x02, 0x21, 75, 0x01, 0x00, 0x00, 0x00])
|
||||
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
# Should saturate
|
||||
|
||||
cmd = bytes([0x02, 0x20, 75])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
counter = int.from_bytes(resp.data[1:4], 'little')
|
||||
assert counter == 0xFFFFFF
|
||||
|
||||
def test_normal_blocks_still_overwrite(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
run(tag.power_on())
|
||||
|
||||
cmd = bytes([0x02, 0x21, 0x00, 0xAA, 0xBB, 0xCC, 0xDD])
|
||||
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
cmd = bytes([0x02, 0x21, 0x00, 0x11, 0x22, 0x33, 0x44])
|
||||
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
|
||||
cmd = bytes([0x02, 0x20, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[1:5] == bytes([0x11, 0x22, 0x33, 0x44])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# READ CONFIG (0xC0) / WRITE CONFIG (0xC1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcode3Config:
|
||||
def test_read_config(self):
|
||||
"""READ CONFIG returns config memory blocks."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
run(tag.power_on())
|
||||
|
||||
# Read config block 16 (DSFID), 1 block
|
||||
cmd = bytes([0x02, CMD_READ_CONFIG, 0x04, 16, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x00
|
||||
assert len(resp.data) >= 5 # flags + 4 bytes
|
||||
|
||||
def test_write_config(self):
|
||||
"""WRITE CONFIG writes to config memory."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
run(tag.power_on())
|
||||
|
||||
# Write to config block 18 (EAS ID)
|
||||
cmd = bytes([0x02, CMD_WRITE_CONFIG, 0x04, 18, 0x34, 0x12, 0x00, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x00
|
||||
|
||||
# Read it back
|
||||
cmd = bytes([0x02, CMD_READ_CONFIG, 0x04, 18, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[1:3] == bytes([0x34, 0x12])
|
||||
|
||||
def test_read_config_passwords_masked(self):
|
||||
"""READ CONFIG masks password blocks with 0x00."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4),
|
||||
read_password=0xDEADBEEF)
|
||||
run(tag.power_on())
|
||||
|
||||
# Read config block 42 (READ_PWD) — should be masked
|
||||
cmd = bytes([0x02, CMD_READ_CONFIG, 0x04, 42, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x00
|
||||
assert resp.data[1:5] == bytes(4) # masked with 0x00
|
||||
|
||||
def test_write_config_requires_auth_when_protected(self):
|
||||
"""WRITE CONFIG fails without config password when protected."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4),
|
||||
config_password=0xAABBCCDD)
|
||||
run(tag.power_on())
|
||||
tag._config_password_protected = True
|
||||
|
||||
cmd = bytes([0x02, CMD_WRITE_CONFIG, 0x04, 18, 0x34, 0x12, 0x00, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] & 0x01
|
||||
|
||||
def test_read_config_multiple_blocks(self):
|
||||
"""READ CONFIG can read multiple blocks."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
run(tag.power_on())
|
||||
|
||||
# Read 3 blocks starting at block 16
|
||||
cmd = bytes([0x02, CMD_READ_CONFIG, 0x04, 16, 0x02])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x00
|
||||
assert len(resp.data) >= 13 # flags + 3 * 4 bytes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# READ TT (0xC4) — TagTamper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcode3TagTamper:
|
||||
def test_read_tt_closed(self):
|
||||
"""READ TT returns closed status by default."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4), tag_tamper=True)
|
||||
run(tag.power_on())
|
||||
|
||||
cmd = bytes([0x02, CMD_READ_TT, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x00
|
||||
# actual_status=0x43('C'), stored_status=0x43('C')
|
||||
assert resp.data[1] == 0x43 # 'C' = Closed
|
||||
assert resp.data[2] == 0x43
|
||||
|
||||
def test_read_tt_open(self):
|
||||
"""READ TT returns open status when tampered."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4), tag_tamper=True)
|
||||
run(tag.power_on())
|
||||
tag._tt_status_actual = 0x4F # 'O' = Open
|
||||
|
||||
cmd = bytes([0x02, CMD_READ_TT, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[1] == 0x4F # 'O'
|
||||
|
||||
def test_read_tt_not_supported_without_flag(self):
|
||||
"""READ TT returns error when tag_tamper=False."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4), tag_tamper=False)
|
||||
run(tag.power_on())
|
||||
|
||||
cmd = bytes([0x02, CMD_READ_TT, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None or resp.data[0] & 0x01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET NXP SYSTEM INFO feature flags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcode3SystemInfo:
|
||||
def test_feature_flags(self):
|
||||
"""GET NXP SYSTEM INFO returns ICODE 3 feature flags."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
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
|
||||
# Feature flags should indicate counter, privacy, destroy, etc.
|
||||
feature_flags = struct.unpack_from("<I", resp.data, 4)[0]
|
||||
assert feature_flags & (1 << 1) # COUNTER
|
||||
assert feature_flags & (1 << 8) # ORIGINALITY SIG
|
||||
assert feature_flags & (1 << 10) # P QUIET
|
||||
assert feature_flags & (1 << 12) # PRIVACY
|
||||
assert feature_flags & (1 << 13) # DESTROY
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NFC ASCII mirror
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcode3NfcMirror:
|
||||
def test_uid_mirror(self):
|
||||
"""UID mirror overlays 16 ASCII hex bytes into user memory on read."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06")
|
||||
run(tag.power_on())
|
||||
|
||||
# Enable UID mirror at block 10, byte 0
|
||||
tag.set_nfc_mirror(sel=1, block=10, byte_offset=0)
|
||||
|
||||
# Read block 10 — should contain ASCII hex of UID
|
||||
cmd = bytes([0x02, 0x20, 10])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
# UID E0040102... MSB first → "E0040102" as ASCII = 0x45 0x30 0x30 0x34
|
||||
data = resp.data[1:5]
|
||||
assert data == b"E004"
|
||||
|
||||
def test_uid_counter_mirror(self):
|
||||
"""UID + counter mirror: 16 bytes UID + 'x' + 6 bytes counter."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06")
|
||||
run(tag.power_on())
|
||||
|
||||
# Set counter to 0x000042 (66 decimal)
|
||||
offset = 75 * 4
|
||||
tag._memory[offset:offset + 3] = (0x42).to_bytes(3, 'little')
|
||||
|
||||
tag.set_nfc_mirror(sel=2, block=10, byte_offset=0)
|
||||
|
||||
# Read blocks 10-15 to get full mirror
|
||||
mirror_data = bytearray()
|
||||
for blk in range(10, 16):
|
||||
cmd = bytes([0x02, 0x20, blk])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
mirror_data.extend(resp.data[1:5])
|
||||
|
||||
# First 16 bytes = UID hex: "E004012003040506"
|
||||
assert mirror_data[:16] == b"E004012003040506"
|
||||
# Separator
|
||||
assert mirror_data[16:17] == b"x"
|
||||
# Counter 6 bytes hex MSB first: 0x000042 → "000042"
|
||||
assert mirror_data[17:23] == b"000042"
|
||||
|
||||
def test_mirror_disabled(self):
|
||||
"""With mirror disabled (sel=0), reads return physical memory."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06")
|
||||
run(tag.power_on())
|
||||
|
||||
tag.set_nfc_mirror(sel=0, block=10, byte_offset=0)
|
||||
|
||||
# Write data to block 10
|
||||
cmd = bytes([0x02, 0x21, 10, 0xAA, 0xBB, 0xCC, 0xDD])
|
||||
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
|
||||
# Read back — should be physical data
|
||||
cmd = bytes([0x02, 0x20, 10])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[1:5] == bytes([0xAA, 0xBB, 0xCC, 0xDD])
|
||||
|
||||
def test_mirror_with_byte_offset(self):
|
||||
"""Mirror starting at byte 2 within a block."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06")
|
||||
run(tag.power_on())
|
||||
|
||||
# Write known data first
|
||||
cmd = bytes([0x02, 0x21, 10, 0xFF, 0xFF, 0xFF, 0xFF])
|
||||
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
|
||||
# UID mirror at block 10, byte 2
|
||||
tag.set_nfc_mirror(sel=1, block=10, byte_offset=2)
|
||||
|
||||
cmd = bytes([0x02, 0x20, 10])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
data = resp.data[1:5]
|
||||
# Bytes 0-1 = physical (0xFF, 0xFF), bytes 2-3 = mirror start ("E0")
|
||||
assert data[0:2] == bytes([0xFF, 0xFF])
|
||||
assert data[2:4] == b"E0"
|
||||
|
||||
def test_mirror_outside_block_returns_physical(self):
|
||||
"""Blocks before mirror region return physical memory."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06")
|
||||
run(tag.power_on())
|
||||
|
||||
tag.set_nfc_mirror(sel=1, block=20, byte_offset=0)
|
||||
|
||||
cmd = bytes([0x02, 0x21, 5, 0x11, 0x22, 0x33, 0x44])
|
||||
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
|
||||
cmd = bytes([0x02, 0x20, 5])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[1:5] == bytes([0x11, 0x22, 0x33, 0x44])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Privacy mode 2 + PICK RANDOM ID
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcode3PrivacyMode2:
|
||||
def test_privacy_mode2_inventory_zeroed_uid(self):
|
||||
"""In privacy mode 2, inventory returns zeroed UID."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06",
|
||||
privacy_password=0xDEADBEEF)
|
||||
run(tag.power_on())
|
||||
tag._privacy_mode_sel = 2
|
||||
tag.enter_privacy_mode()
|
||||
|
||||
inv = bytes([0x26, 0x01, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
|
||||
assert resp is not None
|
||||
# UID should be E0 04 00 00 00 00 00 00 (reversed in response)
|
||||
uid_in_resp = resp.data[2:10]
|
||||
uid_msb = bytes(reversed(uid_in_resp))
|
||||
assert uid_msb == b"\xE0\x04\x00\x00\x00\x00\x00\x00"
|
||||
|
||||
def test_privacy_mode2_allows_reads(self):
|
||||
"""Privacy mode 2 allows READ SINGLE BLOCK."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06",
|
||||
privacy_password=0xDEADBEEF)
|
||||
run(tag.power_on())
|
||||
|
||||
# Write data first
|
||||
cmd = bytes([0x02, 0x21, 0, 0xAA, 0xBB, 0xCC, 0xDD])
|
||||
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
|
||||
tag._privacy_mode_sel = 2
|
||||
tag.enter_privacy_mode()
|
||||
|
||||
# Read should work in privacy mode 2
|
||||
cmd = bytes([0x02, 0x20, 0])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x00
|
||||
|
||||
def test_privacy_mode1_blocks_reads(self):
|
||||
"""Privacy mode 1 blocks everything except GET_RANDOM/SET_PASSWORD."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06",
|
||||
privacy_password=0xDEADBEEF)
|
||||
run(tag.power_on())
|
||||
tag._privacy_mode_sel = 1
|
||||
tag.enter_privacy_mode()
|
||||
|
||||
# Read should fail in privacy mode 1
|
||||
cmd = bytes([0x02, 0x20, 0])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None
|
||||
|
||||
def test_pick_random_id(self):
|
||||
"""PICK RANDOM ID (0xC2) generates random UID for inventory."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06",
|
||||
privacy_password=0xDEADBEEF)
|
||||
run(tag.power_on())
|
||||
tag._privacy_mode_sel = 2
|
||||
tag.enter_privacy_mode()
|
||||
|
||||
# PICK RANDOM ID
|
||||
cmd = bytes([0x02, 0xC2, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x00
|
||||
|
||||
# Now inventory should return the random ID, not the zeroed one
|
||||
inv = bytes([0x26, 0x01, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
|
||||
uid_in_resp = bytes(reversed(resp.data[2:10]))
|
||||
# Random ID format: E0 04 00 00 CID1 CID0 RID1 RID0
|
||||
assert uid_in_resp[0:2] == b"\xE0\x04"
|
||||
assert uid_in_resp[2:4] == b"\x00\x00"
|
||||
# Last 4 bytes should be non-zero (random + CID)
|
||||
# (statistically near-impossible to be all zero)
|
||||
|
||||
def test_pick_random_id_fails_outside_privacy(self):
|
||||
"""PICK RANDOM ID fails when not in privacy mode."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20\x03\x04\x05\x06")
|
||||
run(tag.power_on())
|
||||
|
||||
cmd = bytes([0x02, 0xC2, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None or resp.data[0] & 0x01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 48-byte originality signature (SL2S3003 8.5.3.20)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcode3Signature48:
|
||||
def test_default_32_byte_signature(self):
|
||||
"""Default READ SIGNATURE returns 32 bytes (1 flag + 32 sig = 33)."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
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
|
||||
assert len(resp.data) == 33 # 1 flag + 32 signature
|
||||
|
||||
def test_48_byte_signature_mode(self):
|
||||
"""With 48-byte mode enabled, READ SIGNATURE returns 49 bytes."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
sig48 = bytes(range(48))
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4), signature=sig48)
|
||||
run(tag.power_on())
|
||||
tag.set_signature_mode_48(True)
|
||||
|
||||
cmd = bytes([0x02, 0xBD, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is not None
|
||||
assert resp.data[0] == 0x00
|
||||
assert len(resp.data) == 49 # 1 flag + 48 signature
|
||||
assert resp.data[1:] == sig48
|
||||
|
||||
def test_48_byte_mode_disabled_truncates(self):
|
||||
"""With 48-byte mode disabled, READ SIGNATURE returns first 32 bytes."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
sig48 = bytes(range(48))
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4), signature=sig48)
|
||||
run(tag.power_on())
|
||||
# Mode defaults to 32-byte
|
||||
cmd = bytes([0x02, 0xBD, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert len(resp.data) == 33 # 1 flag + 32 signature
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 24-bit counter preset vs increment (SL2S3003 8.5.3.25)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcode3CounterPreset:
|
||||
def test_value_0x000001_increments(self):
|
||||
"""Writing 0x000001 to block 75 increments counter by 1."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
run(tag.power_on())
|
||||
|
||||
# Counter starts at 0, write 0x000001 → increment by 1 → 1
|
||||
cmd = bytes([0x02, 0x21, 75, 0x01, 0x00, 0x00, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
|
||||
cmd = bytes([0x02, 0x20, 75])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
counter = int.from_bytes(resp.data[1:4], 'little')
|
||||
assert counter == 1
|
||||
|
||||
def test_value_not_0x000001_presets(self):
|
||||
"""Writing != 0x000001 to block 75 presets (overwrites) counter."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
run(tag.power_on())
|
||||
|
||||
# Set counter to 100 first
|
||||
offset = 75 * 4
|
||||
tag._memory[offset:offset + 3] = (100).to_bytes(3, 'little')
|
||||
|
||||
# Write 0x000100 (256) → should preset to 256, NOT add to 100
|
||||
cmd = bytes([0x02, 0x21, 75, 0x00, 0x01, 0x00, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
|
||||
cmd = bytes([0x02, 0x20, 75])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
counter = int.from_bytes(resp.data[1:4], 'little')
|
||||
assert counter == 256 # preset, not 356
|
||||
|
||||
def test_0x000001_twice_increments_twice(self):
|
||||
"""Writing 0x000001 twice increments counter from 0 → 1 → 2."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
run(tag.power_on())
|
||||
|
||||
cmd = bytes([0x02, 0x21, 75, 0x01, 0x00, 0x00, 0x00])
|
||||
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
|
||||
cmd = bytes([0x02, 0x20, 75])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
counter = int.from_bytes(resp.data[1:4], 'little')
|
||||
assert counter == 2
|
||||
|
||||
def test_prot_byte_written(self):
|
||||
"""PROT byte (data[3]) is written to memory alongside counter."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(uid=b"\xE0\x04\x01\x20" + bytes(4))
|
||||
run(tag.power_on())
|
||||
|
||||
# Preset counter to 0x000005 with PROT=0xAB
|
||||
cmd = bytes([0x02, 0x21, 75, 0x05, 0x00, 0x00, 0xAB])
|
||||
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
|
||||
cmd = bytes([0x02, 0x20, 75])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[4] == 0xAB # PROT byte at offset 3 in block
|
||||
341
tests/test_sim_icode_slix.py
Normal file
341
tests/test_sim_icode_slix.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""Tests for ICODE SLIX (SL2S2002) transponder model."""
|
||||
import asyncio
|
||||
import struct
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.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,
|
||||
)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: GET_RANDOM + XOR password
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _xor_password(tag, pwd_value: int) -> 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("<I", pwd_value)
|
||||
return bytes([pwd[0] ^ r[0], pwd[1] ^ r[1], pwd[2] ^ r[0], pwd[3] ^ r[1]])
|
||||
|
||||
|
||||
def _authenticate(tag, pwd_id: int, pwd_value: int):
|
||||
"""GET_RANDOM + SET_PASSWORD for a given password ID."""
|
||||
xored = _xor_password(tag, pwd_value)
|
||||
cmd = bytes([0x02, 0xB3, 0x04, pwd_id]) + xored
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00, f"auth failed for pwd_id {pwd_id:#x}"
|
||||
|
||||
|
||||
PWD_EAS_AFI = 0x10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic SLIX properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcodeSlixBasics:
|
||||
def test_import(self):
|
||||
"""IcodeSlixTag can be imported."""
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
|
||||
def test_inherits_nxp_icode(self):
|
||||
"""SLIX inherits from NxpIcodeTag."""
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
from pm3py.sim.nxp_icode import NxpIcodeTag
|
||||
assert issubclass(IcodeSlixTag, NxpIcodeTag)
|
||||
|
||||
def test_default_28_blocks(self):
|
||||
"""SLIX defaults to 28 blocks × 4 bytes."""
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
||||
assert tag._num_blocks == 28
|
||||
assert tag._block_size == 4
|
||||
|
||||
def test_responds_to_inventory(self):
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
||||
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_read_write_block(self):
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
||||
run(tag.power_on())
|
||||
# Write block 5
|
||||
cmd = bytes([0x02, 0x21, 0x05, 0xAA, 0xBB, 0xCC, 0xDD])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
# Read it back
|
||||
cmd = bytes([0x02, 0x20, 0x05])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[1:5] == bytes([0xAA, 0xBB, 0xCC, 0xDD])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single password (EAS/AFI only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcodeSlixPassword:
|
||||
def test_set_password_eas_afi(self):
|
||||
"""SET_PASSWORD with correct EAS/AFI password succeeds."""
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4),
|
||||
eas_afi_password=0xAABBCCDD)
|
||||
run(tag.power_on())
|
||||
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
|
||||
|
||||
def test_set_password_wrong(self):
|
||||
"""SET_PASSWORD with wrong password fails."""
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4),
|
||||
eas_afi_password=0xAABBCCDD)
|
||||
run(tag.power_on())
|
||||
xored = _xor_password(tag, 0x00000000)
|
||||
cmd = bytes([0x02, 0xB3, 0x04, PWD_EAS_AFI]) + xored
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] & 0x01
|
||||
|
||||
def test_write_password(self):
|
||||
"""WRITE_PASSWORD changes the EAS/AFI password."""
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4),
|
||||
eas_afi_password=0xAABBCCDD)
|
||||
run(tag.power_on())
|
||||
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
|
||||
new_xored = _xor_password(tag, 0x11223344)
|
||||
cmd = bytes([0x02, 0xB4, 0x04, PWD_EAS_AFI]) + new_xored
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
# New password works
|
||||
_authenticate(tag, PWD_EAS_AFI, 0x11223344)
|
||||
|
||||
def test_lock_password(self):
|
||||
"""LOCK_PASSWORD prevents WRITE_PASSWORD."""
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4),
|
||||
eas_afi_password=0xAABBCCDD)
|
||||
run(tag.power_on())
|
||||
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
|
||||
cmd = bytes([0x02, 0xB5, 0x04, PWD_EAS_AFI])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
# WRITE_PASSWORD now fails
|
||||
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
|
||||
new_xored = _xor_password(tag, 0x11223344)
|
||||
cmd = bytes([0x02, 0xB4, 0x04, PWD_EAS_AFI]) + new_xored
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] & 0x01
|
||||
|
||||
def test_no_privacy_password(self):
|
||||
"""SLIX has no privacy — only EAS/AFI password accepted."""
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4),
|
||||
eas_afi_password=0xAABBCCDD)
|
||||
run(tag.power_on())
|
||||
# Try to auth with privacy pwd_id — should fail
|
||||
xored = _xor_password(tag, 0xAABBCCDD)
|
||||
cmd = bytes([0x02, 0xB3, 0x04, 0x04]) + xored # pwd_id=0x04 (privacy)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] & 0x01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EAS subsystem
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcodeSlixEas:
|
||||
def test_set_eas(self):
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
||||
run(tag.power_on())
|
||||
cmd = bytes([0x02, CMD_SET_EAS, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
assert tag.eas_enabled
|
||||
|
||||
def test_reset_eas(self):
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
||||
run(tag.power_on())
|
||||
tag.set_eas(True)
|
||||
cmd = bytes([0x02, CMD_RESET_EAS, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
assert not tag.eas_enabled
|
||||
|
||||
def test_eas_alarm(self):
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
||||
run(tag.power_on())
|
||||
tag.set_eas(True)
|
||||
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
|
||||
assert len(resp.data) >= 33
|
||||
|
||||
def test_eas_alarm_silent_when_disabled(self):
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
||||
run(tag.power_on())
|
||||
cmd = bytes([0x02, CMD_EAS_ALARM, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None
|
||||
|
||||
def test_lock_eas(self):
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
||||
run(tag.power_on())
|
||||
tag.set_eas(True)
|
||||
cmd = bytes([0x02, CMD_LOCK_EAS, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
# Can't change now
|
||||
cmd = bytes([0x02, CMD_RESET_EAS, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] & 0x01
|
||||
|
||||
def test_password_protect_eas_afi(self):
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4),
|
||||
eas_afi_password=0xAABBCCDD)
|
||||
run(tag.power_on())
|
||||
_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.data[0] == 0x00
|
||||
assert tag._eas_password_protected
|
||||
|
||||
def test_write_eas_id(self):
|
||||
"""WRITE EAS ID is SLIX2-only (EAS Selective per AN11809)."""
|
||||
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
|
||||
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.data[0] == 0x00
|
||||
assert tag._eas_id == 0x1234
|
||||
|
||||
def test_slix_rejects_write_eas_id(self):
|
||||
"""SLIX (SL2S2002) does not support WRITE EAS ID (0xA7)."""
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
||||
run(tag.power_on())
|
||||
cmd = bytes([0x02, CMD_WRITE_EAS_ID, 0x04, 0x34, 0x12])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# INVENTORY READ
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIcodeSlixInventoryRead:
|
||||
def test_inventory_read(self):
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10\x03\x04\x05\x06")
|
||||
run(tag.power_on())
|
||||
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
|
||||
|
||||
def test_fast_inventory_read(self):
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10\x03\x04\x05\x06")
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SLIX2 still works after refactoring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSlix2StillWorks:
|
||||
"""Verify SLIX2 inherits from SLIX and retains all features."""
|
||||
|
||||
def test_slix2_inherits_slix(self):
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
|
||||
assert issubclass(IcodeSlix2Tag, IcodeSlixTag)
|
||||
|
||||
def test_slix2_privacy_still_works(self):
|
||||
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
|
||||
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
||||
privacy_password=0xDEADBEEF)
|
||||
run(tag.power_on())
|
||||
tag.enter_privacy_mode()
|
||||
assert tag.privacy_mode
|
||||
|
||||
def test_slix2_destroy_still_works(self):
|
||||
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
|
||||
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
||||
destroy_password=0xDEAD1234)
|
||||
run(tag.power_on())
|
||||
xored = _xor_password(tag, 0xDEAD1234)
|
||||
cmd = bytes([0x02, 0xB9, 0x04]) + xored
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
# Dead
|
||||
inv = bytes([0x26, 0x01, 0x00])
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(inv))) is None
|
||||
|
||||
def test_slix2_eas_inherited_from_slix(self):
|
||||
"""SLIX2 EAS works via inheritance from SLIX."""
|
||||
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
|
||||
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
||||
run(tag.power_on())
|
||||
cmd = bytes([0x02, CMD_SET_EAS, 0x04])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp.data[0] == 0x00
|
||||
assert tag.eas_enabled
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SLIX should NOT have SLIX2-only features
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSlixLacksPrivacy:
|
||||
def test_no_enable_privacy(self):
|
||||
"""SLIX does not respond to ENABLE PRIVACY (0xBA)."""
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
||||
run(tag.power_on())
|
||||
cmd = bytes([0x02, 0xBA, 0x04, 0x00, 0x00, 0x00, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
# Should be None (unhandled) or error
|
||||
assert resp is None or resp.data[0] & 0x01
|
||||
|
||||
def test_no_destroy(self):
|
||||
"""SLIX does not respond to DESTROY (0xB9)."""
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
||||
run(tag.power_on())
|
||||
cmd = bytes([0x02, 0xB9, 0x04, 0x00, 0x00, 0x00, 0x00])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None or resp.data[0] & 0x01
|
||||
|
||||
def test_no_protect_page(self):
|
||||
"""SLIX does not respond to PROTECT PAGE (0xB6)."""
|
||||
from pm3py.sim.icode_slix import IcodeSlixTag
|
||||
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
||||
run(tag.power_on())
|
||||
cmd = bytes([0x02, 0xB6, 0x04, 0x08, 0x01])
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
||||
assert resp is None or resp.data[0] & 0x01
|
||||
105
tests/test_sim_implants.py
Normal file
105
tests/test_sim_implants.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Tests for implant preset profiles."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.medium import SoftwareMedium
|
||||
from pm3py.sim.implants import (
|
||||
xEM, xNT, xM1, FlexDF, NExT,
|
||||
MagicMifareClassicTag,
|
||||
)
|
||||
from pm3py.sim.em4100 import EM4100Reader
|
||||
from pm3py.sim.iso14443a import Reader14443A, _compute_bcc
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
class TestXEM:
|
||||
def test_creates_t5577_configured_as_em4100(self):
|
||||
tag = xEM(tag_id=0x1122334455)
|
||||
assert tag.blocks[0] != 0 # config block set
|
||||
|
||||
def test_readable_as_em4100(self):
|
||||
medium = SoftwareMedium()
|
||||
tag = xEM(tag_id=0x1122334455)
|
||||
run(medium.attach(tag))
|
||||
reader = EM4100Reader(medium)
|
||||
result = run(reader.read_id())
|
||||
assert result is not None
|
||||
assert result["tag_id"] == 0x1122334455
|
||||
|
||||
|
||||
class TestXNT:
|
||||
def test_creates_ntag216_like(self):
|
||||
tag = xNT()
|
||||
assert tag._total_pages >= 45 # NTAG213+ size
|
||||
|
||||
def test_is_type2_tag(self):
|
||||
from pm3py.sim.ndef import NfcType2Tag
|
||||
tag = xNT()
|
||||
assert isinstance(tag, NfcType2Tag)
|
||||
|
||||
def test_has_7byte_uid(self):
|
||||
tag = xNT()
|
||||
assert len(tag._uid) == 7
|
||||
|
||||
|
||||
class TestXM1:
|
||||
def test_creates_mifare_classic_1k(self):
|
||||
tag = xM1()
|
||||
assert tag._size == "1k"
|
||||
assert len(tag._data) == 1024
|
||||
|
||||
def test_default_keys_ff(self):
|
||||
tag = xM1()
|
||||
assert tag._keys_a[0] == b"\xFF" * 6
|
||||
|
||||
|
||||
class TestMagicMifareClassic:
|
||||
def test_gen1a_backdoor_write_block0(self):
|
||||
"""Magic gen1a allows direct write to block 0."""
|
||||
tag = MagicMifareClassicTag(uid=b"\x01\x02\x03\x04",
|
||||
magic_type="gen1a")
|
||||
run(tag.power_on())
|
||||
# gen1a responds to special WUPA sequence for backdoor
|
||||
assert tag.magic_type == "gen1a"
|
||||
# Direct block 0 write should work
|
||||
new_data = b"\xDE\xAD\xBE\xEF" + b"\x00" * 12
|
||||
tag.write_block_raw(0, new_data)
|
||||
assert tag.read_block_raw(0) == new_data
|
||||
|
||||
def test_gen2_cuid(self):
|
||||
tag = MagicMifareClassicTag(uid=b"\x01\x02\x03\x04",
|
||||
magic_type="gen2")
|
||||
assert tag.magic_type == "gen2"
|
||||
|
||||
|
||||
class TestFlexDF:
|
||||
def test_creates_desfire(self):
|
||||
from pm3py.sim.desfire import DesfireTag
|
||||
tag = FlexDF()
|
||||
assert isinstance(tag, DesfireTag)
|
||||
|
||||
def test_has_7byte_uid(self):
|
||||
tag = FlexDF()
|
||||
assert len(tag._uid) == 7
|
||||
|
||||
|
||||
class TestNExT:
|
||||
def test_creates_dual_frequency(self):
|
||||
lf, hf = NExT(tag_id=0x1122334455)
|
||||
# LF side is T5577 configured as EM4100
|
||||
assert lf.blocks[0] != 0
|
||||
# HF side is NfcType2Tag (xNT)
|
||||
from pm3py.sim.ndef import NfcType2Tag
|
||||
assert isinstance(hf, NfcType2Tag)
|
||||
|
||||
def test_both_sides_work(self):
|
||||
lf, hf = NExT(tag_id=0xAABBCCDDEE)
|
||||
# LF
|
||||
lf_medium = SoftwareMedium()
|
||||
run(lf_medium.attach(lf))
|
||||
reader = EM4100Reader(lf_medium)
|
||||
result = run(reader.read_id())
|
||||
assert result["tag_id"] == 0xAABBCCDDEE
|
||||
218
tests/test_sim_lf.py
Normal file
218
tests/test_sim_lf.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Tests for pm3py.sim LF transponders and readers — TagLF, EM4100, HID, T5577."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.medium import SoftwareMedium
|
||||
from pm3py.sim.lf_base import TagLF, ReaderLF, Modulation
|
||||
from pm3py.sim.em4100 import EM4100Tag, EM4100Reader
|
||||
from pm3py.sim.hid import HIDProxTag, HIDReader
|
||||
from pm3py.sim.t5577 import T5577Tag, T5577Reader
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TagLF — base LF transponder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTagLFBase:
|
||||
def test_modulation_types_exist(self):
|
||||
assert Modulation.ASK is not None
|
||||
assert Modulation.FSK is not None
|
||||
assert Modulation.PSK is not None
|
||||
assert Modulation.NRZ is not None
|
||||
|
||||
def test_taglf_responds_to_field(self):
|
||||
"""Any LF tag responds when RF field is present (energized)."""
|
||||
tag = EM4100Tag(tag_id=0x1A2B3C4D5E)
|
||||
run(tag.power_on())
|
||||
# LF tags continuously transmit when energized
|
||||
# A "read" frame from the reader is just an empty energize command
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x00")))
|
||||
assert resp is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EM4100 — read-only 64-bit Manchester ASK tag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEM4100Tag:
|
||||
def test_construction(self):
|
||||
tag = EM4100Tag(tag_id=0x1A2B3C4D5E)
|
||||
assert tag.tag_id == 0x1A2B3C4D5E
|
||||
|
||||
def test_encodes_64bit_data(self):
|
||||
"""EM4100 data: 9-bit header + 10 rows of (4 data + 1 parity) + 4 col parity + stop."""
|
||||
tag = EM4100Tag(tag_id=0x1A2B3C4D5E)
|
||||
data = tag.encoded_data
|
||||
assert len(data) == 64 # 64 bits total
|
||||
|
||||
def test_header_is_9_ones(self):
|
||||
tag = EM4100Tag(tag_id=0x0000000000)
|
||||
data = tag.encoded_data
|
||||
assert all(b == 1 for b in data[:9])
|
||||
|
||||
def test_row_parity(self):
|
||||
"""Each 4-bit row has even parity appended."""
|
||||
tag = EM4100Tag(tag_id=0x1A2B3C4D5E)
|
||||
data = tag.encoded_data
|
||||
# 10 rows starting at bit 9, each 5 bits (4 data + 1 parity)
|
||||
for row in range(10):
|
||||
start = 9 + row * 5
|
||||
row_bits = data[start:start + 5]
|
||||
assert sum(row_bits) % 2 == 0, f"Row {row} parity error"
|
||||
|
||||
def test_column_parity(self):
|
||||
"""4 column parity bits after the data rows."""
|
||||
tag = EM4100Tag(tag_id=0x1A2B3C4D5E)
|
||||
data = tag.encoded_data
|
||||
# Column parity at bits 59-62
|
||||
for col in range(4):
|
||||
col_sum = 0
|
||||
for row in range(10):
|
||||
col_sum += data[9 + row * 5 + col]
|
||||
col_sum += data[59 + col] # parity bit itself
|
||||
assert col_sum % 2 == 0, f"Column {col} parity error"
|
||||
|
||||
def test_stop_bit(self):
|
||||
tag = EM4100Tag(tag_id=0x0000000000)
|
||||
data = tag.encoded_data
|
||||
assert data[63] == 0 # stop bit
|
||||
|
||||
def test_responds_to_read(self):
|
||||
medium = SoftwareMedium()
|
||||
tag = EM4100Tag(tag_id=0x1A2B3C4D5E)
|
||||
run(medium.attach(tag))
|
||||
reader = EM4100Reader(medium)
|
||||
|
||||
result = run(reader.read_id())
|
||||
assert result is not None
|
||||
assert result["tag_id"] == 0x1A2B3C4D5E
|
||||
|
||||
def test_decode_roundtrip(self):
|
||||
"""Encode then decode should return original ID."""
|
||||
original_id = 0xDEADBEEF01
|
||||
tag = EM4100Tag(tag_id=original_id)
|
||||
decoded = EM4100Tag.decode_data(tag.encoded_data)
|
||||
assert decoded == original_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HID ProxCard — FSK modulated, Wiegand credential
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHIDProxTag:
|
||||
def test_construction_26bit(self):
|
||||
tag = HIDProxTag(facility_code=42, card_number=12345, format="H10301")
|
||||
assert tag.facility_code == 42
|
||||
assert tag.card_number == 12345
|
||||
assert tag.format == "H10301"
|
||||
|
||||
def test_26bit_wiegand_encoding(self):
|
||||
"""H10301: 1 even parity + 8 FC + 16 CN + 1 odd parity = 26 bits."""
|
||||
tag = HIDProxTag(facility_code=42, card_number=12345, format="H10301")
|
||||
wiegand = tag.wiegand_bits
|
||||
assert len(wiegand) == 26
|
||||
|
||||
def test_26bit_parity(self):
|
||||
"""First 13 bits: even parity. Last 13 bits: odd parity."""
|
||||
tag = HIDProxTag(facility_code=42, card_number=12345, format="H10301")
|
||||
wiegand = tag.wiegand_bits
|
||||
assert sum(wiegand[:13]) % 2 == 0 # even parity on first half
|
||||
assert sum(wiegand[13:]) % 2 == 1 # odd parity on second half
|
||||
|
||||
def test_37bit_format(self):
|
||||
tag = HIDProxTag(facility_code=1000, card_number=50000, format="H10304")
|
||||
wiegand = tag.wiegand_bits
|
||||
assert len(wiegand) == 37
|
||||
|
||||
def test_reader_reads_credential(self):
|
||||
medium = SoftwareMedium()
|
||||
tag = HIDProxTag(facility_code=42, card_number=12345)
|
||||
run(medium.attach(tag))
|
||||
reader = HIDReader(medium)
|
||||
|
||||
result = run(reader.read_credential())
|
||||
assert result is not None
|
||||
assert result["facility_code"] == 42
|
||||
assert result["card_number"] == 12345
|
||||
|
||||
|
||||
class TestHIDProxDecoding:
|
||||
def test_decode_26bit_roundtrip(self):
|
||||
tag = HIDProxTag(facility_code=100, card_number=9999, format="H10301")
|
||||
decoded = HIDProxTag.decode_wiegand(tag.wiegand_bits, "H10301")
|
||||
assert decoded["facility_code"] == 100
|
||||
assert decoded["card_number"] == 9999
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T5577 — programmable LF emulator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestT5577Tag:
|
||||
def test_construction(self):
|
||||
tag = T5577Tag()
|
||||
assert len(tag.blocks) == 8
|
||||
assert all(b == 0 for b in tag.blocks)
|
||||
|
||||
def test_read_block(self):
|
||||
tag = T5577Tag()
|
||||
tag.blocks[1] = 0xDEADBEEF
|
||||
run(tag.power_on())
|
||||
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x01\x01"))) # read block 1
|
||||
assert resp is not None
|
||||
|
||||
def test_write_block(self):
|
||||
tag = T5577Tag()
|
||||
run(tag.power_on())
|
||||
|
||||
# Write block 1 = 0xCAFEBABE
|
||||
import struct
|
||||
write_data = b"\x02\x01" + struct.pack(">I", 0xCAFEBABE)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(write_data)))
|
||||
assert tag.blocks[1] == 0xCAFEBABE
|
||||
|
||||
def test_password_protection(self):
|
||||
tag = T5577Tag(password=0x12345678)
|
||||
run(tag.power_on())
|
||||
|
||||
# Read without password should fail
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x01\x01")))
|
||||
assert resp is None
|
||||
|
||||
# Read with correct password
|
||||
import struct
|
||||
pwd_read = b"\x03\x01" + struct.pack(">I", 0x12345678)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(pwd_read)))
|
||||
assert resp is not None
|
||||
|
||||
def test_configure_as_em4100(self):
|
||||
"""T5577 can be configured to emulate EM4100."""
|
||||
tag = T5577Tag.preset("em4100", tag_id=0x1A2B3C4D5E)
|
||||
assert tag.blocks[0] != 0 # config block set
|
||||
# Block 0 should have ASK/Manchester config
|
||||
|
||||
def test_reader_read_block(self):
|
||||
medium = SoftwareMedium()
|
||||
tag = T5577Tag()
|
||||
tag.blocks[1] = 0xDEADBEEF
|
||||
run(medium.attach(tag))
|
||||
reader = T5577Reader(medium)
|
||||
|
||||
result = run(reader.read_block(1))
|
||||
assert result["data"] == 0xDEADBEEF
|
||||
|
||||
def test_reader_write_block(self):
|
||||
medium = SoftwareMedium()
|
||||
tag = T5577Tag()
|
||||
run(medium.attach(tag))
|
||||
reader = T5577Reader(medium)
|
||||
|
||||
run(reader.write_block(1, 0xCAFEBABE))
|
||||
result = run(reader.read_block(1))
|
||||
assert result["data"] == 0xCAFEBABE
|
||||
251
tests/test_sim_mcu_bridge.py
Normal file
251
tests/test_sim_mcu_bridge.py
Normal file
@@ -0,0 +1,251 @@
|
||||
import pytest
|
||||
from pm3py.sim.mcu_protocol import (
|
||||
cobs_encode, cobs_decode,
|
||||
MsgType, build_frame, parse_frame,
|
||||
StreamDeframer,
|
||||
)
|
||||
|
||||
|
||||
class TestCobsCodec:
|
||||
def test_encode_empty(self):
|
||||
assert cobs_encode(b"") == b"\x01"
|
||||
|
||||
def test_encode_no_zeros(self):
|
||||
assert cobs_encode(b"\x01\x02\x03") == b"\x04\x01\x02\x03"
|
||||
|
||||
def test_encode_single_zero(self):
|
||||
assert cobs_encode(b"\x00") == b"\x01\x01"
|
||||
|
||||
def test_encode_zeros_in_data(self):
|
||||
assert cobs_encode(b"\x01\x00\x02") == b"\x02\x01\x02\x02"
|
||||
|
||||
def test_decode_roundtrip(self):
|
||||
for data in [b"", b"\x00", b"\x01\x02\x03", b"\x00\x00\x00",
|
||||
b"hello world", bytes(range(256))]:
|
||||
assert cobs_decode(cobs_encode(data)) == data
|
||||
|
||||
def test_decode_empty_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
cobs_decode(b"")
|
||||
|
||||
def test_decode_invalid_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
cobs_decode(b"\xFF")
|
||||
|
||||
|
||||
class TestMessageFraming:
|
||||
def test_build_frame_no_payload(self):
|
||||
frame = build_frame(MsgType.RESET)
|
||||
assert frame[-1] == 0x00
|
||||
decoded = cobs_decode(frame[:-1])
|
||||
assert decoded == bytes([0xF0])
|
||||
|
||||
def test_build_frame_with_payload(self):
|
||||
frame = build_frame(MsgType.SET_ED, bytes([0x01]))
|
||||
decoded = cobs_decode(frame[:-1])
|
||||
assert decoded == bytes([0x20, 0x01])
|
||||
|
||||
def test_parse_frame_i2c_write(self):
|
||||
raw = bytes([MsgType.I2C_WRITE, 0x20, 0x00, 0xDE, 0xAD])
|
||||
msg_type, payload = parse_frame(raw)
|
||||
assert msg_type == MsgType.I2C_WRITE
|
||||
assert payload == bytes([0x20, 0x00, 0xDE, 0xAD])
|
||||
|
||||
def test_parse_frame_mcu_ready(self):
|
||||
raw = bytes([MsgType.MCU_READY, 0x01, 0x03])
|
||||
msg_type, payload = parse_frame(raw)
|
||||
assert msg_type == MsgType.MCU_READY
|
||||
assert payload == bytes([0x01, 0x03])
|
||||
|
||||
def test_parse_frame_empty_payload(self):
|
||||
raw = bytes([MsgType.I2C_STOP])
|
||||
msg_type, payload = parse_frame(raw)
|
||||
assert msg_type == MsgType.I2C_STOP
|
||||
assert payload == b""
|
||||
|
||||
def test_parse_frame_too_short(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_frame(b"")
|
||||
|
||||
|
||||
class TestStreamDeframer:
|
||||
def test_single_complete_frame(self):
|
||||
df = StreamDeframer()
|
||||
frame = build_frame(MsgType.MCU_READY, bytes([0x01, 0x00]))
|
||||
msgs = df.feed(frame)
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0][0] == MsgType.MCU_READY
|
||||
assert msgs[0][1] == bytes([0x01, 0x00])
|
||||
|
||||
def test_partial_then_complete(self):
|
||||
df = StreamDeframer()
|
||||
frame = build_frame(MsgType.I2C_WRITE, bytes([0x20, 0x00, 0xAB]))
|
||||
mid = len(frame) // 2
|
||||
msgs1 = df.feed(frame[:mid])
|
||||
assert len(msgs1) == 0
|
||||
msgs2 = df.feed(frame[mid:])
|
||||
assert len(msgs2) == 1
|
||||
assert msgs2[0][0] == MsgType.I2C_WRITE
|
||||
|
||||
def test_multiple_frames_in_one_chunk(self):
|
||||
df = StreamDeframer()
|
||||
f1 = build_frame(MsgType.I2C_WRITE, bytes([0x20, 0x00]))
|
||||
f2 = build_frame(MsgType.I2C_STOP)
|
||||
msgs = df.feed(f1 + f2)
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0][0] == MsgType.I2C_WRITE
|
||||
assert msgs[1][0] == MsgType.I2C_STOP
|
||||
|
||||
def test_corrupted_frame_skipped(self):
|
||||
df = StreamDeframer()
|
||||
garbage = bytes([0xFF, 0xFE, 0x00])
|
||||
valid = build_frame(MsgType.MCU_READY, bytes([0x01, 0x00]))
|
||||
msgs = df.feed(garbage + valid)
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0][0] == MsgType.MCU_READY
|
||||
|
||||
def test_empty_feed(self):
|
||||
df = StreamDeframer()
|
||||
msgs = df.feed(b"")
|
||||
assert len(msgs) == 0
|
||||
|
||||
|
||||
import time
|
||||
from pm3py.sim.mcu_bridge import McuBridge
|
||||
|
||||
|
||||
class MockSerial:
|
||||
"""Mock serial port for testing McuBridge."""
|
||||
def __init__(self):
|
||||
self._rx_buf = bytearray()
|
||||
self._tx_buf = bytearray()
|
||||
self.is_open = True
|
||||
self.timeout = 0.1
|
||||
|
||||
def read(self, size=1):
|
||||
if not self._rx_buf:
|
||||
time.sleep(0.01)
|
||||
return b""
|
||||
data = bytes(self._rx_buf[:size])
|
||||
self._rx_buf = self._rx_buf[size:]
|
||||
return data
|
||||
|
||||
def write(self, data):
|
||||
self._tx_buf.extend(data)
|
||||
return len(data)
|
||||
|
||||
def inject(self, data: bytes):
|
||||
self._rx_buf.extend(data)
|
||||
|
||||
def get_sent(self) -> bytes:
|
||||
data = bytes(self._tx_buf)
|
||||
self._tx_buf.clear()
|
||||
return data
|
||||
|
||||
def close(self):
|
||||
self.is_open = False
|
||||
|
||||
|
||||
class TestMcuBridge:
|
||||
def test_connect_receives_mcu_ready(self):
|
||||
mock = MockSerial()
|
||||
bridge = McuBridge(port=mock)
|
||||
ready_events = []
|
||||
bridge.on_ready = lambda ver, caps: ready_events.append((ver, caps))
|
||||
bridge.start()
|
||||
mock.inject(build_frame(MsgType.MCU_READY, bytes([0x01, 0x03])))
|
||||
time.sleep(0.15)
|
||||
bridge.stop()
|
||||
assert len(ready_events) == 1
|
||||
assert ready_events[0] == (0x01, 0x03)
|
||||
|
||||
def test_i2c_write_callback(self):
|
||||
mock = MockSerial()
|
||||
bridge = McuBridge(port=mock)
|
||||
writes = []
|
||||
bridge.on_i2c_write = lambda addr, data: writes.append((addr, data))
|
||||
bridge.start()
|
||||
mock.inject(build_frame(MsgType.I2C_WRITE, bytes([0x20, 0x00, 0xDE, 0xAD])))
|
||||
time.sleep(0.15)
|
||||
bridge.stop()
|
||||
assert len(writes) == 1
|
||||
assert writes[0] == (0x2000, bytes([0xDE, 0xAD]))
|
||||
|
||||
def test_i2c_read_req_callback(self):
|
||||
mock = MockSerial()
|
||||
bridge = McuBridge(port=mock)
|
||||
reads = []
|
||||
bridge.on_i2c_read_req = lambda addr, length: reads.append((addr, length))
|
||||
bridge.start()
|
||||
mock.inject(build_frame(MsgType.I2C_READ_REQ, bytes([0x20, 0x00, 0x04])))
|
||||
time.sleep(0.15)
|
||||
bridge.stop()
|
||||
assert len(reads) == 1
|
||||
assert reads[0] == (0x2000, 4)
|
||||
|
||||
def test_send_set_ed(self):
|
||||
mock = MockSerial()
|
||||
bridge = McuBridge(port=mock)
|
||||
bridge.start()
|
||||
bridge.send_set_ed(True)
|
||||
time.sleep(0.05)
|
||||
bridge.stop()
|
||||
sent = mock.get_sent()
|
||||
assert len(sent) > 0
|
||||
assert sent[-1] == 0x00
|
||||
msg_type, payload = parse_frame(cobs_decode(sent[:-1]))
|
||||
assert msg_type == MsgType.SET_ED
|
||||
assert payload == bytes([0x01])
|
||||
|
||||
def test_send_set_eh_voltage(self):
|
||||
mock = MockSerial()
|
||||
bridge = McuBridge(port=mock)
|
||||
bridge.start()
|
||||
bridge.send_set_eh_voltage(2400)
|
||||
time.sleep(0.05)
|
||||
bridge.stop()
|
||||
sent = mock.get_sent()
|
||||
assert sent[-1] == 0x00
|
||||
msg_type, payload = parse_frame(cobs_decode(sent[:-1]))
|
||||
assert msg_type == MsgType.SET_EH_VOLTAGE
|
||||
assert payload == bytes([0x09, 0x60])
|
||||
|
||||
def test_send_write_sram(self):
|
||||
mock = MockSerial()
|
||||
bridge = McuBridge(port=mock)
|
||||
bridge.start()
|
||||
bridge.send_write_sram(0x10, bytes([0xAA, 0xBB, 0xCC]))
|
||||
time.sleep(0.05)
|
||||
bridge.stop()
|
||||
sent = mock.get_sent()
|
||||
assert sent[-1] == 0x00
|
||||
msg_type, payload = parse_frame(cobs_decode(sent[:-1]))
|
||||
assert msg_type == MsgType.WRITE_SRAM
|
||||
assert payload == bytes([0x10, 0xAA, 0xBB, 0xCC])
|
||||
|
||||
def test_send_i2c_read_response(self):
|
||||
mock = MockSerial()
|
||||
bridge = McuBridge(port=mock)
|
||||
bridge.start()
|
||||
bridge.send_i2c_read_response(bytes([0x01, 0x02, 0x03, 0x04]))
|
||||
time.sleep(0.05)
|
||||
bridge.stop()
|
||||
sent = mock.get_sent()
|
||||
assert sent[-1] == 0x00
|
||||
msg_type, payload = parse_frame(cobs_decode(sent[:-1]))
|
||||
assert msg_type == MsgType.I2C_READ_RESPONSE
|
||||
assert payload == bytes([0x01, 0x02, 0x03, 0x04])
|
||||
|
||||
def test_stop_is_idempotent(self):
|
||||
mock = MockSerial()
|
||||
bridge = McuBridge(port=mock)
|
||||
bridge.start()
|
||||
bridge.stop()
|
||||
bridge.stop() # should not raise
|
||||
|
||||
|
||||
class TestMcuBridgeOpen:
|
||||
def test_open_creates_bridge(self):
|
||||
from pm3py.sim import McuBridge as ImportedBridge
|
||||
assert ImportedBridge is McuBridge
|
||||
assert hasattr(McuBridge, 'open')
|
||||
227
tests/test_sim_medium.py
Normal file
227
tests/test_sim_medium.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Tests for pm3py.sim.medium — Medium ABC and SoftwareMedium."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.medium import SoftwareMedium
|
||||
from pm3py.sim.transponder import Transponder
|
||||
|
||||
|
||||
class StubTransponder(Transponder):
|
||||
"""Minimal transponder that echoes back a fixed response."""
|
||||
|
||||
def __init__(self, response: RFFrame | None = None):
|
||||
super().__init__()
|
||||
self._response = response
|
||||
self._state = "IDLE"
|
||||
self._powered = False
|
||||
self._received: list[RFFrame] = []
|
||||
|
||||
async def power_on(self) -> None:
|
||||
self._powered = True
|
||||
self._state = "READY"
|
||||
|
||||
async def power_off(self) -> None:
|
||||
self._powered = False
|
||||
self._state = "OFF"
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
self._received.append(frame)
|
||||
return self._response
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
return self._state
|
||||
|
||||
|
||||
class TestSoftwareMediumAttachDetach:
|
||||
"""Test transponder attachment and detachment."""
|
||||
|
||||
def test_attach_returns_unique_ids(self):
|
||||
medium = SoftwareMedium()
|
||||
t1 = StubTransponder()
|
||||
t2 = StubTransponder()
|
||||
id1 = asyncio.get_event_loop().run_until_complete(medium.attach(t1))
|
||||
id2 = asyncio.get_event_loop().run_until_complete(medium.attach(t2))
|
||||
assert id1 != id2
|
||||
|
||||
def test_attach_powers_on_transponder(self):
|
||||
medium = SoftwareMedium()
|
||||
t = StubTransponder()
|
||||
assert not t._powered
|
||||
asyncio.get_event_loop().run_until_complete(medium.attach(t))
|
||||
assert t._powered
|
||||
|
||||
def test_detach_powers_off_transponder(self):
|
||||
medium = SoftwareMedium()
|
||||
t = StubTransponder()
|
||||
loop = asyncio.get_event_loop()
|
||||
tid = loop.run_until_complete(medium.attach(t))
|
||||
loop.run_until_complete(medium.detach(tid))
|
||||
assert not t._powered
|
||||
|
||||
def test_detach_nonexistent_id_raises(self):
|
||||
medium = SoftwareMedium()
|
||||
with pytest.raises(KeyError):
|
||||
asyncio.get_event_loop().run_until_complete(medium.detach(999))
|
||||
|
||||
|
||||
class TestSoftwareMediumSingleTransponder:
|
||||
"""Test basic transmit/receive with one transponder."""
|
||||
|
||||
def test_reader_transmit_delivers_frame_to_transponder(self):
|
||||
medium = SoftwareMedium()
|
||||
t = StubTransponder(response=RFFrame.from_hex("AABB"))
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(medium.attach(t))
|
||||
|
||||
cmd = RFFrame.from_hex("26") # REQA
|
||||
loop.run_until_complete(medium.transmit_reader(cmd))
|
||||
|
||||
assert len(t._received) == 1
|
||||
assert t._received[0].data == b"\x26"
|
||||
|
||||
def test_reader_receives_transponder_response(self):
|
||||
medium = SoftwareMedium()
|
||||
response = RFFrame.from_hex("4400")
|
||||
t = StubTransponder(response=response)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(medium.attach(t))
|
||||
|
||||
cmd = RFFrame.from_hex("26")
|
||||
loop.run_until_complete(medium.transmit_reader(cmd))
|
||||
result = loop.run_until_complete(medium.receive_reader())
|
||||
|
||||
assert result is not None
|
||||
assert result.data == b"\x44\x00"
|
||||
|
||||
def test_no_transponders_returns_none(self):
|
||||
medium = SoftwareMedium()
|
||||
loop = asyncio.get_event_loop()
|
||||
cmd = RFFrame.from_hex("26")
|
||||
loop.run_until_complete(medium.transmit_reader(cmd))
|
||||
result = loop.run_until_complete(medium.receive_reader())
|
||||
assert result is None
|
||||
|
||||
def test_transponder_returning_none_means_no_response(self):
|
||||
medium = SoftwareMedium()
|
||||
t = StubTransponder(response=None) # stays quiet
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(medium.attach(t))
|
||||
|
||||
cmd = RFFrame.from_hex("26")
|
||||
loop.run_until_complete(medium.transmit_reader(cmd))
|
||||
result = loop.run_until_complete(medium.receive_reader())
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestSoftwareMediumCollision:
|
||||
"""Test multi-transponder collision detection."""
|
||||
|
||||
def test_two_identical_responses_no_collision(self):
|
||||
medium = SoftwareMedium()
|
||||
t1 = StubTransponder(response=RFFrame.from_hex("A5"))
|
||||
t2 = StubTransponder(response=RFFrame.from_hex("A5"))
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(medium.attach(t1))
|
||||
loop.run_until_complete(medium.attach(t2))
|
||||
|
||||
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
|
||||
result = loop.run_until_complete(medium.receive_reader())
|
||||
|
||||
assert result is not None
|
||||
assert result.data == b"\xA5"
|
||||
assert not result.has_collision
|
||||
|
||||
def test_two_different_responses_detect_collision(self):
|
||||
medium = SoftwareMedium()
|
||||
t1 = StubTransponder(response=RFFrame.from_hex("A0")) # 10100000
|
||||
t2 = StubTransponder(response=RFFrame.from_hex("A5")) # 10100101
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(medium.attach(t1))
|
||||
loop.run_until_complete(medium.attach(t2))
|
||||
|
||||
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
|
||||
result = loop.run_until_complete(medium.receive_reader())
|
||||
|
||||
assert result is not None
|
||||
assert result.has_collision
|
||||
assert len(result.collision_positions) == 2
|
||||
|
||||
def test_three_transponders_one_quiet(self):
|
||||
"""One transponder stays quiet — only two respond."""
|
||||
medium = SoftwareMedium()
|
||||
t1 = StubTransponder(response=RFFrame.from_hex("AB"))
|
||||
t2 = StubTransponder(response=None) # quiet
|
||||
t3 = StubTransponder(response=RFFrame.from_hex("AB"))
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(medium.attach(t1))
|
||||
loop.run_until_complete(medium.attach(t2))
|
||||
loop.run_until_complete(medium.attach(t3))
|
||||
|
||||
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
|
||||
result = loop.run_until_complete(medium.receive_reader())
|
||||
|
||||
assert result is not None
|
||||
assert result.data == b"\xAB"
|
||||
assert not result.has_collision
|
||||
|
||||
def test_collision_bits_default_to_one(self):
|
||||
medium = SoftwareMedium()
|
||||
t1 = StubTransponder(response=RFFrame.from_bytes(b"\x00"))
|
||||
t2 = StubTransponder(response=RFFrame.from_bytes(b"\xFF"))
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(medium.attach(t1))
|
||||
loop.run_until_complete(medium.attach(t2))
|
||||
|
||||
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
|
||||
result = loop.run_until_complete(medium.receive_reader())
|
||||
|
||||
# All 8 bits collide, all should be 1
|
||||
assert result.data == b"\xFF"
|
||||
|
||||
|
||||
class TestSoftwareMediumDynamicInjection:
|
||||
"""Test attach/detach mid-protocol."""
|
||||
|
||||
def test_detach_mid_protocol(self):
|
||||
medium = SoftwareMedium()
|
||||
t1 = StubTransponder(response=RFFrame.from_hex("AA"))
|
||||
t2 = StubTransponder(response=RFFrame.from_hex("BB"))
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(medium.attach(t1))
|
||||
tid2 = loop.run_until_complete(medium.attach(t2))
|
||||
|
||||
# First round: collision
|
||||
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
|
||||
r1 = loop.run_until_complete(medium.receive_reader())
|
||||
assert r1.has_collision
|
||||
|
||||
# Detach t2
|
||||
loop.run_until_complete(medium.detach(tid2))
|
||||
|
||||
# Second round: no collision
|
||||
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
|
||||
r2 = loop.run_until_complete(medium.receive_reader())
|
||||
assert not r2.has_collision
|
||||
assert r2.data == b"\xAA"
|
||||
|
||||
def test_attach_mid_protocol(self):
|
||||
medium = SoftwareMedium()
|
||||
t1 = StubTransponder(response=RFFrame.from_hex("AA"))
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(medium.attach(t1))
|
||||
|
||||
# First round: single tag
|
||||
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
|
||||
r1 = loop.run_until_complete(medium.receive_reader())
|
||||
assert not r1.has_collision
|
||||
|
||||
# Inject new tag
|
||||
t2 = StubTransponder(response=RFFrame.from_hex("BB"))
|
||||
loop.run_until_complete(medium.attach(t2))
|
||||
|
||||
# Second round: collision
|
||||
loop.run_until_complete(medium.transmit_reader(RFFrame.from_hex("26")))
|
||||
r2 = loop.run_until_complete(medium.receive_reader())
|
||||
assert r2.has_collision
|
||||
210
tests/test_sim_memory.py
Normal file
210
tests/test_sim_memory.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Tests for DirtyByteArray, MemoryRegion, BlockAccess, Transponder regions."""
|
||||
import pytest
|
||||
from pm3py.sim.memory import DirtyByteArray, MemoryRegion, BlockAccess
|
||||
from pm3py.sim.transponder import Transponder
|
||||
from pm3py.sim.frame import RFFrame
|
||||
|
||||
|
||||
class TestDirtyByteArray:
|
||||
def test_starts_clean(self):
|
||||
d = DirtyByteArray(16)
|
||||
assert not d.dirty
|
||||
|
||||
def test_setitem_marks_dirty(self):
|
||||
d = DirtyByteArray(16)
|
||||
d[0] = 0xFF
|
||||
assert d.dirty
|
||||
|
||||
def test_slice_setitem_marks_dirty(self):
|
||||
d = DirtyByteArray(16)
|
||||
d[0:4] = b"\xDE\xAD\xBE\xEF"
|
||||
assert d.dirty
|
||||
|
||||
def test_clear_dirty(self):
|
||||
d = DirtyByteArray(16)
|
||||
d[0] = 0xFF
|
||||
d.clear_dirty()
|
||||
assert not d.dirty
|
||||
|
||||
def test_extend_marks_dirty(self):
|
||||
d = DirtyByteArray(b"\x00")
|
||||
d.extend(b"\xFF")
|
||||
assert d.dirty
|
||||
|
||||
def test_from_bytes(self):
|
||||
d = DirtyByteArray(b"\x01\x02\x03")
|
||||
assert len(d) == 3
|
||||
assert not d.dirty
|
||||
|
||||
def test_callback_on_dirty(self):
|
||||
called = []
|
||||
d = DirtyByteArray(16)
|
||||
d.on_dirty = lambda: called.append(True)
|
||||
d[0] = 0xFF
|
||||
assert len(called) == 1
|
||||
|
||||
|
||||
class TestBlockAccess:
|
||||
def test_defaults_open(self):
|
||||
a = BlockAccess()
|
||||
assert a.read == "open"
|
||||
assert a.write == "open"
|
||||
assert a.read_key is None
|
||||
|
||||
def test_password_protected(self):
|
||||
a = BlockAccess(read="password", read_key=1)
|
||||
assert a.read == "password"
|
||||
assert a.read_key == 1
|
||||
|
||||
|
||||
class TestMemoryRegion:
|
||||
def test_construction(self):
|
||||
r = MemoryRegion(name="user", data=DirtyByteArray(112), block_size=4)
|
||||
assert r.name == "user"
|
||||
assert len(r.data) == 112
|
||||
assert r.block_size == 4
|
||||
|
||||
def test_eml_offset_default_negative(self):
|
||||
r = MemoryRegion(name="config", data=DirtyByteArray(8))
|
||||
assert r.eml_offset == -1
|
||||
|
||||
def test_num_blocks(self):
|
||||
r = MemoryRegion(name="user", data=DirtyByteArray(112), block_size=4)
|
||||
assert r.num_blocks == 28
|
||||
|
||||
def test_access_for_block_default(self):
|
||||
r = MemoryRegion(name="user", data=DirtyByteArray(16), block_size=4)
|
||||
assert r.access_for_block(0).read == "open"
|
||||
|
||||
def test_access_for_block_with_map(self):
|
||||
r = MemoryRegion(name="user", data=DirtyByteArray(16), block_size=4,
|
||||
access_map=[
|
||||
BlockAccess(),
|
||||
BlockAccess(),
|
||||
BlockAccess(read="password", read_key=1),
|
||||
BlockAccess(read="password", read_key=1),
|
||||
])
|
||||
assert r.access_for_block(0).read == "open"
|
||||
assert r.access_for_block(2).read == "password"
|
||||
|
||||
def test_dirty_propagates_from_data(self):
|
||||
r = MemoryRegion(name="user", data=DirtyByteArray(16), block_size=4)
|
||||
assert not r.data.dirty
|
||||
r.data[0:4] = b"\xDE\xAD\xBE\xEF"
|
||||
assert r.data.dirty
|
||||
|
||||
|
||||
class StubTransponder(Transponder):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.regions["user"] = MemoryRegion(
|
||||
name="user", data=DirtyByteArray(16), block_size=4, eml_offset=100)
|
||||
self.regions["config"] = MemoryRegion(
|
||||
name="config", data=DirtyByteArray(8), block_size=0, eml_offset=-1)
|
||||
|
||||
async def power_on(self): pass
|
||||
async def power_off(self): pass
|
||||
async def handle_frame(self, frame): return None
|
||||
@property
|
||||
def state(self): return "TEST"
|
||||
|
||||
|
||||
class TestTransponderRegions:
|
||||
def test_has_regions_dict(self):
|
||||
t = StubTransponder()
|
||||
assert "user" in t.regions
|
||||
assert "config" in t.regions
|
||||
|
||||
def test_authenticate(self):
|
||||
t = StubTransponder()
|
||||
assert len(t._authenticated) == 0
|
||||
t.authenticate(1)
|
||||
assert 1 in t._authenticated
|
||||
assert t._access_dirty
|
||||
|
||||
def test_deauthenticate(self):
|
||||
t = StubTransponder()
|
||||
t.authenticate(1)
|
||||
t._access_dirty = False
|
||||
t.deauthenticate(1)
|
||||
assert 1 not in t._authenticated
|
||||
assert t._access_dirty
|
||||
|
||||
def test_deauthenticate_all(self):
|
||||
t = StubTransponder()
|
||||
t.authenticate(1)
|
||||
t.authenticate(2)
|
||||
t.deauthenticate()
|
||||
assert len(t._authenticated) == 0
|
||||
|
||||
|
||||
class TestTag15693Regions:
|
||||
def test_has_user_region(self):
|
||||
from pm3py.sim.iso15693 import Tag15693
|
||||
tag = Tag15693(uid=bytes(8), block_size=4, num_blocks=28)
|
||||
assert "user" in tag.regions
|
||||
assert tag.regions["user"].block_size == 4
|
||||
assert tag.regions["user"].eml_offset == 175
|
||||
|
||||
def test_memory_property_aliases_region(self):
|
||||
from pm3py.sim.iso15693 import Tag15693
|
||||
tag = Tag15693(uid=bytes(8), block_size=4, num_blocks=28)
|
||||
tag._memory[0:4] = b"\xDE\xAD\xBE\xEF"
|
||||
assert tag.regions["user"].data[0:4] == b"\xDE\xAD\xBE\xEF"
|
||||
assert tag.regions["user"].data.dirty
|
||||
|
||||
|
||||
class TestIcodeSlix2AccessMap:
|
||||
def test_protection_pointer_sets_access_map(self):
|
||||
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
|
||||
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), protection_pointer=4,
|
||||
read_password=0xAABBCCDD)
|
||||
user = tag.regions["user"]
|
||||
assert user.access_for_block(0).read == "open"
|
||||
assert user.access_for_block(3).read == "open"
|
||||
assert user.access_for_block(4).read == "password"
|
||||
assert user.access_for_block(4).read_key == 1
|
||||
|
||||
def test_no_protection_pointer_all_open(self):
|
||||
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
|
||||
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
||||
user = tag.regions["user"]
|
||||
assert user.access_for_block(0).read == "open"
|
||||
# access_map exists for block 79 counter, but all reads are open
|
||||
assert user.access_for_block(78).read == "open"
|
||||
assert user.access_for_block(79).write_mode == "counter"
|
||||
|
||||
|
||||
class TestCompileFromRegions:
|
||||
def test_open_blocks_have_data_entries(self):
|
||||
from pm3py.sim.iso15693 import Tag15693
|
||||
from pm3py.sim.table_compiler import TableCompiler
|
||||
tag = Tag15693(uid=bytes(8), block_size=4, num_blocks=4)
|
||||
tag._memory[0:4] = b"\xDE\xAD\xBE\xEF"
|
||||
table = TableCompiler.compile_from_regions(tag)
|
||||
entry = table.lookup(bytes([0x02, 0x20, 0x00]))
|
||||
assert entry is not None
|
||||
assert entry.response[1:5] == b"\xDE\xAD\xBE\xEF"
|
||||
|
||||
def test_protected_blocks_without_auth(self):
|
||||
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
|
||||
from pm3py.sim.table_compiler import TableCompiler
|
||||
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), protection_pointer=2,
|
||||
read_password=0xAABBCCDD, block_size=4, num_blocks=4)
|
||||
table = TableCompiler.compile_from_regions(tag)
|
||||
# Block 0 (open) should have entry
|
||||
assert table.lookup(bytes([0x02, 0x20, 0x00])) is not None
|
||||
# Block 2 (protected) should have no entry (relay)
|
||||
assert table.lookup(bytes([0x02, 0x20, 0x02])) is None
|
||||
|
||||
def test_protected_blocks_with_auth(self):
|
||||
from pm3py.sim.icode_slix2 import IcodeSlix2Tag, PWD_READ
|
||||
from pm3py.sim.table_compiler import TableCompiler
|
||||
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), protection_pointer=2,
|
||||
read_password=0xAABBCCDD, block_size=4, num_blocks=4)
|
||||
tag.authenticate(PWD_READ)
|
||||
table = TableCompiler.compile_from_regions(tag)
|
||||
# Block 2 now readable
|
||||
entry = table.lookup(bytes([0x02, 0x20, 0x02]))
|
||||
assert entry is not None
|
||||
assert entry.response[0] == 0x00 # no error
|
||||
313
tests/test_sim_mifare.py
Normal file
313
tests/test_sim_mifare.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""Tests for pm3py.sim.crypto1 and pm3py.sim.mifare — Crypto-1 cipher and MIFARE Classic."""
|
||||
import asyncio
|
||||
import struct
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.medium import SoftwareMedium
|
||||
from pm3py.sim.crypto1 import Crypto1
|
||||
from pm3py.sim.mifare import MifareClassicTag, MifareClassicReader
|
||||
from pm3py.sim.iso14443a import Reader14443A, REQA, CL1, NVB_SELECT, _compute_bcc
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Crypto-1 LFSR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCrypto1LFSR:
|
||||
"""Test Crypto-1 cipher against known test vectors."""
|
||||
|
||||
def test_key_load(self):
|
||||
"""Verify LFSR state after loading a key."""
|
||||
c = Crypto1(b"\xFF\xFF\xFF\xFF\xFF\xFF")
|
||||
# After loading all-ones key, LFSR should be non-zero
|
||||
assert c._lfsr != 0
|
||||
|
||||
def test_generate_keystream_is_deterministic(self):
|
||||
"""Same key produces same keystream."""
|
||||
c1 = Crypto1(b"\xA0\xA1\xA2\xA3\xA4\xA5")
|
||||
c2 = Crypto1(b"\xA0\xA1\xA2\xA3\xA4\xA5")
|
||||
ks1 = [c1.generate_bit() for _ in range(32)]
|
||||
ks2 = [c2.generate_bit() for _ in range(32)]
|
||||
assert ks1 == ks2
|
||||
|
||||
def test_different_keys_different_keystream(self):
|
||||
c1 = Crypto1(b"\x00\x00\x00\x00\x00\x00")
|
||||
c2 = Crypto1(b"\xFF\xFF\xFF\xFF\xFF\xFF")
|
||||
ks1 = [c1.generate_bit() for _ in range(32)]
|
||||
ks2 = [c2.generate_bit() for _ in range(32)]
|
||||
assert ks1 != ks2
|
||||
|
||||
def test_encrypt_decrypt_roundtrip(self):
|
||||
"""Encrypting then decrypting with same state should return original."""
|
||||
key = b"\xA0\xA1\xA2\xA3\xA4\xA5"
|
||||
plaintext = b"\xDE\xAD\xBE\xEF"
|
||||
|
||||
c_enc = Crypto1(key)
|
||||
encrypted = c_enc.encrypt_bytes(plaintext)
|
||||
|
||||
c_dec = Crypto1(key)
|
||||
decrypted = c_dec.encrypt_bytes(encrypted) # XOR cipher: enc == dec
|
||||
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_known_vector_lfsr_feedback(self):
|
||||
"""Verify the LFSR feedback polynomial is correct.
|
||||
|
||||
The Crypto-1 LFSR polynomial is:
|
||||
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
|
||||
"""
|
||||
c = Crypto1(b"\x00\x00\x00\x00\x00\x01")
|
||||
# Just verify it doesn't crash and produces deterministic output
|
||||
bits = [c.generate_bit() for _ in range(48)]
|
||||
assert len(bits) == 48
|
||||
|
||||
|
||||
class TestCrypto1Auth:
|
||||
"""Test Crypto-1 mutual authentication protocol."""
|
||||
|
||||
def test_tag_nonce_generation(self):
|
||||
"""Tag generates a 4-byte nonce."""
|
||||
c = Crypto1(b"\xFF\xFF\xFF\xFF\xFF\xFF")
|
||||
nt = c.generate_nonce()
|
||||
assert len(nt) == 4
|
||||
|
||||
def test_auth_mutual_success(self):
|
||||
"""Full mutual authentication between tag and reader Crypto-1 instances."""
|
||||
key = b"\xFF\xFF\xFF\xFF\xFF\xFF"
|
||||
uid = b"\x01\x02\x03\x04"
|
||||
|
||||
# Tag side: generate nonce
|
||||
tag_crypto = Crypto1(key)
|
||||
nt = tag_crypto.generate_nonce()
|
||||
|
||||
# Initialize both sides with uid ^ nt
|
||||
uid_int = struct.unpack(">I", uid)[0]
|
||||
nt_int = struct.unpack(">I", nt)[0]
|
||||
|
||||
tag_crypto = Crypto1(key)
|
||||
tag_crypto.init_auth(uid_int, nt_int)
|
||||
|
||||
reader_crypto = Crypto1(key)
|
||||
reader_crypto.init_auth(uid_int, nt_int)
|
||||
|
||||
# Reader generates nr (random) and computes ar = suc(nt, 64)
|
||||
nr = b"\xAB\xCD\xEF\x01"
|
||||
|
||||
# Encrypt nr with reader's keystream
|
||||
nr_enc = reader_crypto.encrypt_bytes(nr)
|
||||
# Reader computes ar (encrypted successor of nt)
|
||||
ar = reader_crypto.encrypt_bytes(struct.pack(">I", _suc(nt_int, 64)))
|
||||
|
||||
# Tag decrypts nr
|
||||
nr_dec = tag_crypto.encrypt_bytes(nr_enc)
|
||||
assert nr_dec == nr
|
||||
|
||||
# Tag decrypts ar and verifies
|
||||
ar_dec_int = struct.unpack(">I", tag_crypto.encrypt_bytes(ar))[0]
|
||||
assert ar_dec_int == _suc(nt_int, 64)
|
||||
|
||||
# Tag sends at (encrypted successor of nt, 96)
|
||||
at = tag_crypto.encrypt_bytes(struct.pack(">I", _suc(nt_int, 96)))
|
||||
|
||||
# Reader verifies at
|
||||
at_dec_int = struct.unpack(">I", reader_crypto.encrypt_bytes(at))[0]
|
||||
assert at_dec_int == _suc(nt_int, 96)
|
||||
|
||||
|
||||
def _suc(nt: int, n: int) -> int:
|
||||
"""Compute successor of nt by n LFSR clocks (simplified for test)."""
|
||||
# In real Crypto-1, suc is the LFSR state after n clocks
|
||||
# For testing, we use a simple PRNG-like computation
|
||||
val = nt
|
||||
for _ in range(n):
|
||||
bit = ((val >> 31) ^ (val >> 20) ^ (val >> 15) ^ (val >> 0)) & 1
|
||||
val = ((val << 1) | bit) & 0xFFFFFFFF
|
||||
return val
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MifareClassicTag — transponder model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMifareClassicTagBasics:
|
||||
"""Test MifareClassicTag construction and 14443-A compliance."""
|
||||
|
||||
def test_1k_atqa_sak(self):
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
|
||||
run(tag.power_on())
|
||||
resp = run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
assert resp.data == b"\x04\x00" # ATQA for 1K
|
||||
|
||||
# SELECT
|
||||
uid = b"\x01\x02\x03\x04"
|
||||
bcc = _compute_bcc(uid)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
|
||||
assert resp.data[0] == 0x08 # SAK for 1K
|
||||
|
||||
def test_4k_atqa_sak(self):
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="4k")
|
||||
run(tag.power_on())
|
||||
resp = run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
assert resp.data == b"\x02\x00" # ATQA for 4K
|
||||
|
||||
uid = b"\x01\x02\x03\x04"
|
||||
bcc = _compute_bcc(uid)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
|
||||
assert resp.data[0] == 0x18 # SAK for 4K
|
||||
|
||||
def test_rejects_rats(self):
|
||||
"""MIFARE Classic is Part 3 only."""
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
|
||||
run(tag.power_on())
|
||||
run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
uid = b"\x01\x02\x03\x04"
|
||||
bcc = _compute_bcc(uid)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
|
||||
|
||||
resp = run(tag.handle_frame(RFFrame.from_hex("E050")))
|
||||
assert resp is None
|
||||
|
||||
def test_default_memory_all_zeros(self):
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
|
||||
assert len(tag._data) == 1024 # 64 blocks * 16 bytes
|
||||
assert tag._data[:16] == b"\x00" * 16
|
||||
|
||||
def test_4k_memory_size(self):
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="4k")
|
||||
assert len(tag._data) == 4096 # 256 blocks * 16 bytes
|
||||
|
||||
def test_default_keys_are_ff(self):
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04")
|
||||
for sector in range(16):
|
||||
assert tag._keys_a[sector] == b"\xFF" * 6
|
||||
assert tag._keys_b[sector] == b"\xFF" * 6
|
||||
|
||||
|
||||
class TestMifareClassicTagAuth:
|
||||
"""Test MIFARE Classic authentication."""
|
||||
|
||||
def test_auth_command_returns_tag_nonce(self):
|
||||
"""AUTH command (0x60/0x61) should return a 4-byte encrypted tag nonce."""
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
|
||||
run(tag.power_on())
|
||||
run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
uid = b"\x01\x02\x03\x04"
|
||||
bcc = _compute_bcc(uid)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
|
||||
|
||||
# AUTH_A for block 0
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x60\x00")))
|
||||
assert resp is not None
|
||||
assert len(resp.data) == 4 # tag nonce (nt)
|
||||
|
||||
def test_unauthenticated_read_rejected(self):
|
||||
"""READ without authentication should be rejected."""
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
|
||||
run(tag.power_on())
|
||||
run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
uid = b"\x01\x02\x03\x04"
|
||||
bcc = _compute_bcc(uid)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
|
||||
|
||||
# READ block 0 without auth
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x00")))
|
||||
assert resp is None # rejected
|
||||
|
||||
|
||||
class TestMifareClassicTagMemory:
|
||||
"""Test MIFARE Classic memory layout."""
|
||||
|
||||
def test_1k_sector_block_mapping(self):
|
||||
"""1K: sectors 0-15, 4 blocks each."""
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
|
||||
assert tag.sector_for_block(0) == 0
|
||||
assert tag.sector_for_block(3) == 0
|
||||
assert tag.sector_for_block(4) == 1
|
||||
assert tag.sector_for_block(63) == 15
|
||||
|
||||
def test_4k_sector_block_mapping(self):
|
||||
"""4K: sectors 0-31 = 4 blocks, sectors 32-39 = 16 blocks."""
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="4k")
|
||||
assert tag.sector_for_block(0) == 0
|
||||
assert tag.sector_for_block(127) == 31 # last block of sector 31
|
||||
assert tag.sector_for_block(128) == 32 # first block of sector 32
|
||||
assert tag.sector_for_block(255) == 39 # last block of sector 39
|
||||
|
||||
def test_read_block_data(self):
|
||||
"""Direct read of block data from memory."""
|
||||
data = bytearray(1024)
|
||||
data[0:16] = b"\xDE\xAD\xBE\xEF" + b"\x00" * 12
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k", data=data)
|
||||
assert tag.read_block_raw(0) == b"\xDE\xAD\xBE\xEF" + b"\x00" * 12
|
||||
|
||||
def test_write_block_data(self):
|
||||
"""Direct write of block data to memory."""
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
|
||||
new_data = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10"
|
||||
tag.write_block_raw(4, new_data)
|
||||
assert tag.read_block_raw(4) == new_data
|
||||
|
||||
|
||||
class TestMifareClassicReader:
|
||||
"""Test MifareClassicReader with full auth + read/write."""
|
||||
|
||||
def test_read_block_with_default_key(self):
|
||||
"""Reader authenticates and reads a block."""
|
||||
medium = SoftwareMedium()
|
||||
data = bytearray(1024)
|
||||
data[16:32] = b"\xCA\xFE\xBA\xBE" + b"\x00" * 12
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k", data=data)
|
||||
run(medium.attach(tag))
|
||||
|
||||
reader = MifareClassicReader(medium)
|
||||
result = run(reader.read_block(
|
||||
uid=b"\x01\x02\x03\x04",
|
||||
block=1,
|
||||
key=b"\xFF\xFF\xFF\xFF\xFF\xFF",
|
||||
))
|
||||
assert result["success"]
|
||||
assert result["data"][:4] == b"\xCA\xFE\xBA\xBE"
|
||||
|
||||
def test_write_block_with_default_key(self):
|
||||
"""Reader authenticates and writes a block."""
|
||||
medium = SoftwareMedium()
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
|
||||
run(medium.attach(tag))
|
||||
|
||||
reader = MifareClassicReader(medium)
|
||||
new_data = b"\x01" * 16
|
||||
result = run(reader.write_block(
|
||||
uid=b"\x01\x02\x03\x04",
|
||||
block=4,
|
||||
data=new_data,
|
||||
key=b"\xFF\xFF\xFF\xFF\xFF\xFF",
|
||||
))
|
||||
assert result["success"]
|
||||
|
||||
# Verify by reading back
|
||||
result = run(reader.read_block(
|
||||
uid=b"\x01\x02\x03\x04",
|
||||
block=4,
|
||||
key=b"\xFF\xFF\xFF\xFF\xFF\xFF",
|
||||
))
|
||||
assert result["data"] == new_data
|
||||
|
||||
def test_wrong_key_fails(self):
|
||||
"""Reader with wrong key cannot authenticate."""
|
||||
medium = SoftwareMedium()
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
|
||||
run(medium.attach(tag))
|
||||
|
||||
reader = MifareClassicReader(medium)
|
||||
result = run(reader.read_block(
|
||||
uid=b"\x01\x02\x03\x04",
|
||||
block=0,
|
||||
key=b"\x00\x00\x00\x00\x00\x00", # wrong key
|
||||
))
|
||||
assert not result["success"]
|
||||
189
tests/test_sim_ndef.py
Normal file
189
tests/test_sim_ndef.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Tests for NDEF Type 2 and Type 4 tag models."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.medium import SoftwareMedium
|
||||
from pm3py.sim.iso14443a import Reader14443A, _compute_bcc
|
||||
from pm3py.sim.ndef import NfcType2Tag, NfcType4Tag
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NFC Type 2 Tag (extends Tag14443A_3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNfcType2Tag:
|
||||
def test_atqa_sak(self):
|
||||
tag = NfcType2Tag(uid=b"\x04\x01\x02\x03\x04\x05\x06", ndef_message=b"")
|
||||
run(tag.power_on())
|
||||
resp = run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
assert resp.data == b"\x44\x00" # NTAG ATQA
|
||||
|
||||
# SELECT (7-byte UID needs cascade)
|
||||
uid = b"\x04\x01\x02\x03\x04\x05\x06"
|
||||
ct_uid = b"\x88" + uid[0:3]
|
||||
bcc1 = _compute_bcc(ct_uid)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1]))))
|
||||
cl2_uid = uid[3:7]
|
||||
bcc2 = _compute_bcc(cl2_uid)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2_uid + bytes([bcc2]))))
|
||||
assert resp.data[0] & 0x20 == 0 # SAK bit 5 = 0 (no ISO-DEP)
|
||||
|
||||
def test_read_returns_4_pages(self):
|
||||
"""READ command returns 16 bytes (4 pages of 4 bytes)."""
|
||||
tag = NfcType2Tag(uid=b"\x04\x01\x02\x03\x04\x05\x06",
|
||||
ndef_message=b"\xD1\x01\x04\x54\x02enHi")
|
||||
run(tag.power_on())
|
||||
self._select(tag)
|
||||
|
||||
# READ page 0
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x00")))
|
||||
assert resp is not None
|
||||
assert len(resp.data) == 16 # 4 pages
|
||||
|
||||
def test_cc_block_at_page_3(self):
|
||||
"""Page 3 is the capability container."""
|
||||
tag = NfcType2Tag(uid=b"\x04\x01\x02\x03\x04\x05\x06",
|
||||
ndef_message=b"\xD1\x01\x04\x54\x02enHi",
|
||||
total_pages=45) # NTAG213-like
|
||||
run(tag.power_on())
|
||||
self._select(tag)
|
||||
|
||||
# READ page 3 (CC is in pages 3)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x03")))
|
||||
cc = resp.data[0:4]
|
||||
assert cc[0] == 0xE1 # NDEF magic
|
||||
assert cc[1] == 0x10 # version 1.0
|
||||
assert cc[2] > 0 # size (in 8-byte units)
|
||||
assert cc[3] == 0x00 # read/write access
|
||||
|
||||
def test_ndef_data_in_pages_4_plus(self):
|
||||
"""NDEF message stored starting at page 4 as TLV."""
|
||||
msg = b"\xD1\x01\x04\x54\x02enHi"
|
||||
tag = NfcType2Tag(uid=b"\x04\x01\x02\x03\x04\x05\x06", ndef_message=msg)
|
||||
run(tag.power_on())
|
||||
self._select(tag)
|
||||
|
||||
# READ page 4
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x04")))
|
||||
# TLV: type=0x03 (NDEF), length, message...
|
||||
assert resp.data[0] == 0x03 # NDEF TLV type
|
||||
assert resp.data[1] == len(msg)
|
||||
|
||||
def test_write_page(self):
|
||||
"""WRITE command writes 4 bytes to a page."""
|
||||
tag = NfcType2Tag(uid=b"\x04\x01\x02\x03\x04\x05\x06", ndef_message=b"")
|
||||
run(tag.power_on())
|
||||
self._select(tag)
|
||||
|
||||
# WRITE page 4
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x04\xDE\xAD\xBE\xEF")))
|
||||
assert resp is not None # ACK
|
||||
|
||||
# Read back
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x04")))
|
||||
assert resp.data[0:4] == b"\xDE\xAD\xBE\xEF"
|
||||
|
||||
def _select(self, tag):
|
||||
uid = tag._uid
|
||||
run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
ct_uid = b"\x88" + uid[0:3]
|
||||
bcc1 = _compute_bcc(ct_uid)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct_uid + bytes([bcc1]))))
|
||||
cl2_uid = uid[3:7]
|
||||
bcc2 = _compute_bcc(cl2_uid)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2_uid + bytes([bcc2]))))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NFC Type 4 Tag (extends Tag14443A_4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNfcType4Tag:
|
||||
def test_sak_indicates_isodep(self):
|
||||
tag = NfcType4Tag(uid=b"\x04\x01\x02\x03", ndef_message=b"")
|
||||
run(tag.power_on())
|
||||
run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
uid = b"\x04\x01\x02\x03"
|
||||
bcc = _compute_bcc(uid)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
|
||||
assert resp.data[0] & 0x20 != 0 # SAK bit 5 = 1
|
||||
|
||||
def test_rats_returns_ats(self):
|
||||
tag = NfcType4Tag(uid=b"\x04\x01\x02\x03", ndef_message=b"")
|
||||
run(tag.power_on())
|
||||
self._select_and_rats(tag)
|
||||
assert tag.state == "PROTOCOL"
|
||||
|
||||
def test_select_ndef_application(self):
|
||||
"""SELECT NDEF application by AID D2760000850101."""
|
||||
tag = NfcType4Tag(uid=b"\x04\x01\x02\x03",
|
||||
ndef_message=b"\xD1\x01\x04\x54\x02enHi")
|
||||
run(tag.power_on())
|
||||
self._select_and_rats(tag)
|
||||
|
||||
# SELECT AID via I-block
|
||||
select_apdu = b"\x00\xA4\x04\x00\x07\xD2\x76\x00\x00\x85\x01\x01"
|
||||
iblock = b"\x02" + select_apdu
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(iblock)))
|
||||
assert resp is not None
|
||||
# Should get 9000 (success)
|
||||
assert resp.data[-2:] == b"\x90\x00" or resp.data[1:3] == b"\x90\x00"
|
||||
|
||||
def test_select_cc_file(self):
|
||||
"""SELECT CC file (E103) then READ BINARY."""
|
||||
tag = NfcType4Tag(uid=b"\x04\x01\x02\x03",
|
||||
ndef_message=b"\xD1\x01\x04\x54\x02enHi")
|
||||
run(tag.power_on())
|
||||
self._select_and_rats(tag)
|
||||
|
||||
# Select NDEF app
|
||||
self._send_apdu(tag, b"\x00\xA4\x04\x00\x07\xD2\x76\x00\x00\x85\x01\x01")
|
||||
|
||||
# Select CC file (E103)
|
||||
resp = self._send_apdu(tag, b"\x00\xA4\x00\x0C\x02\xE1\x03")
|
||||
assert resp is not None
|
||||
|
||||
# READ BINARY offset=0, length=15
|
||||
resp = self._send_apdu(tag, b"\x00\xB0\x00\x00\x0F")
|
||||
assert resp is not None
|
||||
# CC file starts with CCLEN(2) + version + ...
|
||||
payload = resp[:-2] # strip SW
|
||||
assert len(payload) >= 7
|
||||
|
||||
def test_read_ndef_file(self):
|
||||
"""SELECT NDEF file (E104) then READ BINARY to get NDEF message."""
|
||||
msg = b"\xD1\x01\x04\x54\x02enHi"
|
||||
tag = NfcType4Tag(uid=b"\x04\x01\x02\x03", ndef_message=msg)
|
||||
run(tag.power_on())
|
||||
self._select_and_rats(tag)
|
||||
|
||||
# Select NDEF app
|
||||
self._send_apdu(tag, b"\x00\xA4\x04\x00\x07\xD2\x76\x00\x00\x85\x01\x01")
|
||||
# Select NDEF file (E104)
|
||||
self._send_apdu(tag, b"\x00\xA4\x00\x0C\x02\xE1\x04")
|
||||
# READ BINARY — first 2 bytes are NDEF length
|
||||
resp = self._send_apdu(tag, b"\x00\xB0\x00\x00\x20")
|
||||
payload = resp[:-2]
|
||||
nlen = (payload[0] << 8) | payload[1]
|
||||
assert nlen == len(msg)
|
||||
assert payload[2:2 + nlen] == msg
|
||||
|
||||
def _select_and_rats(self, tag):
|
||||
run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
uid = tag._uid[:4]
|
||||
bcc = _compute_bcc(uid)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + uid + bytes([bcc]))))
|
||||
run(tag.handle_frame(RFFrame.from_hex("E050")))
|
||||
|
||||
def _send_apdu(self, tag, apdu: bytes) -> bytes:
|
||||
"""Send APDU via I-block, return response payload (including SW)."""
|
||||
pcb = 0x02
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([pcb]) + apdu)))
|
||||
if resp is None:
|
||||
return b""
|
||||
return resp.data[1:] # strip PCB
|
||||
1688
tests/test_sim_ntag5.py
Normal file
1688
tests/test_sim_ntag5.py
Normal file
File diff suppressed because it is too large
Load Diff
1016
tests/test_sim_nxp_icode.py
Normal file
1016
tests/test_sim_nxp_icode.py
Normal file
File diff suppressed because it is too large
Load Diff
149
tests/test_sim_pm3medium.py
Normal file
149
tests/test_sim_pm3medium.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Tests for PM3Medium — hardware-backed Medium using Proxmark3."""
|
||||
import asyncio
|
||||
import struct
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.pm3medium import PM3ReaderMedium
|
||||
from pm3py.core.transport import PM3Response
|
||||
from pm3py.core.protocol import Cmd, PM3Status
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _mock_transport():
|
||||
t = AsyncMock()
|
||||
return t
|
||||
|
||||
|
||||
def _14a_scan_response(uid=b"\x01\x02\x03\x04", atqa=b"\x04\x00", sak=0x08):
|
||||
"""Build a mock iso14a_card_select_t response."""
|
||||
# iso14a_card_select_t: uid[10] + uidlen[1] + atqa[2] + sak[1] + ats_len[1] + ats[256]
|
||||
data = uid + b"\x00" * (10 - len(uid))
|
||||
data += bytes([len(uid)])
|
||||
data += atqa
|
||||
data += bytes([sak])
|
||||
data += bytes([0]) # ats_len
|
||||
data += b"\x00" * 256 # ats
|
||||
# Select status comes from oldarg[0]: 1=OK+ATS, 2=OK no ATS
|
||||
return PM3Response(cmd=Cmd.HF_ISO14443A_READER, status=PM3Status.SUCCESS,
|
||||
reason=0, ng=False, data=data,
|
||||
oldarg=(2, 0, 0))
|
||||
|
||||
|
||||
def _14a_raw_response(data=b"\x90\x00"):
|
||||
"""Build a mock raw APDU response."""
|
||||
return PM3Response(cmd=Cmd.HF_ISO14443A_READER, status=PM3Status.SUCCESS,
|
||||
reason=0, ng=False, data=data,
|
||||
oldarg=(len(data), 0, 0))
|
||||
|
||||
|
||||
def _15693_response(data=b"\x00"):
|
||||
"""Build a mock ISO 15693 response."""
|
||||
return PM3Response(cmd=Cmd.HF_ISO15693_COMMAND, status=PM3Status.SUCCESS,
|
||||
reason=0, ng=True, data=data, oldarg=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PM3ReaderMedium — 14443-A reader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPM3ReaderMedium14443A:
|
||||
def test_transmit_receive_14a_scan(self):
|
||||
t = _mock_transport()
|
||||
t.send_mix.return_value = _14a_scan_response()
|
||||
medium = PM3ReaderMedium(t, protocol="14443a")
|
||||
|
||||
# Transmit REQA (triggers scan)
|
||||
run(medium.transmit_reader(RFFrame.from_hex("26")))
|
||||
resp = run(medium.receive_reader())
|
||||
|
||||
assert resp is not None
|
||||
assert t.send_mix.called
|
||||
|
||||
def test_transmit_receive_14a_raw(self):
|
||||
t = _mock_transport()
|
||||
t.send_mix.return_value = _14a_raw_response(b"\x90\x00")
|
||||
medium = PM3ReaderMedium(t, protocol="14443a")
|
||||
|
||||
# Send a raw APDU
|
||||
apdu = b"\x00\xA4\x04\x00"
|
||||
run(medium.transmit_reader(RFFrame.from_bytes(apdu)))
|
||||
resp = run(medium.receive_reader())
|
||||
|
||||
assert resp is not None
|
||||
assert resp.data == b"\x90\x00"
|
||||
|
||||
def test_dropfield(self):
|
||||
t = _mock_transport()
|
||||
t.send_ng_no_response = AsyncMock()
|
||||
medium = PM3ReaderMedium(t, protocol="14443a")
|
||||
|
||||
run(medium.dropfield())
|
||||
t.send_ng_no_response.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PM3ReaderMedium — 15693 reader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPM3ReaderMedium15693:
|
||||
def test_transmit_receive_15693_inventory(self):
|
||||
t = _mock_transport()
|
||||
uid_lsb = b"\x03\x1B\x6A\x5C\x50\x01\x04\xE0"
|
||||
resp_data = b"\x00\x00" + uid_lsb # flags + dsfid + uid
|
||||
t.send_ng.return_value = _15693_response(resp_data)
|
||||
medium = PM3ReaderMedium(t, protocol="15693")
|
||||
|
||||
inv = bytes([0x26, 0x01, 0x00])
|
||||
run(medium.transmit_reader(RFFrame.from_bytes(inv)))
|
||||
resp = run(medium.receive_reader())
|
||||
|
||||
assert resp is not None
|
||||
assert len(resp.data) >= 10
|
||||
|
||||
def test_transmit_receive_15693_read_block(self):
|
||||
t = _mock_transport()
|
||||
block_data = b"\x00\xDE\xAD\xBE\xEF" # flags + data
|
||||
t.send_ng.return_value = _15693_response(block_data)
|
||||
medium = PM3ReaderMedium(t, protocol="15693")
|
||||
|
||||
read_cmd = bytes([0x22, 0x20]) + b"\x01" * 8 + bytes([0x00])
|
||||
run(medium.transmit_reader(RFFrame.from_bytes(read_cmd)))
|
||||
resp = run(medium.receive_reader())
|
||||
|
||||
assert resp is not None
|
||||
assert resp.data[1:5] == b"\xDE\xAD\xBE\xEF"
|
||||
|
||||
def test_timeout_returns_none(self):
|
||||
t = _mock_transport()
|
||||
t.send_ng.side_effect = TimeoutError()
|
||||
medium = PM3ReaderMedium(t, protocol="15693")
|
||||
|
||||
inv = bytes([0x26, 0x01, 0x00])
|
||||
run(medium.transmit_reader(RFFrame.from_bytes(inv)))
|
||||
resp = run(medium.receive_reader())
|
||||
|
||||
assert resp is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PM3ReaderMedium — attach/detach (not applicable for hardware)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPM3ReaderMediumLimitations:
|
||||
def test_attach_raises(self):
|
||||
"""Hardware medium doesn't support attaching software transponders."""
|
||||
t = _mock_transport()
|
||||
medium = PM3ReaderMedium(t, protocol="14443a")
|
||||
with pytest.raises(NotImplementedError):
|
||||
run(medium.attach(None))
|
||||
|
||||
def test_detach_raises(self):
|
||||
t = _mock_transport()
|
||||
medium = PM3ReaderMedium(t, protocol="14443a")
|
||||
with pytest.raises(NotImplementedError):
|
||||
run(medium.detach(0))
|
||||
214
tests/test_sim_reader.py
Normal file
214
tests/test_sim_reader.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Tests for pm3py.sim.reader — Reader ABC, ScriptedReader, InteractiveReader."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.medium import SoftwareMedium
|
||||
from pm3py.sim.reader import Reader, ScriptedReader, InteractiveReader, ReaderStep, StepResult
|
||||
from pm3py.sim.transponder import Transponder
|
||||
|
||||
|
||||
class EchoTransponder(Transponder):
|
||||
"""Returns the received frame back as response."""
|
||||
|
||||
async def power_on(self) -> None:
|
||||
pass
|
||||
|
||||
async def power_off(self) -> None:
|
||||
pass
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
return frame
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
return "ECHO"
|
||||
|
||||
|
||||
class FixedTransponder(Transponder):
|
||||
"""Returns a fixed response to any frame."""
|
||||
|
||||
def __init__(self, response: RFFrame):
|
||||
super().__init__()
|
||||
self._response = response
|
||||
|
||||
async def power_on(self) -> None:
|
||||
pass
|
||||
|
||||
async def power_off(self) -> None:
|
||||
pass
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
return self._response
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
return "FIXED"
|
||||
|
||||
|
||||
class SimpleReader(Reader):
|
||||
"""Minimal reader implementation for testing the base class."""
|
||||
|
||||
async def run(self) -> dict:
|
||||
resp = await self.transceive(RFFrame.from_hex("26"))
|
||||
return {"response": resp}
|
||||
|
||||
|
||||
class TestReaderBase:
|
||||
"""Test Reader ABC transceive."""
|
||||
|
||||
def test_transceive_sends_and_receives(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
medium = SoftwareMedium()
|
||||
loop.run_until_complete(medium.attach(EchoTransponder()))
|
||||
reader = SimpleReader(medium)
|
||||
|
||||
result = loop.run_until_complete(reader.run())
|
||||
assert result["response"] is not None
|
||||
assert result["response"].data == b"\x26"
|
||||
|
||||
def test_transceive_no_transponder_returns_none(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
medium = SoftwareMedium()
|
||||
reader = SimpleReader(medium)
|
||||
|
||||
result = loop.run_until_complete(reader.run())
|
||||
assert result["response"] is None
|
||||
|
||||
|
||||
class TestScriptedReader:
|
||||
"""Test scripted reader sequence execution."""
|
||||
|
||||
def test_run_script_all_pass(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
medium = SoftwareMedium()
|
||||
response = RFFrame.from_hex("4400")
|
||||
loop.run_until_complete(medium.attach(FixedTransponder(response)))
|
||||
|
||||
steps = [
|
||||
ReaderStep(
|
||||
frame=RFFrame.from_hex("26"),
|
||||
description="Send REQA",
|
||||
check=lambda r: r is not None and r.data == b"\x44\x00",
|
||||
),
|
||||
ReaderStep(
|
||||
frame=RFFrame.from_hex("9320"),
|
||||
description="Send ANTICOL",
|
||||
check=lambda r: r is not None,
|
||||
),
|
||||
]
|
||||
|
||||
scripted = ScriptedReader(medium)
|
||||
results = loop.run_until_complete(scripted.run_script(steps))
|
||||
|
||||
assert len(results) == 2
|
||||
assert all(r.passed for r in results)
|
||||
|
||||
def test_run_script_check_fails(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
medium = SoftwareMedium()
|
||||
response = RFFrame.from_hex("4400")
|
||||
loop.run_until_complete(medium.attach(FixedTransponder(response)))
|
||||
|
||||
steps = [
|
||||
ReaderStep(
|
||||
frame=RFFrame.from_hex("26"),
|
||||
description="Send REQA",
|
||||
check=lambda r: r is not None and r.data == b"\xFF\xFF", # wrong
|
||||
),
|
||||
]
|
||||
|
||||
scripted = ScriptedReader(medium)
|
||||
results = loop.run_until_complete(scripted.run_script(steps))
|
||||
|
||||
assert len(results) == 1
|
||||
assert not results[0].passed
|
||||
|
||||
def test_stop_on_fail(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
medium = SoftwareMedium()
|
||||
response = RFFrame.from_hex("4400")
|
||||
loop.run_until_complete(medium.attach(FixedTransponder(response)))
|
||||
|
||||
steps = [
|
||||
ReaderStep(
|
||||
frame=RFFrame.from_hex("26"),
|
||||
description="Fail here",
|
||||
check=lambda r: False,
|
||||
stop_on_fail=True,
|
||||
),
|
||||
ReaderStep(
|
||||
frame=RFFrame.from_hex("9320"),
|
||||
description="Should not run",
|
||||
),
|
||||
]
|
||||
|
||||
scripted = ScriptedReader(medium)
|
||||
results = loop.run_until_complete(scripted.run_script(steps))
|
||||
|
||||
assert len(results) == 1 # stopped after first failure
|
||||
|
||||
def test_no_check_means_pass(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
medium = SoftwareMedium()
|
||||
loop.run_until_complete(medium.attach(FixedTransponder(RFFrame.from_hex("00"))))
|
||||
|
||||
steps = [
|
||||
ReaderStep(frame=RFFrame.from_hex("26"), description="No check"),
|
||||
]
|
||||
|
||||
scripted = ScriptedReader(medium)
|
||||
results = loop.run_until_complete(scripted.run_script(steps))
|
||||
|
||||
assert results[0].passed
|
||||
|
||||
|
||||
class TestInteractiveReader:
|
||||
"""Test interactive reader step-through."""
|
||||
|
||||
def test_send_and_receive(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
medium = SoftwareMedium()
|
||||
loop.run_until_complete(medium.attach(EchoTransponder()))
|
||||
|
||||
interactive = InteractiveReader(medium)
|
||||
resp = loop.run_until_complete(interactive.send(RFFrame.from_hex("26")))
|
||||
|
||||
assert resp is not None
|
||||
assert resp.data == b"\x26"
|
||||
|
||||
def test_send_hex_convenience(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
medium = SoftwareMedium()
|
||||
loop.run_until_complete(medium.attach(EchoTransponder()))
|
||||
|
||||
interactive = InteractiveReader(medium)
|
||||
resp = loop.run_until_complete(interactive.send_hex("A5"))
|
||||
|
||||
assert resp is not None
|
||||
assert resp.data == b"\xA5"
|
||||
|
||||
def test_history_tracks_exchanges(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
medium = SoftwareMedium()
|
||||
loop.run_until_complete(medium.attach(EchoTransponder()))
|
||||
|
||||
interactive = InteractiveReader(medium)
|
||||
loop.run_until_complete(interactive.send_hex("26"))
|
||||
loop.run_until_complete(interactive.send_hex("9320"))
|
||||
|
||||
assert len(interactive.history) == 2
|
||||
assert interactive.history[0][0].data == b"\x26"
|
||||
assert interactive.history[1][0].data == b"\x93\x20"
|
||||
|
||||
def test_dump_trace_returns_string(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
medium = SoftwareMedium()
|
||||
loop.run_until_complete(medium.attach(EchoTransponder()))
|
||||
|
||||
interactive = InteractiveReader(medium)
|
||||
loop.run_until_complete(interactive.send_hex("26"))
|
||||
|
||||
trace = interactive.dump_trace()
|
||||
assert isinstance(trace, str)
|
||||
assert len(trace) > 0
|
||||
599
tests/test_sim_table_compiler.py
Normal file
599
tests/test_sim_table_compiler.py
Normal file
@@ -0,0 +1,599 @@
|
||||
"""Tests for table compiler and sim session."""
|
||||
import asyncio
|
||||
import struct
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.iso14443a import Tag14443A_3, Tag14443A_4, _compute_bcc
|
||||
from pm3py.sim.mifare import MifareClassicTag
|
||||
from pm3py.sim.iso15693 import Tag15693
|
||||
from pm3py.sim.desfire import DesfireTag
|
||||
from pm3py.sim.table_compiler import (
|
||||
TableEntry, ResponseTable, TableCompiler,
|
||||
EML_READ, EML_WRITE, MATCH_PREFIX,
|
||||
)
|
||||
from pm3py.sim.nxp_icode import NxpIcodeTag
|
||||
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TableEntry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTableEntry:
|
||||
def test_serialize_size(self):
|
||||
entry = TableEntry(match=b"\x26", response=b"\x04\x00")
|
||||
raw = entry.serialize()
|
||||
assert len(raw) == 120
|
||||
|
||||
def test_serialize_roundtrip(self):
|
||||
entry = TableEntry(
|
||||
match=b"\x26",
|
||||
match_mode=0,
|
||||
response=b"\x04\x00",
|
||||
)
|
||||
raw = entry.serialize()
|
||||
assert len(raw) == 120
|
||||
|
||||
parsed = TableEntry.deserialize(raw)
|
||||
assert parsed.match == b"\x26"
|
||||
assert parsed.response == b"\x04\x00"
|
||||
assert parsed.match_mode == 0
|
||||
|
||||
def test_eml_fields_roundtrip(self):
|
||||
entry = TableEntry(
|
||||
match=b"\x30\x04",
|
||||
response=b"\x00",
|
||||
eml_action=1,
|
||||
eml_offset=64,
|
||||
eml_len=16,
|
||||
eml_resp_insert=1,
|
||||
)
|
||||
raw = entry.serialize()
|
||||
parsed = TableEntry.deserialize(raw)
|
||||
assert parsed.eml_action == 1
|
||||
assert parsed.eml_offset == 64
|
||||
assert parsed.eml_len == 16
|
||||
assert parsed.eml_resp_insert == 1
|
||||
|
||||
def test_group_fields_roundtrip(self):
|
||||
entry = TableEntry(
|
||||
match=b"\x60\x00",
|
||||
response=b"\xAB\xCD\xEF\x01",
|
||||
group=1,
|
||||
activate_groups=0x00000006,
|
||||
deactivate_groups=0x00000001,
|
||||
set_auth=1,
|
||||
clear_auth=0,
|
||||
)
|
||||
raw = entry.serialize()
|
||||
parsed = TableEntry.deserialize(raw)
|
||||
assert parsed.group == 1
|
||||
assert parsed.activate_groups == 0x00000006
|
||||
assert parsed.deactivate_groups == 0x00000001
|
||||
assert parsed.set_auth == 1
|
||||
assert parsed.clear_auth == 0
|
||||
|
||||
def test_consume_flag(self):
|
||||
entry = TableEntry(match=b"\x60\x00", response=b"\xAB\xCD\xEF\x01", flags=0x02)
|
||||
raw = entry.serialize()
|
||||
parsed = TableEntry.deserialize(raw)
|
||||
assert parsed.flags & 0x02
|
||||
|
||||
def test_exact_match(self):
|
||||
entry = TableEntry(match=b"\x26", response=b"\x04\x00")
|
||||
assert entry.matches(b"\x26")
|
||||
assert not entry.matches(b"\x52")
|
||||
|
||||
def test_prefix_match(self):
|
||||
entry = TableEntry(match=b"\x93\x20", match_mode=1, response=b"\x01\x02\x03\x04\x04")
|
||||
assert entry.matches(b"\x93\x20")
|
||||
assert entry.matches(b"\x93\x20\xFF\xFF")
|
||||
assert not entry.matches(b"\x95\x20")
|
||||
|
||||
def test_crc_flag(self):
|
||||
entry = TableEntry(match=b"\x26", response=b"\x08", response_flags=0x01)
|
||||
assert entry.response_flags & 0x01 # CRC append flag
|
||||
|
||||
def test_stateful_flag(self):
|
||||
entry = TableEntry(match=b"\x60\x00", response=b"\xAB\xCD\xEF\x01", flags=0x02)
|
||||
assert entry.flags & 0x02 # consume after use
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResponseTable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResponseTable:
|
||||
def test_serialize_multiple_entries(self):
|
||||
table = ResponseTable(entries=[
|
||||
TableEntry(match=b"\x26", response=b"\x04\x00"),
|
||||
TableEntry(match=b"\x52", response=b"\x04\x00"),
|
||||
])
|
||||
raw = table.serialize()
|
||||
assert len(raw) == 240 # 2 * 120
|
||||
|
||||
def test_from_trace(self):
|
||||
from pm3py.sim.replay import TraceEntry
|
||||
trace = [
|
||||
TraceEntry(direction="reader", frame=RFFrame.from_hex("26")),
|
||||
TraceEntry(direction="tag", frame=RFFrame.from_hex("0400")),
|
||||
TraceEntry(direction="reader", frame=RFFrame.from_hex("9320")),
|
||||
TraceEntry(direction="tag", frame=RFFrame.from_hex("0102030404")),
|
||||
]
|
||||
table = ResponseTable.from_trace(trace)
|
||||
assert len(table.entries) == 2
|
||||
assert table.entries[0].match == b"\x26"
|
||||
assert table.entries[0].response == b"\x04\x00"
|
||||
|
||||
def test_lookup(self):
|
||||
table = ResponseTable(entries=[
|
||||
TableEntry(match=b"\x26", response=b"\x04\x00"),
|
||||
TableEntry(match=b"\x52", response=b"\x04\x00"),
|
||||
])
|
||||
entry = table.lookup(b"\x26")
|
||||
assert entry is not None
|
||||
assert entry.response == b"\x04\x00"
|
||||
|
||||
assert table.lookup(b"\xFF") is None
|
||||
|
||||
def test_overlay_replaces_matching(self):
|
||||
base = ResponseTable(entries=[
|
||||
TableEntry(match=b"\x26", response=b"\x04\x00"),
|
||||
])
|
||||
overlay = ResponseTable(entries=[
|
||||
TableEntry(match=b"\x26", response=b"\x44\x00"), # different response
|
||||
])
|
||||
base.overlay(overlay)
|
||||
entry = base.lookup(b"\x26")
|
||||
assert entry.response == b"\x44\x00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TableCompiler — 14443-A
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTableCompiler14a:
|
||||
def test_compile_4byte_uid_tag(self):
|
||||
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04", atqa=b"\x04\x00", sak=0x08)
|
||||
table = TableCompiler.compile_14a(tag)
|
||||
|
||||
# Should have: REQA, WUPA, ANTICOL CL1, SELECT CL1
|
||||
assert len(table.entries) >= 4
|
||||
|
||||
# REQA → ATQA
|
||||
reqa = table.lookup(b"\x26")
|
||||
assert reqa is not None
|
||||
assert reqa.response == b"\x04\x00"
|
||||
|
||||
# WUPA → ATQA
|
||||
wupa = table.lookup(b"\x52")
|
||||
assert wupa is not None
|
||||
assert wupa.response == b"\x04\x00"
|
||||
|
||||
def test_compile_7byte_uid_tag(self):
|
||||
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04\x05\x06\x07", sak=0x08)
|
||||
table = TableCompiler.compile_14a(tag)
|
||||
|
||||
# Should have: REQA, WUPA, ANTICOL CL1, SELECT CL1, ANTICOL CL2, SELECT CL2
|
||||
assert len(table.entries) >= 6
|
||||
|
||||
def test_compile_part4_includes_rats(self):
|
||||
tag = Tag14443A_4(uid=b"\x01\x02\x03\x04", sak=0x20,
|
||||
ats=b"\x05\x78\x80\x70\x02")
|
||||
table = TableCompiler.compile_14a(tag)
|
||||
|
||||
# Should have RATS entry
|
||||
rats = table.lookup(b"\xE0")
|
||||
assert rats is not None
|
||||
assert rats.response == b"\x05\x78\x80\x70\x02"
|
||||
|
||||
def test_compile_includes_hlta(self):
|
||||
tag = Tag14443A_3(uid=b"\x01\x02\x03\x04")
|
||||
table = TableCompiler.compile_14a(tag)
|
||||
|
||||
# HLTA should NOT be in table (no response per spec)
|
||||
hlta = table.lookup(b"\x50\x00")
|
||||
assert hlta is None
|
||||
|
||||
|
||||
class TestTableCompiler15693:
|
||||
def test_compile_15693_has_inventory(self):
|
||||
tag = Tag15693(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06", dsfid=0x00)
|
||||
table = TableCompiler.compile_15693(tag)
|
||||
|
||||
assert len(table.entries) >= 1 # at least inventory
|
||||
|
||||
def test_compile_15693_has_system_info(self):
|
||||
tag = Tag15693(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06",
|
||||
dsfid=0x42, block_size=4, num_blocks=28)
|
||||
table = TableCompiler.compile_15693(tag)
|
||||
|
||||
# System info should be present (unaddressed)
|
||||
sysinfo = table.lookup(bytes([0x02, 0x2B]))
|
||||
assert sysinfo is not None
|
||||
|
||||
|
||||
class TestTableCompilerMifare:
|
||||
def test_compile_mifare_has_auth_entries(self):
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
|
||||
table = TableCompiler.compile_mifare(tag)
|
||||
|
||||
# Should have anticollision + auth entries for each sector
|
||||
assert len(table.entries) >= 4 # at least REQA, WUPA, ANTICOL, SELECT
|
||||
|
||||
def test_compile_mifare_auth_is_stateful(self):
|
||||
"""Auth entries should be marked stateful (consumed after use)."""
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04", size="1k")
|
||||
table = TableCompiler.compile_mifare(tag)
|
||||
|
||||
# Find an auth entry (AUTH_A for block 0 = 0x60 0x00)
|
||||
auth_entries = [e for e in table.entries if len(e.match) >= 1 and e.match[0] == 0x60]
|
||||
if auth_entries:
|
||||
# Auth nonce response should be stateful
|
||||
assert auth_entries[0].flags & 0x02
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SimSession (mock transport)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSimSession:
|
||||
def test_upload_table(self):
|
||||
from pm3py.sim.sim_session import SimSession
|
||||
t = AsyncMock()
|
||||
t.send_ng.return_value = AsyncMock()
|
||||
session = SimSession(t)
|
||||
|
||||
table = ResponseTable(entries=[
|
||||
TableEntry(match=b"\x26", response=b"\x04\x00"),
|
||||
])
|
||||
run(session.upload_table(table))
|
||||
|
||||
t.send_ng.assert_called()
|
||||
# Verify the serialized data was sent
|
||||
call_args = t.send_ng.call_args
|
||||
assert len(call_args[0][1]) == 120 # one entry
|
||||
|
||||
def test_upload_large_table_chunks(self):
|
||||
"""Tables larger than 512 bytes should be chunked."""
|
||||
from pm3py.sim.sim_session import SimSession
|
||||
t = AsyncMock()
|
||||
t.send_ng.return_value = AsyncMock()
|
||||
session = SimSession(t)
|
||||
|
||||
# 5 entries = 600 bytes > 512
|
||||
entries = [TableEntry(match=bytes([i]), response=bytes([i+1])) for i in range(5)]
|
||||
table = ResponseTable(entries=entries)
|
||||
run(session.upload_table(table))
|
||||
|
||||
# Should have been called multiple times
|
||||
assert t.send_ng.call_count >= 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TableCompiler — NXP ICODE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTableCompilerNxpIcode:
|
||||
def test_compile_has_get_random(self):
|
||||
tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06")
|
||||
table = TableCompiler.compile_nxp_icode(tag)
|
||||
entry = table.lookup(bytes([0x02, 0xB2, 0x04]))
|
||||
assert entry is not None
|
||||
assert len(entry.response) == 3 # flags + 2 random bytes
|
||||
|
||||
def test_compile_has_get_nxp_system_info(self):
|
||||
tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06")
|
||||
table = TableCompiler.compile_nxp_icode(tag)
|
||||
entry = table.lookup(bytes([0x02, 0xAB, 0x04]))
|
||||
assert entry is not None
|
||||
assert len(entry.response) == 8 # flags + PP + PP_cond + lock + feature_flags(4)
|
||||
|
||||
def test_compile_has_read_signature(self):
|
||||
sig = bytes(range(32))
|
||||
tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06", signature=sig)
|
||||
table = TableCompiler.compile_nxp_icode(tag)
|
||||
entry = table.lookup(bytes([0x02, 0xBD, 0x04]))
|
||||
assert entry is not None
|
||||
assert len(entry.response) == 33 # flags + 32-byte signature
|
||||
assert entry.response[1:] == sig
|
||||
|
||||
def test_compile_no_config_register_entries(self):
|
||||
"""0xA1/0xA2 are FAST INVENTORY READ / SET EAS, not config registers."""
|
||||
tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06")
|
||||
table = TableCompiler.compile_nxp_icode(tag)
|
||||
# Should NOT have entries for 0xA1 or 0xA2
|
||||
assert table.lookup(bytes([0x02, 0xA1, 0x04, 0x00])) is None
|
||||
assert table.lookup(bytes([0x02, 0xA2, 0x04, 0x00])) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TableCompiler — SLIX2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTableCompilerSlix2:
|
||||
def test_compile_has_set_password(self):
|
||||
tag = IcodeSlix2Tag(
|
||||
uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06",
|
||||
read_password=0x12345678,
|
||||
)
|
||||
table = TableCompiler.compile_slix2(tag)
|
||||
fallback = [e for e in table.entries if
|
||||
len(e.match) >= 2 and e.match[1] == 0xB3 and
|
||||
e.match_mode == MATCH_PREFIX]
|
||||
assert len(fallback) > 0
|
||||
assert fallback[-1].response == bytes([0x01, 0x0F])
|
||||
|
||||
def test_compile_set_password_sets_auth(self):
|
||||
tag = IcodeSlix2Tag(
|
||||
uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06",
|
||||
read_password=0x12345678,
|
||||
)
|
||||
table = TableCompiler.compile_slix2(tag)
|
||||
auth_entries = [e for e in table.entries if e.set_auth != 0]
|
||||
assert len(auth_entries) > 0
|
||||
|
||||
def test_compile_includes_nxp_base_entries(self):
|
||||
"""SLIX2 table includes NXP base entries (GET_RANDOM, READ_SIGNATURE, etc.)."""
|
||||
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06")
|
||||
table = TableCompiler.compile_slix2(tag)
|
||||
# Should have GET_RANDOM from nxp_icode compiler
|
||||
assert table.lookup(bytes([0x02, 0xB2, 0x04])) is not None
|
||||
# Should have GET_NXP_SYSTEM_INFO
|
||||
assert table.lookup(bytes([0x02, 0xAB, 0x04])) is not None
|
||||
# Should have READ_SIGNATURE
|
||||
assert table.lookup(bytes([0x02, 0xBD, 0x04])) is not None
|
||||
|
||||
def test_compile_enable_privacy_entry(self):
|
||||
"""SLIX2 table has ENABLE_PRIVACY (0xBA) entries when privacy password set."""
|
||||
tag = IcodeSlix2Tag(
|
||||
uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06",
|
||||
privacy_password=0xAABBCCDD,
|
||||
)
|
||||
table = TableCompiler.compile_slix2(tag)
|
||||
# Should have ENABLE_PRIVACY entries (correct + fallback)
|
||||
enable_entries = [e for e in table.entries if
|
||||
len(e.match) >= 2 and e.match[1] == 0xBA]
|
||||
assert len(enable_entries) >= 2 # correct password + fallback
|
||||
|
||||
def test_compile_privacy_groups(self):
|
||||
"""ENABLE_PRIVACY correct password entry activates a group."""
|
||||
tag = IcodeSlix2Tag(
|
||||
uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06",
|
||||
privacy_password=0xAABBCCDD,
|
||||
)
|
||||
table = TableCompiler.compile_slix2(tag)
|
||||
privacy_entries = [e for e in table.entries if e.activate_groups != 0]
|
||||
assert len(privacy_entries) > 0
|
||||
|
||||
def test_compile_eas_afi_password(self):
|
||||
"""EAS/AFI password (pwd_id 0x10) generates a table entry."""
|
||||
tag = IcodeSlix2Tag(
|
||||
uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06",
|
||||
eas_afi_password=0xAABBCCDD,
|
||||
)
|
||||
table = TableCompiler.compile_slix2(tag)
|
||||
auth_entries = [e for e in table.entries if e.set_auth == 0x10]
|
||||
assert len(auth_entries) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TableCompiler — EAS ALARM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTableCompilerEasAlarm:
|
||||
def test_slix2_has_eas_alarm_entry(self):
|
||||
"""SLIX2 table includes EAS ALARM (0xA5) entry."""
|
||||
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06")
|
||||
tag.set_eas(True)
|
||||
table = TableCompiler.compile_slix2(tag)
|
||||
# EAS ALARM should match 0x02 0xA5
|
||||
entry = table.lookup(bytes([0x02, 0xA5]))
|
||||
assert entry is not None
|
||||
assert entry.response[0] == 0x00 # success
|
||||
assert len(entry.response) >= 33 # flags + 32-byte EAS sequence
|
||||
|
||||
def test_eas_alarm_disabled_returns_none(self):
|
||||
"""EAS ALARM entry not present when EAS disabled."""
|
||||
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06")
|
||||
# EAS not set
|
||||
table = TableCompiler.compile_slix2(tag)
|
||||
entry = table.lookup(bytes([0x02, 0xA5]))
|
||||
# Should either be absent or return error
|
||||
assert entry is None or entry.response[0] & 0x01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TableCompiler — ICODE 3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTableCompilerIcode3:
|
||||
def test_compile_icode3_exists(self):
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag()
|
||||
table = TableCompiler.compile_icode3(tag)
|
||||
assert len(table.entries) > 0
|
||||
|
||||
def test_compile_icode3_includes_slix2_entries(self):
|
||||
"""ICODE 3 table includes SLIX2 base entries."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag()
|
||||
table = TableCompiler.compile_icode3(tag)
|
||||
# Should have GET_RANDOM, GET_NXP_SYSTEM_INFO, READ_SIGNATURE
|
||||
assert table.lookup(bytes([0x02, 0xB2])) is not None
|
||||
assert table.lookup(bytes([0x02, 0xAB])) is not None
|
||||
assert table.lookup(bytes([0x02, 0xBD])) is not None
|
||||
|
||||
def test_compile_icode3_system_info_has_feature_flags(self):
|
||||
"""ICODE 3 GET NXP SYSTEM INFO has proper feature flags."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag()
|
||||
table = TableCompiler.compile_icode3(tag)
|
||||
entry = table.lookup(bytes([0x02, 0xAB]))
|
||||
assert entry is not None
|
||||
feature_flags = struct.unpack_from("<I", entry.response, 4)[0]
|
||||
assert feature_flags & (1 << 1) # COUNTER
|
||||
assert feature_flags & (1 << 12) # PRIVACY
|
||||
assert feature_flags & (1 << 13) # DESTROY
|
||||
|
||||
def test_compile_icode3_config_password(self):
|
||||
"""ICODE 3 table includes config password (pwd_id 0x20)."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
tag = Icode3Tag(config_password=0xAABBCCDD)
|
||||
table = TableCompiler.compile_icode3(tag)
|
||||
auth_entries = [e for e in table.entries if e.set_auth != 0]
|
||||
# Should have entries for config password (0x20)
|
||||
pwd_ids = set()
|
||||
for e in table.entries:
|
||||
if e.set_auth != 0 and len(e.match) >= 3:
|
||||
pwd_ids.add(e.match[2]) # pwd_id byte
|
||||
assert 0x20 in pwd_ids
|
||||
|
||||
def test_session_dispatches_icode3(self):
|
||||
"""SimSession uses compile_icode3 for Icode3Tag."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
from pm3py.sim.sim_session import SimSession
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
tag = Icode3Tag()
|
||||
mock_ser = MagicMock()
|
||||
mock_ser.in_waiting = 0
|
||||
mock_ser.read.return_value = b""
|
||||
|
||||
session = SimSession(port=mock_ser)
|
||||
session.start_15693(tag, compile=True, trace=False)
|
||||
calls = mock_ser.write.call_args_list
|
||||
assert len(calls) >= 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TableCompiler — ICODE DNA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTableCompilerDna:
|
||||
def test_compile_icode_dna_exists(self):
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
tag = IcodeDnaTag()
|
||||
table = TableCompiler.compile_icode_dna(tag)
|
||||
assert len(table.entries) > 0
|
||||
|
||||
def test_compile_includes_base_entries(self):
|
||||
"""DNA table includes GET_RANDOM, READ_SIGNATURE from NXP base."""
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
tag = IcodeDnaTag()
|
||||
table = TableCompiler.compile_icode_dna(tag)
|
||||
assert table.lookup(bytes([0x02, 0xB2])) is not None # GET_RANDOM
|
||||
assert table.lookup(bytes([0x02, 0xBD])) is not None # READ_SIGNATURE
|
||||
|
||||
def test_compile_has_dna_feature_flags(self):
|
||||
"""GET NXP SYSTEM INFO has DNA-specific feature flags."""
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
tag = IcodeDnaTag()
|
||||
table = TableCompiler.compile_icode_dna(tag)
|
||||
entry = table.lookup(bytes([0x02, 0xAB]))
|
||||
assert entry is not None
|
||||
feature_flags = struct.unpack_from("<I", entry.response, 4)[0]
|
||||
assert feature_flags & (1 << 1) # COUNTER
|
||||
assert feature_flags & (1 << 3) # EAS_PP
|
||||
assert feature_flags & (1 << 4) # AFI_PP
|
||||
assert feature_flags & (1 << 12) # PRIVACY
|
||||
assert feature_flags & (1 << 13) # DESTROY
|
||||
|
||||
def test_compile_has_eas(self):
|
||||
"""DNA table includes EAS entries."""
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
tag = IcodeDnaTag()
|
||||
tag.set_eas(True)
|
||||
table = TableCompiler.compile_icode_dna(tag)
|
||||
entry = table.lookup(bytes([0x02, 0xA5])) # EAS ALARM
|
||||
assert entry is not None
|
||||
|
||||
def test_session_dispatches_dna(self):
|
||||
"""SimSession uses compile_icode_dna for IcodeDnaTag."""
|
||||
from pm3py.sim.icode_dna import IcodeDnaTag
|
||||
from pm3py.sim.sim_session import SimSession
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
tag = IcodeDnaTag()
|
||||
mock_ser = MagicMock()
|
||||
mock_ser.in_waiting = 0
|
||||
mock_ser.read.return_value = b""
|
||||
|
||||
session = SimSession(port=mock_ser)
|
||||
session.start_15693(tag, compile=True, trace=False)
|
||||
calls = mock_ser.write.call_args_list
|
||||
assert len(calls) >= 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SimSession — table compile on start_15693
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSimSessionTableCompile:
|
||||
def test_start_15693_compiles_nxp_table(self):
|
||||
"""SimSession uses compile_icode3 for Icode3Tag."""
|
||||
from pm3py.sim.icode3 import Icode3Tag
|
||||
from pm3py.sim.sim_session import SimSession
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
tag = Icode3Tag()
|
||||
mock_ser = MagicMock()
|
||||
mock_ser.in_waiting = 0
|
||||
mock_ser.read.return_value = b""
|
||||
|
||||
session = SimSession(port=mock_ser)
|
||||
session.start_15693(tag, compile=True, trace=False)
|
||||
|
||||
calls = mock_ser.write.call_args_list
|
||||
assert len(calls) >= 2 # SIM start + EML sync + table upload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SimSession — table compile on start_15693
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSimSessionTableCompile:
|
||||
def test_start_15693_compiles_nxp_table(self):
|
||||
"""start_15693 with compile=True should upload table entries."""
|
||||
from pm3py.sim.sim_session import SimSession
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06")
|
||||
|
||||
mock_ser = MagicMock()
|
||||
mock_ser.in_waiting = 0
|
||||
mock_ser.read.return_value = b""
|
||||
|
||||
session = SimSession(port=mock_ser)
|
||||
session.start_15693(tag, compile=True, trace=False)
|
||||
|
||||
# Should have written: SIM command + EML sync + table upload
|
||||
calls = mock_ser.write.call_args_list
|
||||
assert len(calls) >= 2
|
||||
|
||||
def test_start_15693_no_compile_default(self):
|
||||
"""start_15693 without compile should not upload table."""
|
||||
from pm3py.sim.sim_session import SimSession, CMD_SIM_TABLE_UPLOAD
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06")
|
||||
|
||||
mock_ser = MagicMock()
|
||||
mock_ser.in_waiting = 0
|
||||
mock_ser.read.return_value = b""
|
||||
|
||||
session = SimSession(port=mock_ser)
|
||||
session.start_15693(tag, compile=False, trace=False)
|
||||
|
||||
# Check no frame contains CMD_SIM_TABLE_UPLOAD
|
||||
for call in mock_ser.write.call_args_list:
|
||||
frame = call[0][0]
|
||||
if len(frame) >= 10:
|
||||
cmd_in_frame = struct.unpack_from("<H", frame, 6)[0]
|
||||
assert cmd_in_frame != CMD_SIM_TABLE_UPLOAD
|
||||
578
tests/test_sim_trace_fmt.py
Normal file
578
tests/test_sim_trace_fmt.py
Normal file
@@ -0,0 +1,578 @@
|
||||
"""Tests for pm3py.sim.trace_fmt — trace formatting and protocol decoding."""
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.trace_fmt import decode_15693, decode_15693_nxp, decode_14443a, TraceFormatter
|
||||
|
||||
|
||||
class TestDecode15693Request:
|
||||
"""Decode reader->tag (direction=0) 15693 commands."""
|
||||
|
||||
def test_inventory(self):
|
||||
payload = bytes([0x26, 0x01, 0x00])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "INVENTORY"
|
||||
|
||||
def test_inventory_with_mask(self):
|
||||
payload = bytes([0x26, 0x01, 0x08, 0xAB])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "INVENTORY mask=8"
|
||||
|
||||
def test_stay_quiet(self):
|
||||
payload = bytes([0x22, 0x02]) + b"\x01" * 8
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "STAY QUIET"
|
||||
|
||||
def test_read_single_block_addressed(self):
|
||||
payload = bytes([0x22, 0x20]) + b"\x01" * 8 + bytes([0x03])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "READ SINGLE BLOCK #3"
|
||||
|
||||
def test_read_single_block_unaddressed(self):
|
||||
payload = bytes([0x02, 0x20, 0x00])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "READ SINGLE BLOCK #0"
|
||||
|
||||
def test_write_single_block(self):
|
||||
payload = bytes([0x22, 0x21]) + b"\x01" * 8 + bytes([0x05]) + b"\xDE\xAD\xBE\xEF"
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "WRITE SINGLE BLOCK #5 [4B]"
|
||||
|
||||
def test_read_multiple_block(self):
|
||||
payload = bytes([0x22, 0x23]) + b"\x01" * 8 + bytes([0x00, 0x0D])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "READ MULTIPLE BLOCK #0+13"
|
||||
|
||||
def test_reset_to_ready(self):
|
||||
payload = bytes([0x22, 0x26]) + b"\x01" * 8
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "RESET TO READY"
|
||||
|
||||
def test_get_system_info(self):
|
||||
payload = bytes([0x22, 0x2B]) + b"\x01" * 8
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "GET SYSTEM INFO"
|
||||
|
||||
def test_get_multiple_block_security(self):
|
||||
payload = bytes([0x22, 0x2C]) + b"\x01" * 8 + bytes([0x00, 0x3F])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "GET MULTIPLE BLOCK SECURITY #0+63"
|
||||
|
||||
def test_nxp_command_decoded(self):
|
||||
payload = bytes([0x22, 0xA1, 0x04, 0x02])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "NXP FAST INVENTORY READ"
|
||||
|
||||
def test_unknown_command(self):
|
||||
payload = bytes([0x22, 0xFF])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "UNKNOWN CMD 0xFF"
|
||||
|
||||
def test_too_short(self):
|
||||
result = decode_15693(0, bytes([0x26]))
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestDecode15693NxpRequest:
|
||||
"""Decode NXP custom reader->tag (direction=0) commands."""
|
||||
|
||||
def test_nxp_fast_inventory_read(self):
|
||||
payload = bytes([0x22, 0xA1, 0x04, 0x02])
|
||||
result = decode_15693_nxp(0, payload)
|
||||
assert result == "NXP FAST INVENTORY READ"
|
||||
|
||||
def test_nxp_set_eas(self):
|
||||
payload = bytes([0x22, 0xA2, 0x04, 0x03, 0xFF])
|
||||
result = decode_15693_nxp(0, payload)
|
||||
assert result == "NXP SET EAS"
|
||||
|
||||
def test_nxp_get_random(self):
|
||||
payload = bytes([0x22, 0xB2, 0x04])
|
||||
result = decode_15693_nxp(0, payload)
|
||||
assert result == "NXP GET RANDOM"
|
||||
|
||||
def test_nxp_set_password(self):
|
||||
payload = bytes([0x22, 0xB3, 0x04, 0x01]) + b"\x00" * 4
|
||||
result = decode_15693_nxp(0, payload)
|
||||
assert result == "NXP SET PASSWORD id=1"
|
||||
|
||||
def test_response_returns_none(self):
|
||||
payload = bytes([0x00])
|
||||
result = decode_15693_nxp(1, payload)
|
||||
assert result is None
|
||||
|
||||
def test_unknown_nxp_command(self):
|
||||
payload = bytes([0x22, 0xC0, 0x04])
|
||||
result = decode_15693_nxp(0, payload)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestDecode15693Response:
|
||||
"""Decode tag->reader (direction=1) 15693 responses."""
|
||||
|
||||
def test_ok_empty(self):
|
||||
result = decode_15693(1, bytes([0x00]))
|
||||
assert result == "OK"
|
||||
|
||||
def test_inventory_response(self):
|
||||
uid_lsb = bytes([0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x04, 0xE0])
|
||||
payload = bytes([0x00, 0x00]) + uid_lsb
|
||||
result = decode_15693(1, payload)
|
||||
assert result == "OK INVENTORY UID=E004010101010101"
|
||||
|
||||
def test_error_response(self):
|
||||
payload = bytes([0x01, 0x0F])
|
||||
result = decode_15693(1, payload)
|
||||
assert result == "ERROR 0x0F"
|
||||
|
||||
def test_error_block_not_available(self):
|
||||
payload = bytes([0x01, 0x10])
|
||||
result = decode_15693(1, payload)
|
||||
assert result == "ERROR 0x10 block not available"
|
||||
|
||||
def test_data_response(self):
|
||||
payload = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF])
|
||||
result = decode_15693(1, payload)
|
||||
assert result == "OK [4B]"
|
||||
|
||||
def test_too_short(self):
|
||||
result = decode_15693(1, b"")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestDecode14443aRequest:
|
||||
"""Decode reader->tag 14443-A commands."""
|
||||
|
||||
def test_reqa(self):
|
||||
assert decode_14443a(0, bytes([0x26])) == "REQA"
|
||||
|
||||
def test_wupa(self):
|
||||
assert decode_14443a(0, bytes([0x52])) == "WUPA"
|
||||
|
||||
def test_hlta(self):
|
||||
assert decode_14443a(0, bytes([0x50, 0x00])) == "HLTA"
|
||||
|
||||
def test_anticol_cl1(self):
|
||||
assert decode_14443a(0, bytes([0x93, 0x20])) == "ANTICOL CL1"
|
||||
|
||||
def test_select_cl1(self):
|
||||
payload = bytes([0x93, 0x70]) + b"\x01\x02\x03\x04\x04"
|
||||
assert decode_14443a(0, payload) == "SELECT CL1"
|
||||
|
||||
def test_anticol_cl2(self):
|
||||
assert decode_14443a(0, bytes([0x95, 0x20])) == "ANTICOL CL2"
|
||||
|
||||
def test_select_cl2(self):
|
||||
payload = bytes([0x95, 0x70]) + b"\x01\x02\x03\x04\x04"
|
||||
assert decode_14443a(0, payload) == "SELECT CL2"
|
||||
|
||||
def test_anticol_cl3(self):
|
||||
assert decode_14443a(0, bytes([0x97, 0x20])) == "ANTICOL CL3"
|
||||
|
||||
def test_select_cl3(self):
|
||||
payload = bytes([0x97, 0x70]) + b"\x01\x02\x03\x04\x04"
|
||||
assert decode_14443a(0, payload) == "SELECT CL3"
|
||||
|
||||
def test_rats(self):
|
||||
assert decode_14443a(0, bytes([0xE0, 0x50])) == "RATS"
|
||||
|
||||
def test_iblock_even(self):
|
||||
assert decode_14443a(0, bytes([0x02, 0x00, 0xA4])) == "I-BLOCK(0)"
|
||||
|
||||
def test_iblock_odd(self):
|
||||
assert decode_14443a(0, bytes([0x03, 0x00, 0xA4])) == "I-BLOCK(1)"
|
||||
|
||||
def test_rack(self):
|
||||
assert decode_14443a(0, bytes([0xA2])) == "R-ACK(0)"
|
||||
assert decode_14443a(0, bytes([0xA3])) == "R-ACK(1)"
|
||||
|
||||
def test_rnak(self):
|
||||
assert decode_14443a(0, bytes([0xB2])) == "R-NAK(0)"
|
||||
assert decode_14443a(0, bytes([0xB3])) == "R-NAK(1)"
|
||||
|
||||
def test_deselect(self):
|
||||
assert decode_14443a(0, bytes([0xC2])) == "S(DESELECT)"
|
||||
|
||||
def test_wtx(self):
|
||||
assert decode_14443a(0, bytes([0xF2, 0x01])) == "S(WTX)"
|
||||
|
||||
def test_unknown(self):
|
||||
assert decode_14443a(0, bytes([0xFF])) is None
|
||||
|
||||
def test_empty(self):
|
||||
assert decode_14443a(0, b"") is None
|
||||
|
||||
|
||||
class TestDecode14443aResponse:
|
||||
"""Decode tag->reader 14443-A responses."""
|
||||
|
||||
def test_atqa(self):
|
||||
assert decode_14443a(1, bytes([0x04, 0x00])) == "ATQA 04 00"
|
||||
|
||||
def test_sak(self):
|
||||
assert decode_14443a(1, bytes([0x20])) == "SAK 20"
|
||||
|
||||
def test_ats(self):
|
||||
ats = bytes([0x05, 0x78, 0x80, 0x70, 0x02])
|
||||
assert decode_14443a(1, ats) == "ATS [5]"
|
||||
|
||||
def test_iblock_response(self):
|
||||
assert decode_14443a(1, bytes([0x02, 0x90, 0x00])) == "I-BLOCK(0)"
|
||||
|
||||
def test_empty(self):
|
||||
assert decode_14443a(1, b"") is None
|
||||
|
||||
|
||||
class TestTraceFormatterBasic:
|
||||
"""Test formatting output (colors stripped for assertion)."""
|
||||
|
||||
def _strip_ansi(self, s: str) -> str:
|
||||
import re
|
||||
return re.sub(r'\033\[[0-9;]*m', '', s)
|
||||
|
||||
def test_starts_with_newline(self):
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
|
||||
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
|
||||
assert result.startswith("\n")
|
||||
|
||||
def test_sim_mode_tag(self):
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
assert "[Sim]" in result
|
||||
|
||||
def test_reader_mode_tag(self):
|
||||
fmt = TraceFormatter(mode="reader", decoder=decode_15693)
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
assert "[Rdr]" in result
|
||||
|
||||
def test_sniff_mode_tag(self):
|
||||
fmt = TraceFormatter(mode="sniff", decoder=decode_15693)
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
assert "[Snf]" in result
|
||||
|
||||
def test_reader_to_tag_arrow(self):
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
assert "Reader \u2192 Tag:" in result
|
||||
|
||||
def test_tag_to_reader_arrow(self):
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
|
||||
result = self._strip_ansi(fmt.format(1, bytes([0x00])))
|
||||
assert "Tag \u2192 Reader:" in result
|
||||
|
||||
def test_hex_space_separated(self):
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x22, 0x20, 0x03])))
|
||||
assert "22 20 03" in result
|
||||
|
||||
def test_hex_uppercase(self):
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0xDE, 0xAD])))
|
||||
assert "DE AD" in result
|
||||
|
||||
def test_annotation_present(self):
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
assert "INVENTORY" in result
|
||||
|
||||
def test_no_annotation_for_unknown(self):
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x22, 0xFF])))
|
||||
assert "22 FF" in result
|
||||
|
||||
def test_no_decoder(self):
|
||||
fmt = TraceFormatter(mode="sim")
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
assert "26 01 00" in result
|
||||
assert "INVENTORY" not in result
|
||||
|
||||
def test_custom_decoder(self):
|
||||
def my_decoder(d, p):
|
||||
return "CUSTOM"
|
||||
fmt = TraceFormatter(mode="sim", decoder=my_decoder)
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x00])))
|
||||
assert "CUSTOM" in result
|
||||
|
||||
|
||||
class TestTraceFormatterDimOurSide:
|
||||
"""Our side (the one we're simulating) should be dimmed."""
|
||||
|
||||
def test_sim_mode_dims_tag_response(self):
|
||||
"""In sim mode, tag→reader (our side) should have dim annotation."""
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, is_tty=True)
|
||||
result = fmt.format(1, bytes([0x00]))
|
||||
# dim (\033[2m) should appear before direction color for annotation
|
||||
assert "\033[2m" in result
|
||||
|
||||
def test_sim_mode_bright_reader_command(self):
|
||||
"""In sim mode, reader→tag (their side) should NOT be dimmed."""
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, is_tty=True)
|
||||
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
|
||||
# Annotation should use direction color (cyan), not dim
|
||||
assert "\033[36mINVENTORY\033[0m" in result
|
||||
|
||||
def test_reader_mode_dims_reader_command(self):
|
||||
"""In reader mode, reader→tag (our side) should be dimmed."""
|
||||
fmt = TraceFormatter(mode="reader", decoder=decode_15693, is_tty=True)
|
||||
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
|
||||
assert "\033[2m" in result
|
||||
|
||||
def test_sniff_mode_dims_neither(self):
|
||||
"""In sniff mode, neither side is dimmed — annotations use direction colors."""
|
||||
fmt = TraceFormatter(mode="sniff", decoder=decode_15693, is_tty=True)
|
||||
r0 = fmt.format(0, bytes([0x26, 0x01, 0x00]))
|
||||
r1 = fmt.format(1, bytes([0x00]))
|
||||
# reader→tag uses cyan, tag→reader uses yellow
|
||||
assert "\033[36mINVENTORY\033[0m" in r0
|
||||
assert "\033[33mOK\033[0m" in r1
|
||||
|
||||
|
||||
class TestTraceFormatterWrapping:
|
||||
"""Test column-aligned wrapping for long payloads."""
|
||||
|
||||
def _strip_ansi(self, s: str) -> str:
|
||||
import re
|
||||
return re.sub(r'\033\[[0-9;]*m', '', s)
|
||||
|
||||
def test_short_payload_inline_annotation(self):
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=80)
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
lines = result.strip().split("\n")
|
||||
assert len(lines) == 1
|
||||
assert "INVENTORY" in lines[0]
|
||||
assert "26 01 00" in lines[0]
|
||||
|
||||
def test_annotation_right_justified(self):
|
||||
"""Annotation should end at the terminal's right edge."""
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=80)
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
line = result.strip()
|
||||
assert line.endswith("INVENTORY")
|
||||
assert len(line) == 80
|
||||
|
||||
def test_wrapped_annotation_right_justified(self):
|
||||
"""When wrapped, annotation line should be right-justified."""
|
||||
# Use wider terminal so annotation fits within avail
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=80)
|
||||
# Short response that still wraps at width=80 won't work, so test
|
||||
# with a response that has a short annotation
|
||||
payload = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF])
|
||||
# At width=80 this fits on one line — annotation right-justified
|
||||
result = self._strip_ansi(fmt.format(1, payload))
|
||||
line = result.strip()
|
||||
assert line.endswith("OK [4B]")
|
||||
assert len(line) == 80
|
||||
|
||||
def test_long_payload_wraps(self):
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=60)
|
||||
long_payload = bytes([0x00]) + bytes(40)
|
||||
result = self._strip_ansi(fmt.format(1, long_payload))
|
||||
lines = result.strip().split("\n")
|
||||
assert len(lines) > 1
|
||||
# Continuation lines should be indented to prefix column
|
||||
first_hex_col = lines[0].index("00")
|
||||
for line in lines[1:]:
|
||||
if line.strip():
|
||||
leading = len(line) - len(line.lstrip())
|
||||
assert leading >= first_hex_col - 1
|
||||
|
||||
def test_wrapped_annotation_on_own_line(self):
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=50)
|
||||
uid_lsb = bytes([0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x04, 0xE0])
|
||||
payload = bytes([0x00, 0x00]) + uid_lsb
|
||||
result = self._strip_ansi(fmt.format(1, payload))
|
||||
lines = result.strip().split("\n")
|
||||
if len(lines) > 1:
|
||||
annotation_lines = [l for l in lines if "INVENTORY" in l]
|
||||
hex_lines = [l for l in lines if "00 01" in l.lower() or "04 e0" in l.lower()]
|
||||
if len(hex_lines) > 1:
|
||||
assert len(annotation_lines) == 1
|
||||
assert annotation_lines[0] not in hex_lines
|
||||
|
||||
def test_long_annotation_wraps_to_next_line(self):
|
||||
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
|
||||
from pm3py.sim.type5 import ndef_text
|
||||
msg = ndef_text("A" * 30)
|
||||
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
|
||||
payload = bytes([0x00]) + cc + tlv
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=80)
|
||||
result = self._strip_ansi(fmt.format(1, payload))
|
||||
lines = result.strip().split("\n")
|
||||
ann_lines = [l for l in lines if "NDEF" in l]
|
||||
assert len(ann_lines) >= 1
|
||||
|
||||
|
||||
class TestTraceFormatterCRC:
|
||||
"""CRC bytes rendered separately and excluded from decoding."""
|
||||
|
||||
def _strip_ansi(self, s: str) -> str:
|
||||
import re
|
||||
return re.sub(r'\033\[[0-9;]*m', '', s)
|
||||
|
||||
def test_crc_bytes_in_output(self):
|
||||
"""CRC bytes still appear in hex output."""
|
||||
# Inventory response with CRC: flags + dsfid + uid(8) + crc(2)
|
||||
uid_lsb = bytes([0x04, 0x03, 0x02, 0x01, 0xEF, 0xBE, 0xAD, 0xDE])
|
||||
payload = bytes([0x00, 0x00]) + uid_lsb + bytes([0x84, 0x62])
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=2, width=120)
|
||||
result = self._strip_ansi(fmt.format(1, payload))
|
||||
assert "84 62" in result
|
||||
|
||||
def test_crc_excluded_from_decode(self):
|
||||
"""Decoder sees data without CRC — inventory response recognized."""
|
||||
uid_lsb = bytes([0x04, 0x03, 0x02, 0x01, 0xEF, 0xBE, 0xAD, 0xDE])
|
||||
payload = bytes([0x00, 0x00]) + uid_lsb + bytes([0x84, 0x62])
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=2, width=120)
|
||||
result = self._strip_ansi(fmt.format(1, payload))
|
||||
assert "OK INVENTORY UID=" in result
|
||||
|
||||
def test_read_response_with_crc(self):
|
||||
"""Read response with CRC decoded as OK [4B] not OK [6B]."""
|
||||
payload = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF, 0x48, 0xD1])
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=2, width=120)
|
||||
result = self._strip_ansi(fmt.format(1, payload))
|
||||
assert "OK [4B]" in result
|
||||
|
||||
def test_crc_different_color(self):
|
||||
"""CRC bytes use distinct color from data bytes."""
|
||||
payload = bytes([0x00, 0xDE, 0xAD, 0x48, 0xD1])
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=2, is_tty=True, width=120)
|
||||
result = fmt.format(1, payload)
|
||||
# Data uses dim, CRC uses bold white
|
||||
assert "\033[2m" in result # dim for data
|
||||
assert "\033[1;37m" in result # bold white for CRC
|
||||
|
||||
def test_no_crc_when_crc_len_zero(self):
|
||||
"""No CRC splitting when crc_len=0."""
|
||||
payload = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF])
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=0, width=120)
|
||||
result = self._strip_ansi(fmt.format(1, payload))
|
||||
assert "OK [4B]" in result
|
||||
|
||||
def test_short_payload_no_crc_split(self):
|
||||
"""Payload shorter than crc_len doesn't crash."""
|
||||
payload = bytes([0x00])
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=2, width=120)
|
||||
result = self._strip_ansi(fmt.format(1, payload))
|
||||
assert "00" in result
|
||||
|
||||
def test_crc_in_commands(self):
|
||||
"""Reader→tag commands include CRC — all bytes appear in hex output."""
|
||||
# INVENTORY with CRC: 26 01 00 F6 0A — CRC stripped for decoding
|
||||
payload = bytes([0x26, 0x01, 0x00, 0xF6, 0x0A])
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, crc_len=2, width=120)
|
||||
result = self._strip_ansi(fmt.format(0, payload))
|
||||
assert "INVENTORY" in result
|
||||
assert "26 01 00" in result
|
||||
assert "F6 0A" in result
|
||||
|
||||
|
||||
class TestTraceFormatterNoColor:
|
||||
"""Colors suppressed when is_tty=False."""
|
||||
|
||||
def test_no_ansi_when_not_tty(self):
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, is_tty=False)
|
||||
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
|
||||
assert "\033[" not in result
|
||||
|
||||
def test_ansi_present_when_tty(self):
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, is_tty=True)
|
||||
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
|
||||
assert "\033[" in result
|
||||
|
||||
|
||||
class TestTransponderDecodeTrace:
|
||||
"""Verify decode_trace on transponder hierarchy."""
|
||||
|
||||
def test_base_transponder_returns_none(self):
|
||||
from pm3py.sim.transponder import Transponder
|
||||
# Can't instantiate ABC directly, but we can check the method exists
|
||||
assert hasattr(Transponder, 'decode_trace')
|
||||
|
||||
def test_tag15693_decodes_standard(self):
|
||||
from pm3py.sim.iso15693 import Tag15693
|
||||
tag = Tag15693(uid=bytes(8))
|
||||
result = tag.decode_trace(0, bytes([0x26, 0x01, 0x00]))
|
||||
assert result == "INVENTORY"
|
||||
|
||||
def test_tag15693_decodes_nxp_names(self):
|
||||
"""Base Tag15693 decodes NXP command names (via standard decoder)."""
|
||||
from pm3py.sim.iso15693 import Tag15693
|
||||
tag = Tag15693(uid=bytes(8))
|
||||
result = tag.decode_trace(0, bytes([0x22, 0xA1, 0x04, 0x02]))
|
||||
assert result == "NXP FAST INVENTORY READ"
|
||||
|
||||
def test_nxp_icode_decodes_nxp(self):
|
||||
from pm3py.sim.nxp_icode import NxpIcodeTag
|
||||
tag = NxpIcodeTag(uid=b"\xE0\x04" + bytes(6))
|
||||
result = tag.decode_trace(0, bytes([0x22, 0xA1, 0x04, 0x02]))
|
||||
assert result == "NXP FAST INVENTORY READ"
|
||||
|
||||
def test_nxp_icode_falls_back_to_standard(self):
|
||||
from pm3py.sim.nxp_icode import NxpIcodeTag
|
||||
tag = NxpIcodeTag(uid=b"\xE0\x04" + bytes(6))
|
||||
result = tag.decode_trace(0, bytes([0x26, 0x01, 0x00]))
|
||||
assert result == "INVENTORY"
|
||||
|
||||
def test_icode_slix2_inherits_nxp(self):
|
||||
from pm3py.sim.icode_slix2 import IcodeSlix2Tag
|
||||
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
||||
result = tag.decode_trace(0, bytes([0x22, 0xB2, 0x04]))
|
||||
assert result == "NXP GET RANDOM"
|
||||
|
||||
def test_tag14443a_decodes(self):
|
||||
from pm3py.sim.iso14443a import Tag14443A
|
||||
tag = Tag14443A(uid=bytes(4))
|
||||
result = tag.decode_trace(0, bytes([0x26]))
|
||||
assert result == "REQA"
|
||||
|
||||
def test_tag14443a_no_15693(self):
|
||||
from pm3py.sim.iso14443a import Tag14443A
|
||||
tag = Tag14443A(uid=bytes(4))
|
||||
result = tag.decode_trace(0, bytes([0x26, 0x01, 0x00]))
|
||||
# 14443a decoder sees 0x26 as REQA (single byte check happens first)
|
||||
# This is fine — the protocol context determines which decoder runs
|
||||
assert result is not None # it will decode as REQA since b0=0x26
|
||||
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
import struct
|
||||
|
||||
|
||||
class TestSimSessionTraceWiring:
|
||||
"""Verify SimSession uses TraceFormatter with tag.decode_trace."""
|
||||
|
||||
def _make_trace_frame(self, direction: int, payload: bytes) -> bytes:
|
||||
from pm3py.core.transport import RESP_POSTAMBLE_NOCRC
|
||||
from pm3py.core.protocol import RESP_PREAMBLE_MAGIC
|
||||
from pm3py.sim.sim_session import CMD_HF_ISO15693_SIM_TRACE
|
||||
data = bytes([direction]) + payload
|
||||
length = len(data) | 0x8000
|
||||
header = struct.pack("<IHbbH", RESP_PREAMBLE_MAGIC, length, 0, 0, CMD_HF_ISO15693_SIM_TRACE)
|
||||
postamble = struct.pack("<H", RESP_POSTAMBLE_NOCRC)
|
||||
return header + data + postamble
|
||||
|
||||
@patch("pm3py.sim.sim_session.TraceFormatter")
|
||||
def test_trace_uses_tag_decoder(self, MockFormatter):
|
||||
from pm3py.sim.sim_session import SimSession
|
||||
mock_fmt = MockFormatter.return_value
|
||||
session = SimSession()
|
||||
session._formatter = mock_fmt
|
||||
session._active = True
|
||||
|
||||
frame = self._make_trace_frame(0, bytes([0x26, 0x01, 0x00]))
|
||||
mock_serial = MagicMock()
|
||||
mock_serial.in_waiting = len(frame)
|
||||
call_count = 0
|
||||
|
||||
def read_side_effect(n):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return frame
|
||||
session._active = False
|
||||
return b""
|
||||
|
||||
mock_serial.read.side_effect = read_side_effect
|
||||
session._trace_reader(mock_serial)
|
||||
mock_fmt.print.assert_called_once_with(0, bytes([0x26, 0x01, 0x00]), crc_fail=False)
|
||||
Reference in New Issue
Block a user