Files
pm3py/pm3py/cli/rawcli/identify.py
michael 899142f23a fix(rawcli): memory annotations survive a flaky/absent GET_VERSION
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>
2026-07-15 18:23:38 -07:00

253 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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