Deletes 24 shim files from sim/. All test imports now point directly to canonical transponders/ and trace/ paths. sim/ contains only infrastructure (15 files): frame, memory, transponder ABC, reader ABC, medium, sim_session, table_compiler, replay, relay, fuzzer, pm3medium, mcu_bridge, mcu_protocol, dual_session, __init__. 751 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
190 lines
6.9 KiB
Python
190 lines
6.9 KiB
Python
"""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.transponders.hf.iso14443a.base 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)
|