feat(rawcli): LF T5577 identify + clear stale result per identify

- 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 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-14 13:31:35 -07:00
parent 0775df4d54
commit 8726681e06
2 changed files with 98 additions and 5 deletions

View File

@@ -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()

View File

@@ -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")