"""LF demod tests — hardware-free. Every case builds a synthetic Proxmark-style envelope from a transponder *encoder*, runs it through the demod, and asserts the credential comes back. Because the encode side is the shipped model, a green test means the demod is the true inverse of what the tag emits. """ from unittest.mock import MagicMock from pm3py.lf import dsp, fsk, demod_samples, read_config, identify_lf from pm3py.lf import protocols from pm3py.lf import capture from pm3py.transponders.lf.em.em4100 import EM4100Tag from pm3py.transponders.lf.hid.hid import HIDProxTag from pm3py.transponders.lf.atmel.t5577 import T5577Config, CONFIG_EM4100 # --- synthetic envelope builders (inverse of the demod) --------------------------------------- def _manchester_env(frame_bits, rf=64, high=200, low=60, repeats=4, start_offset=0, noise=0): """Manchester-encode a bit list into an RF/n ASK envelope (data 1 -> half-bits 1,0).""" half = [] for d in frame_bits * repeats: half += [1, 0] if d else [0, 1] sph = rf // 2 env = [] for hb in half: env += [high if hb else low] * sph if noise: # deterministic ± ripple, no RNG (Math.random is unavailable and tests must be stable) env = [max(0, min(255, v + (noise if (i % 3 == 0) else -noise))) for i, v in enumerate(env)] return bytes(env[start_offset:]) def _em4100_env(tag_id, **kw): return _manchester_env(EM4100Tag._encode(tag_id), **kw) def _t55xx_config_env(word, rf=64, **kw): bits = [(word >> (31 - i)) & 1 for i in range(32)] return _manchester_env(bits, rf=rf, **kw) def _fsk_env(symbols, high=200, low=60, repeats=3): """Render FSK2a final-bit symbols (0 -> fc/8 wave, 1 -> fc/10 wave) as a square-wave envelope, fcAll-style: (fc-halfFC) low then halfFC high per wave, ~clk/fc waves filling clk=50.""" env = [] for _ in range(repeats): for s in symbols: fc = 10 if s else 8 half = fc >> 1 for _w in range(round(50 / fc)): env += [low] * (fc - half) + [high] * half return bytes(env) def _bits(value, n): return [(value >> (n - 1 - i)) & 1 for i in range(n)] def _hid_env(fc, cn, fmt="H10301", repeats=5, **kw): """A faithful HID Prox envelope (matches firmware add_HID_preamble): SOF + Manchester(frame), FSK2a modulated. 26-bit carries a 0x20 header (bit 37) + sentinel (bit 26); 37-bit is bare.""" w_int = 0 for b in HIDProxTag._encode_wiegand(fc, cn, fmt): w_int = (w_int << 1) | b if fmt == "H10301": # 26-bit: header + sentinel, 38-bit frame full = (0x20 << 32) | (1 << 26) | w_int frame = _bits(full, 38) else: # 37-bit: header-less frame = _bits(w_int, 37) manch = [] for d in frame: manch += [1, 0] if d else [0, 1] # data 1 -> fc10,fc8 ; 0 -> fc8,fc10 return _fsk_env(fsk.HID_PREAMBLE + manch, repeats=repeats, **kw) # --- DSP primitives --------------------------------------------------------------------------- class TestDSP: def test_threshold_midpoint(self): assert 100 < dsp.threshold([60] * 50 + [200] * 50) < 160 def test_flat_capture_detected(self): assert dsp.is_flat([128] * 200) assert not dsp.is_flat([60] * 100 + [200] * 100) def test_clock_detects_halfbit(self): env = _em4100_env(0x0102030405, rf=64) half = dsp.detect_clock(dsp.binarize(env)) assert half is not None and abs(half - 32) < 4 # RF/64 -> 32-sample half-bit def test_clock_survives_sparse_stray_edges(self): # hardware regression: a strongly-coupled EM4100 gives a clean RF/64 signal (intervals # dominated by 32/64) with a few sub-bit stray edges from ADC ripple. The old min-based # estimator keyed off the bare shortest interval and collapsed to ~3 (-> data clock 8 -> # dead demod). The dominant interval is still 32, so detect_clock must ride through it. env = bytearray(_em4100_env(0x0102030405, rf=64, high=255, low=0)) for i in range(200, len(env), 900): # sparse single-sample spikes env[i] = 0 if env[i] > 128 else 255 half = dsp.detect_clock(dsp.binarize(bytes(env))) assert half is not None and abs(half - 32) < 4 # not dragged down to the ~3 outlier r = protocols.decode_em4100(bytes(env)) assert r and r["id_hex"] == "0102030405" # end-to-end decode survives def test_manchester_roundtrip(self): # (1,0)->1, (0,1)->0 at phase 0 assert dsp.manchester_decode([1, 0, 0, 1, 1, 0]) == [1, 0, 1] # --- EM4100 ----------------------------------------------------------------------------------- class TestEM4100: def test_roundtrip(self): r = protocols.decode_em4100(_em4100_env(0x0102030405)) assert r and r["protocol"] == "EM4100" assert r["tag_id"] == 0x0102030405 and r["id_hex"] == "0102030405" def test_various_ids_and_rates(self): for tid in (0x0000000001, 0xAB12CD34EF, 0xFFFFFFFFFF): for rf in (32, 64): r = protocols.decode_em4100(_em4100_env(tid, rf=rf)) assert r and r["tag_id"] == tid, f"{tid:010X} @ RF/{rf}" def test_starts_mid_frame(self): # capture that begins partway through a frame still locks (frame repeats) r = protocols.decode_em4100(_em4100_env(0x1234567890, start_offset=777)) assert r and r["tag_id"] == 0x1234567890 def test_survives_noise(self): r = protocols.decode_em4100(_em4100_env(0x00DEADBEEF, noise=25)) assert r and r["tag_id"] == 0x00DEADBEEF def test_flat_is_none(self): assert protocols.decode_em4100(bytes([128] * 4000)) is None def test_customer_and_card_fields(self): r = protocols.decode_em4100(_em4100_env(0xAB01020304)) assert r["customer_id"] == 0xAB assert r["card_number"] == 0x01020304 # --- HID Prox (FSK) --------------------------------------------------------------------------- class TestFSK: def test_fskdemod_recovers_preamble(self): # a bare SOF + a few symbols demodulates to the HID preamble pattern bits = fsk.fskdemod(_fsk_env(fsk.HID_PREAMBLE + [0, 1] * 20)) found, start, _ = fsk.preamble_search(bits, fsk.HID_PREAMBLE) assert found def test_noise_rejected(self): assert fsk.is_noise(bytes([128] * 5000)) assert fsk.fskdemod(bytes([128] * 5000)) == [] class TestHID: def test_h10301_roundtrip(self): r = protocols.decode_hid(_hid_env(123, 45678)) assert r and r["protocol"] == "HID Prox" and r["format"] == "H10301" assert r["facility_code"] == 123 and r["card_number"] == 45678 def test_various_h10301(self): for fc, cn in ((0, 1), (255, 65535), (77, 12345)): r = protocols.decode_hid(_hid_env(fc, cn)) assert r and r["facility_code"] == fc and r["card_number"] == cn, f"{fc}/{cn}" def test_h10304_37bit(self): r = protocols.decode_hid(_hid_env(4321, 123456, fmt="H10304")) # CN < 2^19 assert r and r["format"] == "H10304" assert r["facility_code"] == 4321 and r["card_number"] == 123456 def test_not_hid_returns_none(self): assert protocols.decode_hid(_em4100_env(0x0102030405)) is None assert protocols.decode_hid(bytes([128] * 6000)) is None def test_demod_samples_picks_hid(self): r = demod_samples(_hid_env(200, 9999)) assert r["protocol"] == "HID Prox" and r["card_number"] == 9999 # --- AWID (FSK, no Manchester) ---------------------------------------------------------------- def _awid_env(fc, cn, fmt_len=26, repeats=4, **kw): """A faithful AWID envelope: 66-bit Wiegand (fmtLen + fc + card) re-parity'd into 88 bits (3 data + 1 odd parity per group), preambled, FSK2a-modulated (one symbol per bit).""" (fc_off, fc_w), (cn_off, cn_w) = protocols._AWID_FORMATS[fmt_len] data = [0] * 66 for k in range(8): data[k] = (fmt_len >> (7 - k)) & 1 for k in range(fc_w): data[fc_off + k] = (fc >> (fc_w - 1 - k)) & 1 for k in range(cn_w): data[cn_off + k] = (cn >> (cn_w - 1 - k)) & 1 parity88 = [] # re-insert odd parity every 3 bits for i in range(0, 66, 3): g = data[i:i + 3] parity88 += g + [1 - (sum(g) & 1)] # odd parity of the 3 data bits frame = fsk.AWID_PREAMBLE + parity88 # 8 + 88 = 96 bits return _fsk_env(frame, repeats=repeats, **kw) class TestAWID: def test_awid26_roundtrip(self): r = protocols.decode_awid(_awid_env(12, 34567)) assert r and r["protocol"] == "AWID" and r["format"] == "AWID-26" assert r["facility_code"] == 12 and r["card_number"] == 34567 def test_awid_various(self): for fc, cn in ((0, 1), (255, 65535), (200, 9999)): r = protocols.decode_awid(_awid_env(fc, cn)) assert r and r["facility_code"] == fc and r["card_number"] == cn, f"{fc}/{cn}" def test_parity_gate_rejects(self): assert protocols.decode_awid(bytes([128] * 6000)) is None assert protocols.decode_awid(_em4100_env(0x0102030405)) is None def test_demod_samples_picks_awid(self): r = demod_samples(_awid_env(7, 4242)) assert r["protocol"] == "AWID" and r["card_number"] == 4242 # --- FDX-B (biphase) -------------------------------------------------------------------------- def _fdxb_bits(country, national, animal=0): bits = [1] * 128 for i in range(10): bits[i] = 0 bits[10] = 1 def put(val, n, off): for k in range(n): bits[off + k] = (val >> k) & 1 # LSB-first, like num_to_bytebitsLSBF put(national & 0xFF, 8, 11) put((national >> 8) & 0xFF, 8, 20) put((national >> 16) & 0xFF, 8, 29) put((national >> 24) & 0xFF, 8, 38) put((national >> 32) & 0x3F, 6, 47) put(country & 0x3, 2, 53) put((country >> 2) & 0xFF, 8, 56) bits[65] = 0 put(0, 7, 66) put(0, 7, 74) bits[81] = animal raw = bytes(sum(bits[11 + i * 9 + k] << k for k in range(8)) for i in range(8)) crc = protocols.crc16_fdxb(raw) put(crc & 0xFF, 8, 83) put((crc >> 8) & 0xFF, 8, 92) return bits def _biphase_env(databits, rf=32, high=200, low=60, repeats=3): """Differential-biphase ASK envelope: toggle level at each bit boundary, and for data 1 add a mid-bit toggle (so the two half-bits differ) — the inverse of biphase_raw_decode.""" half = [] level = 0 for _ in range(repeats): for d in databits: level ^= 1 h1 = level if d: level ^= 1 half.append(h1) half.append(level) sph = rf // 2 env = [] for hb in half: env += [high if hb else low] * sph return bytes(env) def _fdxb_env(country, national, animal=0, **kw): return _biphase_env(_fdxb_bits(country, national, animal), **kw) # --- NRZ / raw physical-layer fallback -------------------------------------------------------- def _nrz_env(databits, rf=32, high=200, low=60, repeats=8): """Direct/NRZ ASK envelope: each data bit is output as-is, held for one bit-width (rf samples). The inverse of ask.nrz_bits.""" env = [] for d in databits * repeats: env += [high if d else low] * rf return bytes(env) def _cyclic_match(bitstr, frame) -> bool: """Is ``bitstr`` a rotation of ``frame`` or its bitwise inverse? Raw demod carries polarity and start-of-frame ambiguity, so cyclic-plus-invert is the right equivalence.""" f = "".join(str(b) for b in frame) if len(bitstr) != len(f): return False inv = "".join("1" if c == "0" else "0" for c in f) return bitstr in (f + f) or bitstr in (inv + inv) class TestRawDemod: # a 32-bit frame with no short internal period (so the recovered frame length is 32) FRAME = [1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 1] def test_nrz_round_trips(self): r = capture.demod_raw(_nrz_env(self.FRAME, rf=32)) assert r and "nrz" in r["encodings"] enc = r["encodings"]["nrz"] assert enc["period"] == 32 assert _cyclic_match(enc["bits"], self.FRAME) assert r["rf"] == "RF/32" def test_nrz_various_rates(self): for rf in (32, 64): r = capture.demod_raw(_nrz_env(self.FRAME, rf=rf)) assert r and _cyclic_match(r["encodings"]["nrz"]["bits"], self.FRAME) def test_biphase_round_trips(self): r = capture.demod_raw(_biphase_env(self.FRAME, rf=32)) assert r and "biphase" in r["encodings"] assert _cyclic_match(r["encodings"]["biphase"]["bits"], self.FRAME) def test_noise_returns_none(self): assert capture.demod_raw(bytes([128] * 4000)) is None assert capture.demod_raw(b"") is None def test_raw_is_below_credentials(self): # a genuine EM4100 must decode as a credential; the raw fallback is only for the unnamed assert demod_samples(_em4100_env(0x0102030405))["protocol"] == "EM4100" def test_generators_yield_bits(self): from pm3py.lf import ask assert any(len(b) >= 32 for b in ask.nrz_bits(_nrz_env(self.FRAME, rf=32))) assert any(len(b) >= 32 for b in ask.biphase_bits(_biphase_env(self.FRAME, rf=32))) def test_rejects_constant_tail(self): # hardware regression: biphase-decoding a non-biphase tag emits a short prefix then a long # constant run, which trivially "repeats" at any period. That must NOT report a frame. assert capture._frame_period([0, 0, 0, 1, 0, 1, 0, 1] + [1] * 200) is None assert capture._frame_period([1] * 200) is None # fully constant assert capture._frame_period(([0, 1] * 100)) is None # alternating fill, sub-periodic class TestCRC16: def test_known_vectors(self): # "123456789" -> CRC-16/XMODEM 0x31C3, CRC-16/KERMIT 0x2189 (anchors the CRC impl) d = b"123456789" assert protocols.crc16(d, 0, 0x1021, refin=False, refout=False) == 0x31C3 assert protocols.crc16(d, 0, 0x1021, refin=True, refout=True) == 0x2189 class TestFDXB: def test_roundtrip(self): r = protocols.decode_fdxb(_fdxb_env(999, 0x3FA1B2C3D)) assert r and r["protocol"] == "FDX-B" assert r["country_code"] == 999 and r["national_code"] == 0x3FA1B2C3D def test_various(self): for country, national in ((1, 1), (840, 123456789), (0x3FF, 0x3FFFFFFFFF)): r = protocols.decode_fdxb(_fdxb_env(country, national)) assert r and r["country_code"] == country and r["national_code"] == national def test_animal_flag(self): assert protocols.decode_fdxb(_fdxb_env(124, 555, animal=1))["animal"] is True def test_crc_gate_rejects_noise(self): assert protocols.decode_fdxb(bytes([128] * 6000)) is None assert protocols.decode_fdxb(_em4100_env(0x0102030405)) is None def test_demod_samples_picks_fdxb(self): r = demod_samples(_fdxb_env(56, 77)) assert r["protocol"] == "FDX-B" and r["country_code"] == 56 # --- T55xx config ----------------------------------------------------------------------------- class TestT55xxConfig: def test_em4100_config_roundtrip(self): r = protocols.decode_t55xx_config(_t55xx_config_env(CONFIG_EM4100)) assert r and r["protocol"] == "T5577" assert r["config"] == CONFIG_EM4100 assert r["modulation"] == "ASK" assert r["emulating"] == "EM4100 / EM4102" def test_config_rotation_recovers_preset(self): # a block read recovers the config at some bit rotation (hardware gave 0x000A4020 = # 0x00148040 >> 1); rotate-matching must still resolve it to the EM4100 preset rotated = ((CONFIG_EM4100 >> 1) | ((CONFIG_EM4100 & 1) << 31)) & 0xFFFFFFFF assert rotated == 0x000A4020 r = protocols.decode_t55xx_config(_t55xx_config_env(rotated)) assert r and r["config"] == CONFIG_EM4100 and r["emulating"] == "EM4100 / EM4102" def test_config_no_false_positive(self): # repeating words that aren't any preset rotation (all-1s, random) -> None, not a bogus config assert protocols.decode_t55xx_config(_t55xx_config_env(0xFFFFFFFF)) is None assert protocols.decode_t55xx_config(_t55xx_config_env(0x12345678)) is None def test_preset_labels(self): assert protocols.emulation_name(T5577Config._PRESETS["hid"]) == "HID Prox" assert protocols.emulation_name(T5577Config._PRESETS["indala"]) == "Indala" assert protocols.emulation_name(T5577Config._PRESETS["fdxb"]) == "FDX-B" def test_unknown_config_family_label(self): # an ASK config that isn't a known preset -> family label, not a crash label = protocols.emulation_name(0x00148000) assert "ASK" in label or "EM" in label # --- device orchestration (mocked) ------------------------------------------------------------ def _mock_lf_device(config_env=b"", emitted_env=b""): dev = MagicMock() dev.lf.t55.readbl.return_value = {"status": 0} dev.lf.read.return_value = {"status": 0, "samples": len(emitted_env)} # identify_lf downloads config first, then the emitted stream dev.lf.download_samples.side_effect = [config_env, emitted_env] return dev class TestIdentifyLF: def test_t5577_emulating_em4100(self): dev = _mock_lf_device(config_env=_t55xx_config_env(CONFIG_EM4100), emitted_env=_em4100_env(0x0102030405)) lines = [] r = identify_lf(dev, emit=lines.append) assert r["found"] and r["field"] == "lf" assert r["chip"] == "T5577" assert r["emulating"] == "EM4100 / EM4102" assert r["emitted"]["id_hex"] == "0102030405" # label names the chip + the actual decoded credential (not just the config's guess) assert r["label"] == "T5577 (EM4100 0102030405)" assert any("T55xx block 0" in l for l in lines) def test_native_em4100_no_config(self): # a real EM4100 chip: nothing answers the T55xx read, but it emits its ID dev = _mock_lf_device(config_env=bytes([128] * 4000), emitted_env=_em4100_env(0x1122334455)) r = identify_lf(dev) assert r["found"] and r["chip"] is None assert r["label"] == "EM4100 1122334455" def test_native_fdxb_label(self): # a native FDX-B tag (no T55xx) must label per-protocol, not KeyError on id_hex dev = _mock_lf_device(config_env=bytes([128] * 4000), emitted_env=_fdxb_env(124, 555, animal=1)) r = identify_lf(dev) assert r["found"] and r["chip"] is None assert r["label"].startswith("FDX-B 124-000000000555") def test_native_hid_label(self): dev = _mock_lf_device(config_env=bytes([128] * 4000), emitted_env=_hid_env(12, 3456)) r = identify_lf(dev) assert r["label"] == "HID Prox H10301 FC 12 Card 3456" def test_nothing_on_antenna(self): dev = _mock_lf_device(config_env=bytes([128] * 4000), emitted_env=bytes([128] * 4000)) assert identify_lf(dev) is None def test_no_device(self): assert identify_lf(None) is None def test_rejects_async_device(self): # a raw async Proxmark3() exposes coroutine-function methods — must fail with a clear # message pointing at Proxmark3.sync(), not blow up deep in the demod on an un-awaited # coroutine (the MagicMock tests couldn't catch this since mocks return plain values) dev = MagicMock() async def _aread(*a, **k): return {} dev.lf.read = _aread try: identify_lf(dev) assert False, "expected TypeError for an async device" except TypeError as e: assert "sync" in str(e).lower() class TestCaptureValidation: def test_looks_like_samples_rejects_memory(self): from pm3py.lf.capture import _looks_like_samples env = _em4100_env(0x0102030405) assert _looks_like_samples(env) # a real envelope passes assert not _looks_like_samples(b"\xef\xbe\xad\xde" * 500) # DEADBEEF stack canary assert not _looks_like_samples(b"\x50\x4d\x33\x62" + bytes(500)) # PM3 response magic assert not _looks_like_samples(bytes(4000)) # all-zero (unwritten) buffer assert not _looks_like_samples(b"\x80" * 100) # too short def test_capture_retries_past_garbage(self): from pm3py.lf.capture import _capture good = _em4100_env(0x0102030405) dev = MagicMock() # first download is desynced memory, second is a real envelope -> retry returns the good one dev.lf.download_samples.side_effect = [b"\xef\xbe\xad\xde" * 500, good] dev.lf.read.return_value = {"status": 0} data = _capture(dev, lambda: dev.lf.read(samples=15000)) assert data == good def test_capture_gives_up_all_garbage(self): from pm3py.lf.capture import _capture dev = MagicMock() dev.lf.download_samples.return_value = b"\xef\xbe\xad\xde" * 500 dev.lf.read.return_value = {"status": 0} assert _capture(dev, lambda: dev.lf.read(samples=15000)) == b""