From 8726681e06df128253a1816c250b3f6f0daabd64 Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 14 Jul 2026 13:31:35 -0700 Subject: [PATCH] feat(rawcli): LF T5577 identify + clear stale result per identify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - identify clears the previous field/protocol/transponder up front, so consecutive identifies (esp. when swapping tags) never show stale data. - LF identify: probe a T5577 by reading config block 0 (lf.t55.readbl(0)). A valid config confirms the T5577 and, decoded via T5577Config, names what it's emulating — "T5577 (EM4100 / EM4102)", "T5577 (HID Prox)", etc. (exact-word map, else modulation family). Tightly gated (status ok, non-trivial config, known modulation, sane max_block) so no-tag reads don't false-positive; lf.search() couldn't do this without demod. - format_summary shows the config word for LF (no UID). 4 tests (T5577 emulation report, no-T5577, stale-clear). Co-Authored-By: Claude Opus 4.8 --- pm3py/cli/rawcli/identify.py | 76 +++++++++++++++++++++++++++++++++--- tests/test_rawcli.py | 27 +++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/pm3py/cli/rawcli/identify.py b/pm3py/cli/rawcli/identify.py index 5bfe1bb..8059404 100644 --- a/pm3py/cli/rawcli/identify.py +++ b/pm3py/cli/rawcli/identify.py @@ -141,6 +141,10 @@ def identify(session, emit=None) -> dict: each annotated trace line (default: no trace). Returns a summary dict.""" emit = emit or (lambda _line: None) dev = session.device + # clear the previous result up front — a new identify must never show stale field/transponder + session.field = None + session.transponder = None + session.protocol = "hf14a" if dev is None: return {"found": False, "error": "no device connected"} @@ -174,11 +178,67 @@ def identify(session, emit=None) -> dict: return {"found": True, "field": "hf", "protocol": "15693", "transponder": "ISO15693", "uid": uid15} - # LF: lf.search() only captures samples — it can't confirm a tag without demod, so it would - # always false-positive. Reliable LF identify (T55xx probe / EM4100 demod) is a follow-up. + # 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 = _identify_lf(dev, session, emit) + if lf: + return lf + 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: + 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)})" + return {"found": True, "field": "lf", "protocol": "lf", + "transponder": session.transponder, "config": f"{config:08X}"} + + def _probe_version(dev, fmt, emit) -> tuple[str | None, bytes | None]: """Send a real GET_VERSION (0x60 +CRC), trace the exchange, decode the exact model.""" cmd = b"\x60" @@ -208,6 +268,12 @@ def clear(session) -> None: def format_summary(result: dict) -> str: if not result.get("found"): return f"no tag found{': ' + result['error'] if result.get('error') else ''}" - uid = result.get("uid") - uid_hex = uid.hex().upper() if isinstance(uid, (bytes, bytearray)) else (uid or "?") - return f"{result['transponder']} ({result['field'].upper()} / {result['protocol']}) UID {uid_hex}" + if result.get("uid"): + uid = result["uid"] + detail = f"UID {uid.hex().upper() if isinstance(uid, (bytes, bytearray)) else uid}" + elif result.get("config"): + detail = f"config 0x{result['config']}" + else: + detail = "" + head = f"{result['transponder']} ({result['field'].upper()} / {result['protocol']})" + return f"{head} {detail}".rstrip() diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index 885834a..e268e76 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -182,6 +182,7 @@ 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.hf.iso14a.raw.return_value = {"raw": (version + b"\x99\x88") if version else None} return dev @@ -228,6 +229,32 @@ class TestIdentify: s = RawSession(device=dev) assert identify(s)["found"] is False and s.transponder is None + 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) + r = identify(s) + assert r["found"] and s.field == "lf" and s.protocol == "lf" + assert s.transponder == "T5577 (EM4100 / EM4102)" + assert r["config"] == "00148040" + + 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_identify_clears_stale_result(self): + # a second identify that finds nothing must clear the previous tag from the session + dev = _mock_device(scan14={"found": True, "sak": 0x08, "atqa": "0400", "uid": "b978423d"}) + s = RawSession(device=dev) + 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 + def test_sak_names_from_an10833(self): assert name_14a({"sak": 0x18}) == "MIFARE Classic 4K" assert name_14a({"sak": 0x20}).startswith("ISO14443-4")