identify_lf built its label from emitted['id_hex'], which only EM4100 provides — a native HID/AWID/FDX-B tag (no T5577 config) would KeyError. Add credential_label() to format each decoder's own fields: "HID Prox H10301 FC.. Card..", "AWID-26 FC.. Card..", "FDX-B 124-000000000555 (animal)", "EM4100 <id>". Tests for native FDX-B + HID labels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
365 lines
14 KiB
Python
365 lines
14 KiB
Python
"""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.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_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)
|
|
|
|
|
|
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_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"
|
|
assert r["label"] == "T5577 (EM4100 / EM4102)"
|
|
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
|