The page-role annotations and completion were gated on identify resolving an *exact* model string (NTAG216). On a flaky NG link a single lost GET_VERSION dropped identify to the generic SAK name "MIFARE Ultralight / NTAG", which isn't in the layout table — so every region annotation and memory-map completion silently went blank. (The happy-path checks always got a clean identify, so this never showed in earlier verification.) Two fixes: - _probe_version retries GET_VERSION up to 3x, re-selecting between attempts, so a lost shot on a flaky link no longer costs the exact model (and its full memory map). - memory._resolve_layout falls back to a generic Type 2 map (UID / CC / the always-user pages 0x04-0x0F) when only the NTAG/Ultralight family is known — so READ(0)/READ(4) still annotate even when the exact model can't be determined (or an original Ultralight has no GET_VERSION). Config pages differ by model and are left unnamed rather than guessed. Tests: retry recovers the model after two lost shots; a never-answering GET_VERSION yields the generic name yet still annotates the universal pages; generic names resolve UID/CC/user but not model-specific pages. 1328 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
253 lines
11 KiB
Python
253 lines
11 KiB
Python
"""Tag identification for rawcli.
|
||
|
||
``identify`` doesn't just select — it *probes*, and every raw exchange it uses to figure the tag
|
||
out is streamed to the trace (reconstructed 14a activation + a real GET_VERSION). The GET_VERSION
|
||
response is reverse-mapped against the transponder models' own ``_version_bytes`` to name the
|
||
exact chip (NTAG216, not "MIFARE UL/NTAG"); the version's storage-size byte is what separates
|
||
NTAG213/215/216 and the Ultralight EV1 variants. The 14a scan uses NO_DISCONNECT, so the tag stays
|
||
selected for the follow-up probes — the persistent connection.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
|
||
from pm3py.trace import TraceFormatter, decode_15693
|
||
|
||
ISO14A_APPEND_CRC = 0x20 # pm3_cmd.h: append CRC to a raw 14a frame
|
||
|
||
# SAK → 14a family for tags without GET_VERSION (NXP AN10833 "MIFARE type identification
|
||
# procedure", Table 4). Used as the fallback when GET_VERSION isn't available/applicable.
|
||
_SAK_NAMES = {
|
||
0x00: "MIFARE Ultralight / NTAG", # -> GET_VERSION for the exact IC
|
||
0x04: "UID incomplete (cascade)",
|
||
0x08: "MIFARE Classic 1K",
|
||
0x09: "MIFARE Mini",
|
||
0x10: "MIFARE Plus 2K (SL2)",
|
||
0x11: "MIFARE Plus 4K (SL2)",
|
||
0x18: "MIFARE Classic 4K",
|
||
0x20: "ISO14443-4 (DESFire / Plus SL3 / …)",
|
||
0x28: "MIFARE Classic 1K + NFC-P2P",
|
||
0x38: "MIFARE Classic 4K + NFC-P2P",
|
||
}
|
||
|
||
# GET_VERSION byte-2 lower nibble → product family (AN10833 Table 1). Upper nibble is the HW
|
||
# type (0x0 native, 0x8 implementation, 0x9 Java Card applet, 0xA MIFARE 2GO / DUOX / X DNA).
|
||
_GETVER_PRODUCT = {
|
||
0x1: "MIFARE DESFire", 0x2: "MIFARE Plus", 0x3: "MIFARE Ultralight",
|
||
0x4: "NTAG", 0x7: "NTAG I2C", 0x8: "MIFARE DESFire Light",
|
||
}
|
||
|
||
# GET_VERSION 8-byte response → exact model. Mirrors the transponder models' `_version_bytes`
|
||
# (kept static here to avoid an import-order circular; a test asserts they stay in sync).
|
||
_VERSION_MODELS = {
|
||
"0004040101000b03": "NTAG210",
|
||
"0004040101000e03": "NTAG212",
|
||
"0004040201000f03": "NTAG213",
|
||
"0004040201001103": "NTAG215",
|
||
"0004040201001303": "NTAG216",
|
||
"0004030101000b03": "MIFARE Ultralight EV1 (MF0UL11)",
|
||
"0004030101000e03": "MIFARE Ultralight EV1 (MF0UL21)",
|
||
"0004040502021303": "NTAG I2C plus 1K (NT3H2111)",
|
||
"0004040502021503": "NTAG I2C plus 2K (NT3H2211)",
|
||
}
|
||
|
||
|
||
def _storage_range(byte: int) -> str:
|
||
"""GET_VERSION storage-size byte → memory-size string. The 7 MSBs code 2^n; the LSB set
|
||
means the size is *between* 2^n and 2^(n+1) (AN10833)."""
|
||
lo = 1 << (byte >> 1)
|
||
return f"{lo}–{lo * 2} B" if byte & 1 else f"{lo} B"
|
||
|
||
|
||
def decode_version(version: bytes) -> str | None:
|
||
"""Name the chip from an 8-byte GET_VERSION response: exact match against the known models,
|
||
else a structural best-effort (product family + memory-size range, AN10833)."""
|
||
exact = _VERSION_MODELS.get(bytes(version[:8]).hex())
|
||
if exact:
|
||
return exact
|
||
if len(version) >= 7 and version[1] == 0x04: # NXP vendor
|
||
family = _GETVER_PRODUCT.get(version[2] & 0x0F, f"NXP type {version[2]:#04x}")
|
||
return f"{family} ({_storage_range(version[6])})"
|
||
return None
|
||
|
||
|
||
def name_14a(scan: dict) -> str:
|
||
sak = scan.get("sak")
|
||
if isinstance(sak, int):
|
||
return _SAK_NAMES.get(sak, f"ISO14443-A (SAK {sak:02X})")
|
||
return "ISO14443-A"
|
||
|
||
|
||
def _ident_decode(direction: int, payload: bytes) -> str | None:
|
||
"""Annotator for the identify probe frames (activation + GET_VERSION)."""
|
||
if not payload:
|
||
return None
|
||
if direction == 0: # reader → tag
|
||
head = payload[:2]
|
||
if payload[:1] == b"\x52":
|
||
return "WUPA"
|
||
if payload[:1] == b"\x26":
|
||
return "REQA"
|
||
if payload[0] in (0x93, 0x95):
|
||
cl = 1 if payload[0] == 0x93 else 2
|
||
if len(payload) >= 2 and payload[1] == 0x20:
|
||
return f"ANTICOLLISION CL{cl}"
|
||
if len(payload) >= 2 and payload[1] == 0x70:
|
||
return f"SELECT CL{cl}"
|
||
if payload[0] == 0x60:
|
||
return "GET_VERSION"
|
||
if payload[0] == 0x50:
|
||
return "HALT"
|
||
else: # tag → reader
|
||
if len(payload) >= 8 and payload[0] == 0x00 and payload[1] == 0x04:
|
||
return "VERSION"
|
||
return None
|
||
|
||
|
||
def _activation_frames(scan: dict) -> list[tuple[int, bytes]]:
|
||
"""Reconstruct the ISO 14443-3A activation (WUPA/ATQA/anticollision/SELECT/SAK) from the scan
|
||
result, so the user sees the select the reader performed."""
|
||
try:
|
||
uid = bytes.fromhex(scan.get("uid") or "")
|
||
atqa = bytes.fromhex(scan.get("atqa") or "")
|
||
except ValueError:
|
||
return []
|
||
sak = scan.get("sak", 0)
|
||
frames: list[tuple[int, bytes]] = [(0, b"\x52"), (1, atqa)]
|
||
if len(uid) == 4:
|
||
bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
|
||
frames += [(0, b"\x93\x20"), (1, uid + bytes([bcc])),
|
||
(0, b"\x93\x70" + uid + bytes([bcc])), (1, bytes([sak]))]
|
||
elif len(uid) == 7:
|
||
ct = 0x88
|
||
b1 = ct ^ uid[0] ^ uid[1] ^ uid[2]
|
||
b2 = uid[3] ^ uid[4] ^ uid[5] ^ uid[6]
|
||
frames += [(0, b"\x93\x20"), (1, bytes([ct]) + uid[0:3] + bytes([b1])),
|
||
(0, b"\x93\x70" + bytes([ct]) + uid[0:3] + bytes([b1])), (1, b"\x04"),
|
||
(0, b"\x95\x20"), (1, uid[3:7] + bytes([b2])),
|
||
(0, b"\x95\x70" + uid[3:7] + bytes([b2])), (1, bytes([sak]))]
|
||
return frames
|
||
|
||
|
||
def _try(fn, default):
|
||
try:
|
||
return fn()
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def identify(session, emit=None) -> dict:
|
||
"""Probe the field and update ``session`` (field / protocol / transponder). ``emit`` receives
|
||
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"}
|
||
|
||
# ISO 14443-A — retry: the scan can miss before it selects on a flaky link, and a miss must
|
||
# not fall through to a bogus 15693/LF hit.
|
||
scan = {"found": False}
|
||
for _ in range(3):
|
||
scan = _try(lambda: dev.hf.iso14a.scan(), {"found": False})
|
||
if scan.get("found"):
|
||
break
|
||
if scan.get("found"):
|
||
session.field, session.protocol = "hf", "hf14a"
|
||
# always emit ANSI; the app's printer renders it (and strips it off a non-terminal)
|
||
fmt = TraceFormatter(mode="reader", decoder=_ident_decode, crc_len=0, is_tty=True)
|
||
for direction, data in _activation_frames(scan):
|
||
emit(fmt.format(direction, data).strip("\n"))
|
||
model, version = None, None
|
||
if scan.get("sak") == 0x00: # UL/NTAG family — ask GET_VERSION
|
||
model, version = _probe_version(dev, fmt, emit)
|
||
session.transponder = model or name_14a(scan)
|
||
return {"found": True, "field": "hf", "protocol": "14a",
|
||
"transponder": session.transponder, "uid": scan.get("uid"),
|
||
"version": version.hex() if version else None}
|
||
|
||
# ISO 15693 — require found AND a real E0-prefixed UID, else a 14a miss reads as a bogus tag.
|
||
scan15 = _try(lambda: dev.hf.iso15.scan(), {"found": False})
|
||
uid15 = scan15.get("uid")
|
||
if scan15.get("found") and isinstance(uid15, str) and uid15.lower().startswith("e0"):
|
||
session.field, session.protocol = "hf", "hf15"
|
||
session.transponder = "ISO15693"
|
||
return {"found": True, "field": "hf", "protocol": "15693",
|
||
"transponder": "ISO15693", "uid": uid15}
|
||
|
||
# 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
|
||
|
||
return {"found": False}
|
||
|
||
|
||
def _identify_lf(dev, session, emit):
|
||
"""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 <id>`` 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
|
||
session.field, session.protocol = "lf", "lf"
|
||
session.transponder = lf["label"]
|
||
return {"found": True, "field": "lf", "protocol": "lf",
|
||
"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]:
|
||
"""Send a real GET_VERSION (0x60 +CRC), trace the exchange, decode the exact model. Retried:
|
||
a single shot can be lost on a flaky NG link, and a miss drops us to the generic SAK name —
|
||
which loses the exact-model memory map (page roles, completion). Re-selects between attempts to
|
||
recover a desynced link."""
|
||
cmd = b"\x60"
|
||
emit(fmt.format(0, cmd).strip("\n"))
|
||
for attempt in range(3):
|
||
resp = _try(lambda: dev.hf.iso14a.raw(cmd, flags=ISO14A_APPEND_CRC), None)
|
||
raw = resp.get("raw") if isinstance(resp, dict) else None
|
||
if raw and len(raw) >= 8:
|
||
version = bytes(raw[:8])
|
||
model = decode_version(version)
|
||
if model:
|
||
emit(fmt.format(1, bytes(raw)).strip("\n")) # raw() already trims to the RX length
|
||
emit(f" → {model}")
|
||
return model, version
|
||
if attempt < 2:
|
||
_try(lambda: dev.hf.iso14a.scan(), None) # re-select, then retry the version
|
||
return None, None
|
||
|
||
|
||
def clear(session) -> None:
|
||
"""Forget the identified tag (drop the logical connection); the device stays open."""
|
||
session.field = None
|
||
session.transponder = None
|
||
session.protocol = "hf14a"
|
||
dev = session.device
|
||
if dev is not None:
|
||
_try(lambda: dev.hf.dropfield(), None)
|
||
|
||
|
||
def format_summary(result: dict) -> str:
|
||
if not result.get("found"):
|
||
return f"no tag found{': ' + result['error'] if result.get('error') else ''}"
|
||
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']}"
|
||
elif result.get("id"):
|
||
detail = f"id {result['id']}"
|
||
else:
|
||
detail = ""
|
||
head = f"{result['transponder']} ({result['field'].upper()} / {result['protocol']})"
|
||
return f"{head} {detail}".rstrip()
|