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>
This commit is contained in:
michael
2026-07-14 12:40:41 -07:00
parent 5f57cc4202
commit 6b4a4551d4
3 changed files with 195 additions and 29 deletions

View File

@@ -146,11 +146,15 @@ class TestDispatch:
assert "whole number of bytes" in capsys.readouterr().out
def _mock_device(scan14=None, scan15=None):
NTAG216_VERSION = bytes.fromhex("0004040201001303")
def _mock_device(scan14=None, scan15=None, version=None):
dev = MagicMock()
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.hf.iso14a.raw.return_value = {"raw": (version + b"\x99\x88") if version else None}
return dev
@@ -160,13 +164,22 @@ class TestIdentify:
r = identify(s)
assert r["found"] is False and s.field is None
def test_14a_sets_field_and_name(self):
def test_14a_sak_only_name(self):
# SAK != 0 -> named from AN10833 Table 4, no GET_VERSION
s = RawSession(device=_mock_device(
scan14={"found": True, "sak": 0x08, "uid": b"\x01\x02\x03\x04"}))
scan14={"found": True, "sak": 0x08, "atqa": "0400", "uid": "01020304"}))
r = identify(s)
assert r["found"] and s.field == "hf" and s.protocol == "hf14a"
assert s.transponder == "MIFARE Classic 1K"
assert r["uid"] == b"\x01\x02\x03\x04"
def test_14a_getversion_exact(self):
s = RawSession(device=_mock_device(
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"},
version=NTAG216_VERSION))
r = identify(s)
assert s.transponder == "NTAG216" # exact, not "MIFARE UL/NTAG"
assert r["version"] == NTAG216_VERSION.hex()
s.device.hf.iso14a.raw.assert_called_with(b"\x60", flags=0x20) # real GET_VERSION+CRC
def test_15693_fallback(self):
s = RawSession(device=_mock_device(scan15={"found": True, "uid": b"\xE0\x04"}))
@@ -174,24 +187,52 @@ class TestIdentify:
assert r["found"] and s.field == "hf" and s.protocol == "hf15"
assert s.transponder == "ISO15693"
def test_name_unknown_sak(self):
def test_sak_names_from_an10833(self):
assert name_14a({"sak": 0x18}) == "MIFARE Classic 4K"
assert name_14a({"sak": 0x20}).startswith("ISO14443-4")
assert name_14a({"sak": 0x99}) == "ISO14443-A (SAK 99)"
def test_clear_forgets_tag(self):
s = RawSession(device=_mock_device(scan14={"found": True, "sak": 0x08}))
s = RawSession(device=_mock_device(scan14={"found": True, "sak": 0x08, "uid": "01020304"}))
identify(s)
clear(s)
assert s.field is None and s.transponder is None and s.protocol == "hf14a"
class TestVersionId:
def test_exact_models(self):
from pm3py.cli.rawcli.identify import decode_version
assert decode_version(bytes.fromhex("0004040201001303")) == "NTAG216"
assert decode_version(bytes.fromhex("0004040201000f03")) == "NTAG213"
assert "Ultralight EV1" in decode_version(bytes.fromhex("0004030101000b03"))
def test_structural_range_for_unknown(self):
from pm3py.cli.rawcli.identify import decode_version
# unknown NTAG-family version -> product + honest size range (AN10833)
out = decode_version(bytes.fromhex("0004040201001503"))
assert out.startswith("NTAG (") and "B)" in out
def test_static_map_matches_models(self):
# guard against drift between the static version map and the transponder models
from pm3py.cli.rawcli.identify import _VERSION_MODELS
from pm3py.sim import (NTAG210, NTAG212, NTAG213, NTAG215, NTAG216,
MF0UL11, MF0UL21, NT3H2111, NT3H2211)
model_vbytes = {c._version_bytes.hex() for c in
(NTAG210, NTAG212, NTAG213, NTAG215, NTAG216,
MF0UL11, MF0UL21, NT3H2111, NT3H2211)}
assert set(_VERSION_MODELS) == model_vbytes
class TestControlCommands:
def test_identify_command(self, capsys):
s = RawSession(device=_mock_device(
scan14={"found": True, "sak": 0x00, "uid": b"\x04\x11\x22\x33"}))
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"},
version=NTAG216_VERSION))
dispatch(s, "identify")
out = capsys.readouterr().out
assert "MIFARE UL/NTAG" in out and "04112233" in out
assert s.breadcrumb() == "[raw / hf / MIFARE UL/NTAG / 0x]"
out = _plain(capsys.readouterr().out)
assert "NTAG216" in out # exact model in the summary
assert "WUPA" in out and "GET_VERSION" in out # probe exchanges streamed to the trace
assert s.breadcrumb() == "[raw / hf / NTAG216 / 0x]"
def test_transponder_when_none(self, capsys):
dispatch(RawSession(), "transponder")
@@ -231,7 +272,7 @@ class TestCatalog:
class TestFunctionCalls:
def _id(self, sak=0x00):
return RawSession(device=_mock_device(
scan14={"found": True, "sak": sak, "uid": b"\x04\x01\x02\x03"}))
scan14={"found": True, "sak": sak, "atqa": "4400", "uid": "04010203"}))
def test_call_read_sends_correct_bytes(self, capsys):
s = self._id()