diff --git a/pm3py/cli/rawcli/identify.py b/pm3py/cli/rawcli/identify.py index 8059404..7b1c52b 100644 --- a/pm3py/cli/rawcli/identify.py +++ b/pm3py/cli/rawcli/identify.py @@ -178,8 +178,10 @@ def identify(session, emit=None) -> dict: return {"found": True, "field": "hf", "protocol": "15693", "transponder": "ISO15693", "uid": uid15} - # LF: probe for a T5577 by reading its config block (block 0). A valid config word confirms - # a T5577 and — decoded — tells us what it's emulating. lf.search() can't do this (no demod). + # LF: no HF tag, so demodulate the field. pm3py.lf captures the envelope and decodes it — + # what the tag emits (EM4100 ID, …) and, via a block-0 read, whether it's actually a T5577 + # and what it's programmed to emulate. This is the real DSP path; the firmware only hands + # back raw samples, so there's no shortcut (lf.search() can't demodulate). lf = _identify_lf(dev, session, emit) if lf: return lf @@ -187,56 +189,19 @@ def identify(session, emit=None) -> dict: return {"found": False} -# Known T5577 config words → the tag it's set up to emulate (Proxmark3 config-block table). -_T5577_EMULATION = { - 0x00148040: "EM4100 / EM4102", - 0x00107060: "HID Prox", - 0x00081040: "Indala", - 0x903F0082: "FDX-B", - 0x00088040: "Viking", - 0x000880E8: "raw / default", -} - - -def _infer_emulation(cfg) -> str: - """From a decoded T5577 config, name the emulated tag (exact word, else modulation family).""" - exact = _T5577_EMULATION.get(int(cfg)) - if exact: - return exact - mod = cfg.modulation - if not isinstance(mod, str): - return "unknown" - family = ("EM/ASK" if mod == "ASK" else "HID/AWID" if mod.startswith("FSK") - else "Indala/PSK" if mod.startswith("PSK") else "FDX-B/Nedap" if "biphase" in mod - else mod) - return f"{family} · {mod} RF/{cfg.data_bit_rate}" - - def _identify_lf(dev, session, emit): - """Read T55xx block 0; if it decodes to a plausible config, report the T5577 and what it - emulates. Tightly gated so no-tag reads don't false-positive.""" - r = _try(lambda: dev.lf.t55.readbl(0), None) - if not isinstance(r, dict) or r.get("status") != 0: + """Adapter onto :func:`pm3py.lf.identify_lf` — run the LF demod and fold its result into the + session + the rawcli result shape. ``label`` is the breadcrumb name (``T5577 (EM4100 / + EM4102)`` for an emulator, ``EM4100 `` for a bare credential).""" + from pm3py.lf import identify_lf as _lf_identify + lf = _try(lambda: _lf_identify(dev, emit), None) + if not lf: return None - raw = r.get("raw") or b"" - if len(raw) < 4: - return None - config = int.from_bytes(raw[:4], "big") - if config in (0, 0xFFFFFFFF): - return None - try: - from pm3py.sim import T5577Config - cfg = T5577Config(config) - except Exception: - return None - if not isinstance(cfg.modulation, str) or not (1 <= cfg.max_block <= 8): - return None # implausible → probably no T5577 - emit(f" T55xx block 0 = 0x{config:08X} " - f"({cfg.modulation}, RF/{cfg.data_bit_rate}, {cfg.max_block} blocks)") session.field, session.protocol = "lf", "lf" - session.transponder = f"T5577 ({_infer_emulation(cfg)})" + session.transponder = lf["label"] return {"found": True, "field": "lf", "protocol": "lf", - "transponder": session.transponder, "config": f"{config:08X}"} + "transponder": lf["label"], "config": lf.get("config"), + "id": (lf.get("emitted") or {}).get("id_hex")} def _probe_version(dev, fmt, emit) -> tuple[str | None, bytes | None]: @@ -273,6 +238,8 @@ def format_summary(result: dict) -> str: detail = f"UID {uid.hex().upper() if isinstance(uid, (bytes, bytearray)) else uid}" elif result.get("config"): detail = f"config 0x{result['config']}" + elif result.get("id"): + detail = f"id {result['id']}" else: detail = "" head = f"{result['transponder']} ({result['field'].upper()} / {result['protocol']})" diff --git a/pm3py/lf/__init__.py b/pm3py/lf/__init__.py new file mode 100644 index 0000000..757903e --- /dev/null +++ b/pm3py/lf/__init__.py @@ -0,0 +1,22 @@ +"""Low-frequency signal demodulation. + +The Proxmark firmware returns *raw ADC envelope samples* for LF reads — the actual +demodulation (ASK/FSK/PSK, Manchester/biphase, protocol framing) is the client's job. The C +client has a large DSP stack for this; pm3py historically had none, so LF ``identify`` could +never see a tag. This package is that missing stack, in Python. + +Layers: + +* :mod:`pm3py.lf.dsp` — modulation-agnostic primitives (threshold, clock recovery, ASK + binarize, Manchester/biphase decode) that turn an envelope into a bit stream. +* :mod:`pm3py.lf.protocols` — per-tag framing (EM4100, T55xx config, HID, ...) that turns a + bit stream into a decoded credential, reusing the transponder models' own validators. +* :mod:`pm3py.lf.capture` — pull an envelope off a live device (or accept one for tests) and + run every decoder, returning the best match. + +Everything is testable without hardware: the LF transponder models already *encode* these +protocols, so a synthetic envelope built from an encoder round-trips through the demod. +""" +from .capture import demod_samples, read_config, identify_lf + +__all__ = ["demod_samples", "read_config", "identify_lf"] diff --git a/pm3py/lf/capture.py b/pm3py/lf/capture.py new file mode 100644 index 0000000..8b3c1ed --- /dev/null +++ b/pm3py/lf/capture.py @@ -0,0 +1,104 @@ +"""Live-device LF capture + identify. + +``demod_samples`` is the hardware-free heart: an envelope in, a decoded credential out — every +protocol decoder tried in turn. ``identify_lf`` drives a real device: it captures what the tag +*emits* (a plain read) and, separately, probes for a T55xx by reading config block 0, then +combines the two into "is it a T5577, and what is it (emulating)?". +""" +from __future__ import annotations + +from . import protocols + +# how many envelope samples to pull from BigBuf after a capture (a T55xx/EM frame is a few +# thousand samples at RF/64; this spans several repeats for a clean lock) +_SAMPLE_COUNT = 15000 + +# every emitted-credential decoder, tried in order (headered/most-specific first) +_DECODERS = ( + protocols.decode_em4100, +) + + +def demod_samples(samples) -> dict | None: + """Decode a raw LF envelope to a credential by trying each protocol demod in turn. + + Hardware-free and the single point every decoder funnels through — feed it a synthetic + envelope from a transponder encoder in tests, or a BigBuf download at runtime.""" + if not samples: + return None + for decode in _DECODERS: + result = decode(samples) + if result is not None: + return result + return None + + +def _download(device, count: int = _SAMPLE_COUNT) -> bytes: + """Pull the last capture's envelope out of the device BigBuf (best-effort -> b'').""" + try: + return device.lf.download_samples(count=count) or b"" + except Exception: + return b"" + + +def read_emitted(device) -> dict | None: + """Capture and decode whatever the tag is emitting in the field (no downlink) — the EM4100 + ID a tag (native or T5577-emulated) streams continuously.""" + try: + device.lf.read(samples=_SAMPLE_COUNT) + except Exception: + return None + return demod_samples(_download(device)) + + +def read_config(device) -> dict | None: + """Probe for a T55xx by reading config block 0 and demodulating the response. A clean, + sane config both proves the chip is a T5577 and says what it's programmed to emulate.""" + try: + device.lf.t55.readbl(0) # fills BigBuf with the block-0 response envelope + except Exception: + return None + return protocols.decode_t55xx_config(_download(device)) + + +def identify_lf(device, emit=None) -> dict | None: + """Full LF identify against a live device. Returns a result dict (``found``/``field``/ + ``label``/``chip``/``config``/``emulating``/``emitted``) or ``None`` if the device is + unusable. ``emit`` streams human-readable progress lines to the caller's trace.""" + emit = emit or (lambda _l: None) + if device is None: + return None + + config = read_config(device) # T55xx? -> chip + emulation + emitted = read_emitted(device) # what's on the air -> EM4100 ID (etc.) + + if config is None and emitted is None: + return None + + chip = None + emulating = None + if config is not None: + chip = "T5577" + emulating = config["emulating"] + emit(f" T55xx block 0 = 0x{config['config_hex']} " + f"({config['modulation']}, RF/{config['data_bit_rate']}, {config['max_block']} blocks)" + f" -> {emulating}") + if emitted is not None: + emit(f" emitted: {emitted['protocol']} id {emitted['id_hex']}") + + # label: prefer the physical chip + what it emulates; else the emitted credential + if chip: + label = f"{chip} ({emulating})" + else: + label = f"{emitted['protocol']} {emitted['id_hex']}" + + return { + "found": True, + "field": "lf", + "protocol": "lf", + "chip": chip, + "config": config["config_hex"] if config else None, + "emulating": emulating, + "emitted": emitted, + "label": label, + } diff --git a/pm3py/lf/dsp.py b/pm3py/lf/dsp.py new file mode 100644 index 0000000..014d94b --- /dev/null +++ b/pm3py/lf/dsp.py @@ -0,0 +1,131 @@ +"""Modulation-agnostic LF DSP primitives. + +Input everywhere is a Proxmark LF *envelope*: one unsigned byte (0-255) per carrier period +(one "field clock"), as returned by ``lf.download_samples``. So a tag clocked at RF/64 spans +64 samples per data bit, RF/32 spans 32, and so on — the RF/n divider *is* the samples-per-bit. + +The functions here recover a bit stream from that envelope without knowing the protocol: +threshold to binary, detect the bit clock, then slice/decode (Manchester or biphase). Framing +(headers, parity, credential fields) lives in :mod:`pm3py.lf.protocols`. +""" +from __future__ import annotations + +# T55xx standard data-bit-rate set (RF/n) — samples-per-bit candidates when the clock is +# unknown. x-mode allows any even n; the reader still recovers those via the generic detector. +RF_RATES = (8, 16, 32, 40, 50, 64, 100, 128) + + +def _percentile(sorted_vals: list[int], frac: float) -> float: + """Value at ``frac`` (0..1) through an already-sorted list; robust to spikes.""" + if not sorted_vals: + return 0.0 + idx = min(len(sorted_vals) - 1, max(0, round(frac * (len(sorted_vals) - 1)))) + return float(sorted_vals[idx]) + + +def threshold(samples) -> float: + """A robust high/low decision level for an ASK/OOK envelope. + + The midpoint between the 5th and 95th percentiles, so a few saturated or dropped samples + don't drag it (a plain min/max would, and the duty cycle isn't reliably 50% so the mean + can bias). Returns 0.0 for an empty/flat capture.""" + if not samples: + return 0.0 + s = sorted(samples) + lo, hi = _percentile(s, 0.05), _percentile(s, 0.95) + return (lo + hi) / 2.0 + + +def is_flat(samples, min_swing: int = 16) -> bool: + """True if the capture has too little dynamic range to carry data (no tag / dead field).""" + if len(samples) < 8: + return True + s = sorted(samples) + return (_percentile(s, 0.95) - _percentile(s, 0.05)) < min_swing + + +def binarize(samples, level: float | None = None) -> list[int]: + """Envelope -> list of 0/1 by thresholding (ASK/OOK).""" + if level is None: + level = threshold(samples) + return [1 if v > level else 0 for v in samples] + + +def _edges(binary: list[int]) -> list[int]: + return [i for i in range(1, len(binary)) if binary[i] != binary[i - 1]] + + +def detect_clock(binary: list[int], min_run: int = 3) -> float | None: + """Estimate the shortest symbol width (samples) from the edge-interval distribution. + + For Manchester this is the *half-bit* (there is always a mid-bit transition, so runs are + one or two half-bits); for plain OOK it is the bit itself. Runs shorter than ``min_run`` + are treated as noise/jitter and ignored. Returns the mean of the shortest interval cluster, + or ``None`` when there aren't enough clean edges to decide.""" + edges = _edges(binary) + if len(edges) < 4: + return None + intervals = [edges[i] - edges[i - 1] for i in range(1, len(edges)) if edges[i] - edges[i - 1] >= min_run] + if len(intervals) < 3: + return None + intervals.sort() + base = intervals[0] + # average the cluster within 1.5x of the shortest interval -> the fundamental symbol width + cluster = [d for d in intervals if d <= base * 1.5] + return sum(cluster) / len(cluster) + + +def resample(binary: list[int], symbol: float, offset: float = 0.5) -> list[int]: + """Sample ``binary`` once per ``symbol``-wide window (majority vote), starting at + ``offset`` symbols in. Turns a per-sample stream into a per-symbol (e.g. per-half-bit) + stream at the recovered clock.""" + if symbol <= 0: + return [] + out: list[int] = [] + n = len(binary) + pos = offset * symbol + while pos < n: + lo = max(0, int(pos - symbol / 4)) + hi = min(n, int(pos + symbol / 4) + 1) + window = binary[lo:hi] or [binary[min(int(pos), n - 1)]] + out.append(1 if sum(window) * 2 >= len(window) else 0) + pos += symbol + return out + + +def manchester_decode(halfbits: list[int], phase: int = 0, invert: bool = False): + """Decode a half-bit stream as Manchester: each data bit is a pair of opposite half-bits. + + ``phase`` (0/1) picks the pairing alignment; ``invert`` swaps the polarity convention + (``10`` -> 1 vs ``01`` -> 1). Pairs that are ``00``/``11`` are clock slips — returned as + ``None`` markers so callers can resync. Returns the decoded bit list (with ``None`` gaps). + Callers typically try both phases/polarities and keep the alignment that frames cleanly.""" + bits: list[int | None] = [] + for i in range(phase, len(halfbits) - 1, 2): + a, b = halfbits[i], halfbits[i + 1] + if a == b: + bits.append(None) # not a valid Manchester symbol -> clock slip + else: + bit = 1 if a == 1 else 0 # (1,0) -> 1, (0,1) -> 0 + bits.append(bit ^ 1 if invert else bit) + return bits + + +def biphase_decode(halfbits: list[int], phase: int = 0, invert: bool = False): + """Decode a half-bit stream as biphase (differential Manchester / FDX-B style): a data + ``0`` has a mid-bit transition (half-bits differ), a data ``1`` does not (half-bits equal). + ``phase`` aligns the pairing; ``invert`` swaps the 0/1 convention.""" + bits: list[int] = [] + for i in range(phase, len(halfbits) - 1, 2): + differ = halfbits[i] != halfbits[i + 1] + bit = 0 if differ else 1 + bits.append(bit ^ 1 if invert else bit) + return bits + + +def bits_to_int(bits: list[int]) -> int: + """MSB-first bit list -> integer.""" + v = 0 + for b in bits: + v = (v << 1) | (b & 1) + return v diff --git a/pm3py/lf/protocols.py b/pm3py/lf/protocols.py new file mode 100644 index 0000000..0eb8745 --- /dev/null +++ b/pm3py/lf/protocols.py @@ -0,0 +1,139 @@ +"""Per-tag LF framing: bit stream -> decoded credential. + +Each decoder takes a raw envelope, runs the generic :mod:`pm3py.lf.dsp` pipeline (trying both +Manchester phase/polarity alignments), and validates candidate frames with the *transponder +model's own* checker — ``EM4100Code.valid``, ``T5577Config`` sanity — so framing rules live in +exactly one place. A decoder returns a structured dict on a clean, validated frame, else ``None``. +""" +from __future__ import annotations + +from . import dsp +# Import the models through the ``pm3py.sim`` package (not their deep module paths): sim's +# __init__ re-exports them, and going through it forces sim to fully initialize first, avoiding +# a circular import (em4100 -> pm3py.sim.frame -> sim.__init__ -> em4100) when pm3py.lf is the +# first thing loaded. +from pm3py.sim import EM4100Code, T5577Config + +# config word -> preset name (invert T5577Config._PRESETS) for "emulating ..." labelling +_PRESET_BY_WORD = {word: name for name, word in T5577Config._PRESETS.items()} + + +def _manchester_bits(samples): + """Envelope -> (data-bit stream, half-bit width). Yields each (phase, polarity) alignment's + bit list so a caller can pick whichever frames cleanly. Empty if the capture can't clock.""" + if dsp.is_flat(samples): + return + binary = dsp.binarize(samples) + half = dsp.detect_clock(binary) + if not half: + return + halfbits = dsp.resample(binary, half) + for phase in (0, 1): + for invert in (False, True): + yield dsp.manchester_decode(halfbits, phase, invert) + + +def decode_em4100(samples) -> dict | None: + """Demodulate an EM4100/EM4102 envelope (ASK + Manchester) to its 40-bit ID. + + Searches the demodulated stream for a 64-bit frame whose header, ten row parities, four + column parities and stop bit all check out (``EM4100Code.valid``) — the frame repeats, so a + clean copy is found even if the capture starts mid-frame.""" + for bits in _manchester_bits(samples): + code = _find_em4100(bits) + if code is not None: + return { + "protocol": "EM4100", + "tag_id": code.tag_id, + "id_hex": f"{code.tag_id:010X}", + "customer_id": code.customer_id, + "card_number": code.card_number, + } + return None + + +def _find_em4100(bits) -> EM4100Code | None: + n = len(bits) + for start in range(max(0, n - 63)): + window = bits[start:start + 64] + if len(window) < 64 or None in window: + continue + code = EM4100Code(dsp.bits_to_int(window)) + if code.valid: + return code + return None + + +def decode_t55xx_config(samples) -> dict | None: + """Recover a T55xx block-0 config word from a block-0 read response (ASK/Manchester modes). + + A block read streams the 32-bit word with no header, so alignment is found by the word + repeating (block value at offset N equals offset N+32) and the candidate being a *sane* + config (known modulation, valid master key, 1..7 data blocks). Best-effort by nature — the + reader labels it as a probable config, not gospel. Returns ``None`` for FSK/PSK-mode tags + (handled elsewhere) or when nothing repeats cleanly.""" + best = None + for bits in _manchester_bits(samples): + clean = [b for b in bits if b is not None] + cand = _find_repeating_word(clean, 32) + if cand is None: + continue + cfg = T5577Config(cand) + score = _config_score(cfg) + if best is None or score > best[0]: + best = (score, cand, cfg) + if best is None or best[0] < 2: # need at least a known modulation + sane structure + return None + _, word, cfg = best + return { + "protocol": "T5577", + "config": word, + "config_hex": f"{word:08X}", + "modulation": cfg.modulation if isinstance(cfg.modulation, str) else f"0x{cfg.modulation:02X}", + "data_bit_rate": cfg.data_bit_rate, + "max_block": cfg.max_block, + "emulating": emulation_name(word, cfg), + } + + +def _find_repeating_word(bits, width: int) -> int | None: + """Smallest-offset ``width``-bit window that equals the next ``width`` bits (one block + period), scored by config sanity; returns the best repeating word's value or ``None``.""" + n = len(bits) + best = None + for start in range(max(0, n - 2 * width)): + w = bits[start:start + width] + if w == bits[start + width:start + 2 * width]: + val = dsp.bits_to_int(w) + score = _config_score(T5577Config(val)) + if best is None or score > best[0]: + best = (score, val) + return best[1] if best else None + + +def _config_score(cfg: T5577Config) -> int: + score = 0 + if isinstance(cfg.modulation, str): # decoded to a known scheme + score += 1 + if cfg.master_key in (0, 6, 9): # normal / extended-mode keys + score += 1 + if 1 <= cfg.max_block <= 7: + score += 1 + return score + + +def emulation_name(word: int, cfg: T5577Config | None = None) -> str: + """Human label for what a T5577 config emulates: an exact preset match if we have one + (EM4100, HID, Indala, FDX-B, Viking, default), else the modulation/rate family.""" + exact = _PRESET_BY_WORD.get(word) + if exact: + return {"em4100": "EM4100 / EM4102", "hid": "HID Prox", "indala": "Indala", + "fdxb": "FDX-B", "viking": "Viking", "default": "raw / default"}.get(exact, exact) + cfg = cfg or T5577Config(word) + mod = cfg.modulation if isinstance(cfg.modulation, str) else "?" + family = ("EM/ASK" if mod == "ASK" + else "HID/AWID" if mod.startswith("FSK") + else "Indala/PSK" if mod.startswith("PSK") + else "FDX-B/Nedap" if "biphase" in mod + else mod) + return f"{family} · {mod} RF/{cfg.data_bit_rate}" diff --git a/tests/test_lf_demod.py b/tests/test_lf_demod.py new file mode 100644 index 0000000..4faa4d9 --- /dev/null +++ b/tests/test_lf_demod.py @@ -0,0 +1,151 @@ +"""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, demod_samples, read_config, identify_lf +from pm3py.lf import protocols +from pm3py.transponders.lf.em.em4100 import EM4100Tag +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) + + +# --- 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 + + +# --- 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_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 diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index e268e76..e430359 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -10,7 +10,7 @@ from pm3py.cli.rawcli.session import RawSession from pm3py.cli.rawcli.trace_view import render_exchange from pm3py.cli.rawcli.parser import parse_token, parse_bytes, parse_line from pm3py.cli.rawcli.entry import byte_space -from pm3py.cli.rawcli.identify import identify, clear, name_14a +from pm3py.cli.rawcli.identify import identify, clear, name_14a, format_summary from pm3py.cli.rawcli.catalog import TYPE2, ISO15, catalog_for, catalog_for as _cf from pm3py.cli.rawcli.tlv import parse_tlv, format_tlv from pm3py.cli.rawcli.completer import RawCompleter @@ -182,7 +182,9 @@ def _mock_device(scan14=None, scan15=None, version=None): dev.hf.iso14a.scan.return_value = scan14 or {"found": False} dev.hf.iso15.scan.return_value = scan15 or {"found": False} dev.lf.search.return_value = None - dev.lf.t55.readbl.return_value = {"status": 1, "raw": b""} # no T55xx by default + dev.lf.t55.readbl.return_value = {"status": 0} + dev.lf.read.return_value = {"status": 0} + dev.lf.download_samples.return_value = bytes([128] * 2000) # flat -> no LF tag by default dev.hf.iso14a.raw.return_value = {"raw": (version + b"\x99\x88") if version else None} return dev @@ -222,27 +224,36 @@ class TestIdentify: r = identify(s) assert r["found"] is False and s.transponder is None - def test_no_false_lf(self): - # nothing on HF and lf.search() truthy must report not-found, not a bogus "LF tag" - dev = _mock_device() - dev.lf.search.return_value = object() # always-truthy search result - s = RawSession(device=dev) - assert identify(s)["found"] is False and s.transponder is None + def test_no_flat_lf(self): + # nothing on HF and a flat LF envelope must report not-found, not a bogus "LF tag" + assert identify(RawSession(device=_mock_device()))["found"] is False - def test_lf_t5577_reports_emulation(self): - # T55xx block 0 = EM4100 config -> "T5577 (EM4100 / EM4102)" - dev = _mock_device() - dev.lf.t55.readbl.return_value = {"status": 0, "raw": bytes.fromhex("00148040")} - s = RawSession(device=dev) + def test_lf_wiring_sets_label(self, monkeypatch): + # the LF path folds pm3py.lf.identify_lf's result into the session (demod itself is + # covered in test_lf_demod.py); here we assert the rawcli wiring/label/summary. + import pm3py.lf + monkeypatch.setattr(pm3py.lf, "identify_lf", lambda dev, emit=None: { + "found": True, "field": "lf", "protocol": "lf", "chip": "T5577", + "config": "00148040", "emulating": "EM4100 / EM4102", + "emitted": {"protocol": "EM4100", "id_hex": "0102030405"}, + "label": "T5577 (EM4100 / EM4102)"}) + s = RawSession(device=_mock_device()) # HF finds nothing -> LF path r = identify(s) assert r["found"] and s.field == "lf" and s.protocol == "lf" assert s.transponder == "T5577 (EM4100 / EM4102)" - assert r["config"] == "00148040" + assert r["config"] == "00148040" and r["id"] == "0102030405" + assert "config 0x00148040" in format_summary(r) - def test_lf_no_t5577_not_found(self): - dev = _mock_device() - dev.lf.t55.readbl.return_value = {"status": 1, "raw": b""} # no T55xx response - assert identify(RawSession(device=dev))["found"] is False + def test_lf_native_credential_label(self, monkeypatch): + import pm3py.lf + monkeypatch.setattr(pm3py.lf, "identify_lf", lambda dev, emit=None: { + "found": True, "field": "lf", "protocol": "lf", "chip": None, "config": None, + "emulating": None, "emitted": {"protocol": "EM4100", "id_hex": "1122334455"}, + "label": "EM4100 1122334455"}) + s = RawSession(device=_mock_device()) + r = identify(s) + assert s.transponder == "EM4100 1122334455" + assert "id 1122334455" in format_summary(r) def test_identify_clears_stale_result(self): # a second identify that finds nothing must clear the previous tag from the session @@ -251,7 +262,6 @@ class TestIdentify: identify(s) assert s.transponder == "MIFARE Classic 1K" dev.hf.iso14a.scan.return_value = {"found": False} # tag removed - dev.lf.t55.readbl.return_value = {"status": 1, "raw": b""} identify(s) assert s.transponder is None and s.field is None # no stale data