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:
michael
2026-03-18 20:04:55 -07:00
parent 7fc2470b01
commit 668170457e
68 changed files with 16951 additions and 1 deletions

419
tests/test_sim_14443a.py Normal file
View 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
View 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

View 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
View 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
View 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
View 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"]

View 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
View 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
View 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

View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

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
View 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
View 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

View 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
View 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)