Files
pm3py/pm3py/lf/capture.py
michael a0b70a1984 feat(rawcli): command catalogs + hints for identified LF chips
catalog_for() returned None for LF, so after identifying a T5577/EM4100 the completer and
input_hint had nothing — no command suggestions, no hex/binary opcode hints. Now LF chips
get catalogs like the HF ones:

- T5577 catalog: READ(block) / WRITE(block, data) / WAKE(password) / DETECT(), with the
  downlink opcode exposed via build() for hex+binary completion, block-role hints (0=config,
  7=password, 1-6=data), and execution via a new run() path (LF isn't a raw-byte exchange —
  it drives lf.t55 / the demod). READ(0)/DETECT use the reliable rotation-fixed config read;
  data-block reads are best-effort and labelled as such. capture.read_t55xx_block added.
- LF read-only credentials (native EM4100/HID/AWID/FDX-B) get a minimal catalog (INFO -> re-read).
- catalog_for dispatches protocol "lf": "T5577" in the label -> T5577, else read-only.
- TagCommand gains an optional run(device, *args); completer/_opcode tolerate build=None.

Hardware-verified: identify -> catalog T5577, help lists the commands, DETECT()/READ(0) return
config 00148040 (EM4100). Tests: LF resolver, T5577 opcodes, T5577 block-role hints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:53:15 -07:00

192 lines
7.7 KiB
Python

"""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
import inspect
from . import ask
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,
protocols.decode_hid,
protocols.decode_awid,
protocols.decode_fdxb,
)
def credential_label(c: dict) -> str:
"""One-line name for a decoded credential, per protocol (each decoder returns its own fields)."""
p = c.get("protocol", "?")
if p == "EM4100":
return f"EM4100 {c['id_hex']}"
if p == "HID Prox":
return f"HID Prox {c['format']} FC {c['facility_code']} Card {c['card_number']}"
if p == "AWID":
return f"{c['format']} FC {c['facility_code']} Card {c['card_number']}"
if p == "FDX-B":
return f"FDX-B {c['id']}" + (" (animal)" if c.get("animal") else "")
return p
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
# download a fixed, safe span rather than trusting lf.read()'s reported count — on a flaky NG
# link that count is sometimes garbage, and over-reading spills into BigBuf memory past the
# samples. 12000 is under a normal acquisition, enough frames for any LF frame to repeat.
_DOWNLOAD_BYTES = 12000
# markers that a "sample" buffer is actually desynced BigBuf memory, not an envelope
_POISON = b"\xef\xbe\xad\xde" # 0xDEADBEEF — firmware stack canary
_RESP_MAGIC = b"\x50\x4d\x33\x62" # 0x62334d50 — a PM3 response frame sitting in the buffer
def _try(fn, default):
try:
return fn()
except Exception:
return default
def _looks_like_samples(data: bytes) -> bool:
"""Reject a download that is clearly desynced BigBuf memory rather than an LF envelope: the
DEADBEEF stack canary, an embedded PM3 response magic, or a mostly-zero (unwritten) buffer."""
if len(data) < 256:
return False
if _POISON in data or _RESP_MAGIC in data:
return False
return data.count(0) < len(data) // 2
def _download(device, count: int = _DOWNLOAD_BYTES) -> 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 _capture(device, do_read, count: int = _DOWNLOAD_BYTES, tries: int = 3) -> bytes:
"""Read + download a clean LF envelope, retrying past the intermittent desyncs a flaky NG
link produces. Returns validated sample bytes, or ``b''`` if every attempt came back as
memory (device likely needs a power-cycle / lower debug level)."""
for _ in range(tries):
if do_read() is None:
continue
data = _download(device, count=count)
if _looks_like_samples(data):
return data
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."""
data = _capture(device, lambda: _try(lambda: device.lf.read(samples=15000), None))
return demod_samples(data) if data else None
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."""
data = _capture(device, lambda: _try(lambda: device.lf.t55.readbl(0), None))
return protocols.decode_t55xx_config(data) if data else None
def read_t55xx_block(device, block: int) -> int | None:
"""Best-effort read of a T55xx 32-bit data block: send the block read, demod the first
repeating 32-bit word. A bare block has no header/CRC, so the bit alignment (rotation) can't
be verified — this is inherently best-effort. Block 0 (config) is the reliable one; read it
via read_config, which resolves the rotation against known presets."""
data = _capture(device, lambda: _try(lambda: device.lf.t55.readbl(int(block)), None))
if not data:
return None
for bits in ask.manchester_bits(data):
clean = [b for b in bits if b is not None]
for word in protocols._repeating_words(clean, 32):
return word
return None
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
# capture drives the device *synchronously* (as Proxmark3.sync()'s proxy exposes it). A raw
# async Proxmark3() returns un-awaited coroutines here — fail with an actionable message
# instead of a cryptic "coroutine has no len()" deep in the demod.
if inspect.iscoroutinefunction(getattr(getattr(device, "lf", None), "read", None)):
raise TypeError(
"identify_lf needs a synchronous device — use Proxmark3.sync(), not Proxmark3(). "
"(A bare Proxmark3() is the async API and isn't connected.)"
)
# Force the device debug level to NONE for the duration: on debug-heavy firmware a raised
# g_dbglevel makes LF acquisition emit Dbprintf frames over USB, which interleave with the
# sample download and desync it. This is the device-side knob (not the C client's g_debugMode
# spam), and pm3py's own reads are what matter here.
_try(lambda: device.hw.dbg(0), 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: {credential_label(emitted)}")
# label: name the chip and what it's actually emitting (the reliable, decoded credential);
# fall back to the config's emulation guess, or just the emitted credential.
if chip and emitted:
label = f"{chip} ({credential_label(emitted)})"
elif chip:
label = f"{chip} ({emulating})"
else:
label = credential_label(emitted)
return {
"found": True,
"field": "lf",
"protocol": "lf",
"chip": chip,
"config": config["config_hex"] if config else None,
"emulating": emulating,
"emitted": emitted,
"label": label,
}