Files
pm3py/pm3py/cli/rawcli/identify.py
michael 6b4a4551d4 feat(rawcli): deep, traced identify — exact model via GET_VERSION
`identify` now probes and shows its work instead of a coarse SAK guess:

- Streams every probe exchange to the trace (proxmark-style annotated):
  the reconstructed 14a activation (WUPA/ATQA/anticollision CL1+CL2/
  SELECT/SAK) plus a REAL GET_VERSION (0x60 +CRC) sent over the persistent
  connection.
- Names the exact IC from GET_VERSION: an NTAG216 now reports "NTAG216",
  not "MIFARE UL/NTAG". Exact map mirrors the transponder models'
  _version_bytes (static, to dodge an import-order circular; a test guards
  against drift). Unknown chips fall back to product family + an honest
  memory-size *range* (AN10833 storage-byte encoding), not a bogus size.
- SAK fallback table + GET_VERSION product-nibble map aligned to NXP
  AN10833 (MIFARE type identification procedure).

11 tests (exact/structural decode, map-model sync, traced probe, SAK
names). GET_VERSION is a real device exchange — the user's hardware test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:40:41 -07:00

211 lines
8.6 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
if dev is None:
return {"found": False, "error": "no device connected"}
scan = _try(lambda: dev.hf.iso14a.scan(), {"found": False})
if scan.get("found"):
session.field, session.protocol = "hf", "hf14a"
fmt = TraceFormatter(mode="reader", decoder=_ident_decode, crc_len=0)
for direction, data in _activation_frames(scan):
emit(fmt.format(direction, data).lstrip("\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}
scan15 = _try(lambda: dev.hf.iso15.scan(), {"found": False})
if scan15.get("found") or scan15.get("uid"):
session.field, session.protocol = "hf", "hf15"
session.transponder = "ISO15693"
return {"found": True, "field": "hf", "protocol": "15693",
"transponder": "ISO15693", "uid": scan15.get("uid")}
lf = _try(lambda: dev.lf.search(), None)
if lf:
session.field, session.protocol = "lf", "lf"
name = getattr(lf, "tag_type", None) or getattr(lf, "type", None) or "LF tag"
session.transponder = str(name)
return {"found": True, "field": "lf", "protocol": "lf",
"transponder": session.transponder, "raw": lf}
return {"found": False}
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"
emit(fmt.format(0, cmd).lstrip("\n"))
resp = _try(lambda: dev.hf.iso14a.raw(cmd, flags=ISO14A_APPEND_CRC), None)
raw = resp.get("raw") if isinstance(resp, dict) else None
if not raw:
return None, None
emit(fmt.format(1, bytes(raw)).lstrip("\n"))
version = bytes(raw[:8])
model = decode_version(version)
if model:
emit(f"{model}")
return model, version
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 ''}"
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}"