From 4684d0cc83214dc14ce2c612fb9b2605ab54d419 Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 16 Jul 2026 13:33:41 -0700 Subject: [PATCH] feat(rawcli): identify NTAG5 and ICODE DNA (READ_CONFIG probe) NTAG5, ICODE DNA and SLIX2 all share E0 04 and answer READ_SIGNATURE, so name_iso15 now splits them with an addressed, retried READ_CONFIG (0xC0) probe: - session register 0xA0 (STATUS_REG) succeeds only on NTAG5 -> "NTAG 5" - 0xA0 errors but config block 0x00 succeeds (DNA has config mem) -> "ICODE DNA" - neither (SLIX2 has no READ_CONFIG) -> the 0xBD signature step -> "ICODE SLIX2" - plain SLIX answers neither -> "ICODE SLIX" Hardware-verified against a real NTAG5 (VivoKey VK Thermo, UID E0 04 01 58, 0xA0 -> 00 01400000) and a real ICODE DNA (UID E0 04 01 18, 0xA0 -> 01 0F error, 0x00 -> 00 ...). DNA was previously mislabeled "ICODE SLIX2". catalog_for routes DNA to the SLIX2 catalog for now (a dedicated DNA catalog with READ_CONFIG + AES is a follow-up). Adds a mocked unit test covering all four branches from the real captures. Co-Authored-By: Claude Opus 4.8 --- docs/rawcli-todo.md | 13 +++++++------ pm3py/cli/rawcli/catalog.py | 2 +- pm3py/cli/rawcli/identify.py | 33 ++++++++++++++++++++++++++++++--- tests/test_rawcli.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/docs/rawcli-todo.md b/docs/rawcli-todo.md index 0827830..79fb606 100644 --- a/docs/rawcli-todo.md +++ b/docs/rawcli-todo.md @@ -47,12 +47,13 @@ rendered dropdown on a real terminal is the only thing left unautomated. 15693 catalogs. The **full request-flags byte** is settable: sub-carrier (0x01), data-rate, inventory, protocol-ext (0x08), and the mode-dependent bits 5-6 (Select/Address vs AFI byte + Nb_slots 1/16), plus option. `iso15_flags_decode` decodes every bit for the byte-0 hint. -2. **Per-chip identify** — `identify.name_iso15` distinguishes SLIX vs SLIX2 (READ_SIGNATURE 0xBD - probe). **NTAG5 and ICODE DNA both carry UID `E0 04 01 18`** (identical type indicator — model - comment confirms), so they can't be told apart by UID. Routing NTAG5 needs a **functional - probe** (e.g. READ_CONFIG 0xC0 of session register 0xA0/0xA1 — NTAG5 has them, DNA doesn't). - ⚠ HARDWARE-DEPENDENT: needs a brief NTAG5 tap to capture the discriminating response (and ideally - a DNA capture to confirm it differs) before wiring — otherwise it risks misclassifying DNA. +2. **Per-chip identify** ✅ — `identify.name_iso15` now splits the whole E0 04 family with a + READ_CONFIG (0xC0) probe (addressed + retried), **hardware-verified against a real NTAG5 and a + real DNA**: session register 0xA0 succeeds only on NTAG5 (`00 01400000`); DNA errors on 0xA0 + (`01 0F`) but its config block 0x00 succeeds; SLIX2 has no READ_CONFIG (falls to the 0xBD + signature step); plain SLIX answers neither. So NTAG5 → "NTAG 5", DNA → "ICODE DNA" (was + mislabeled SLIX2), SLIX2/SLIX as before. Real UIDs: NTAG5 `E0 04 01 58`, DNA `E0 04 01 18` + (the model's NTAG5 prefix `…18` was copied from DNA; real NTAG5 is `…58`). 3. **Catalogs** — `_SLIX`, `_SLIX2`, and **`_NTAG5`** ✅ all done, dispatched by `catalog_for`, verified live. `_NTAG5` = SLIX2 minus STAY_QUIET_PERSISTENT (NTAG5 rejects it) + READ_CONFIG (0xC0) / WRITE_CONFIG (0xC1) / PICK_RANDOM_UID (0xC2) / READ_SRAM (0xD2) / WRITE_SRAM (0xD3). diff --git a/pm3py/cli/rawcli/catalog.py b/pm3py/cli/rawcli/catalog.py index 0ff576d..7ac6f54 100644 --- a/pm3py/cli/rawcli/catalog.py +++ b/pm3py/cli/rawcli/catalog.py @@ -527,7 +527,7 @@ def catalog_for(session) -> Catalog | None: if session.protocol == "hf15": if "NTAG5" in name or "NTAG 5" in name: return _NTAG5 - if "SLIX2" in name: + if "SLIX2" in name or "DNA" in name: # DNA: closest existing set; own catalog is a follow-up return _SLIX2 return _SLIX if "SLIX" in name or "ICODE" in name else ISO15 if session.protocol == "hf14a": diff --git a/pm3py/cli/rawcli/identify.py b/pm3py/cli/rawcli/identify.py index 0535ce5..9f6b642 100644 --- a/pm3py/cli/rawcli/identify.py +++ b/pm3py/cli/rawcli/identify.py @@ -97,11 +97,38 @@ def _iso15_uid(dev) -> str | None: def name_iso15(dev, uid15: str) -> str: - """Name a 15693 tag from its UID + a probe. ``E0 04`` = NXP ICODE; distinguish ICODE SLIX - (no READ_SIGNATURE) from SLIX2 (supports it, 0xBD). Non-NXP falls back to generic ISO15693. - (ICODE DNA / NTAG5 also share E0 04 and would need a finer probe — a later refinement.)""" + """Name a 15693 tag from its UID + probes. ``E0 04`` = NXP ICODE. NTAG5 shares ``E0 04 01`` with + ICODE DNA, so it is told apart by a functional probe (see below); SLIX2 is distinguished from + plain SLIX by READ_SIGNATURE (0xBD). Non-NXP falls back to generic ISO15693.""" if not uid15.lower().startswith("e004"): return "ISO15693" + # NTAG5 / DNA / SLIX2 all share E0 04 and answer READ_SIGNATURE, so split them with READ_CONFIG + # (0xC0), addressed + retried (these tags are finicky). Session register 0xA0 (STATUS_REG) + # succeeds only on NTAG5 (its I2C-side registers). DNA has no session registers (0xA0 errors) + # but does have config memory (block 0x00 succeeds); SLIX2 has no READ_CONFIG command at all. + # Hardware-verified: NTAG5 (E0 04 01 58) 0xA0 -> 00 01400000; DNA (E0 04 01 18) 0xA0 -> 01 0F, + # 0x00 -> 00 ... + try: + uid_wire = bytes.fromhex(uid15)[::-1] # UID LSByte-first for addressing + + def _read_config(block: int): + frame = bytes([0x22, 0xC0, 0x04]) + uid_wire + bytes([block, 0x00]) + for _ in range(6): + r = _try(lambda: dev.hf.iso15.raw(frame), None) + raw = r.get("raw") if isinstance(r, dict) else None + if raw and raw[0] in (0x00, 0x01): # a real ok/error response (not a miss) + return raw + return None + + a0 = _read_config(0xA0) + if a0 and a0[0] == 0x00 and len(a0) >= 5: # valid STATUS_REG -> NTAG5 + return "NTAG 5" + if a0 and a0[0] == 0x01: # 0xA0 errored -> not NTAG5; DNA has + cfg0 = _read_config(0x00) # config memory, SLIX2 has no 0xC0 + if cfg0 and cfg0[0] == 0x00 and len(cfg0) >= 5: + return "ICODE DNA" + except ValueError: + pass # READ_SIGNATURE (0xBD): SLIX2 returns a 32-byte ECC signature (response >= 33 bytes with the # flags byte); plain SLIX ignores the command. Require the full length so a flaky/stale frame # can't false-positive; probe twice in case the real signature frame is dropped. diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index 791a0cc..85bcaa0 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -268,6 +268,34 @@ class TestIdentify: r = identify(s) assert r["found"] is False and s.transponder is None + def test_name_iso15_ntag5_dna_slix2_slix(self): + # split the E0 04 family by READ_CONFIG (0xC0) probe, using the real hardware captures: + # NTAG5 -> session reg 0xA0 succeeds; DNA -> 0xA0 errors but config 0x00 succeeds; + # SLIX2 -> no READ_CONFIG (timeout) but READ_SIGNATURE answers; SLIX -> neither. + from pm3py.cli.rawcli.identify import name_iso15 + from unittest.mock import MagicMock + + def dev_with(responses): + dev = MagicMock() + def _raw(frame): + f = bytes(frame) + if f[:2] == b"\x22\xc0": # READ_CONFIG addressed: key by block + return responses.get(("cfg", f[-2]), {"raw": None}) + if f[:3] == b"\x02\xbd\x04": # READ_SIGNATURE + return responses.get("sig", {"raw": None}) + return {"raw": None} + dev.hf.iso15.raw.side_effect = _raw + return dev + + ntag5 = dev_with({("cfg", 0xA0): {"raw": bytes.fromhex("0001400000bad5")}}) + assert name_iso15(ntag5, "e0040158108ee802") == "NTAG 5" + dna = dev_with({("cfg", 0xA0): {"raw": bytes.fromhex("010f68ee")}, + ("cfg", 0x00): {"raw": bytes.fromhex("001e4d3235a1b8")}}) + assert name_iso15(dna, "e0040118009b4ccb") == "ICODE DNA" + slix2 = dev_with({"sig": {"raw": bytes([0x00]) + bytes(34)}}) # 35-byte signature reply + assert name_iso15(slix2, "e0040203040506") == "ICODE SLIX2" + assert name_iso15(dev_with({}), "e004a2110b37c06b") == "ICODE SLIX" + def test_no_flat_lf(self): # nothing on HF and a flat LF envelope must report not-found, not a bogus "LF tag" assert identify(RawSession(device=_mock_device()))["found"] is False