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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
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 +
|
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.
|
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
|
2. **Per-chip identify** ✅ — `identify.name_iso15` now splits the whole E0 04 family with a
|
||||||
probe). **NTAG5 and ICODE DNA both carry UID `E0 04 01 18`** (identical type indicator — model
|
READ_CONFIG (0xC0) probe (addressed + retried), **hardware-verified against a real NTAG5 and a
|
||||||
comment confirms), so they can't be told apart by UID. Routing NTAG5 needs a **functional
|
real DNA**: session register 0xA0 succeeds only on NTAG5 (`00 01400000`); DNA errors on 0xA0
|
||||||
probe** (e.g. READ_CONFIG 0xC0 of session register 0xA0/0xA1 — NTAG5 has them, DNA doesn't).
|
(`01 0F`) but its config block 0x00 succeeds; SLIX2 has no READ_CONFIG (falls to the 0xBD
|
||||||
⚠ HARDWARE-DEPENDENT: needs a brief NTAG5 tap to capture the discriminating response (and ideally
|
signature step); plain SLIX answers neither. So NTAG5 → "NTAG 5", DNA → "ICODE DNA" (was
|
||||||
a DNA capture to confirm it differs) before wiring — otherwise it risks misclassifying DNA.
|
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`,
|
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
|
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).
|
(0xC0) / WRITE_CONFIG (0xC1) / PICK_RANDOM_UID (0xC2) / READ_SRAM (0xD2) / WRITE_SRAM (0xD3).
|
||||||
|
|||||||
@@ -527,7 +527,7 @@ def catalog_for(session) -> Catalog | None:
|
|||||||
if session.protocol == "hf15":
|
if session.protocol == "hf15":
|
||||||
if "NTAG5" in name or "NTAG 5" in name:
|
if "NTAG5" in name or "NTAG 5" in name:
|
||||||
return _NTAG5
|
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 _SLIX2
|
||||||
return _SLIX if "SLIX" in name or "ICODE" in name else ISO15
|
return _SLIX if "SLIX" in name or "ICODE" in name else ISO15
|
||||||
if session.protocol == "hf14a":
|
if session.protocol == "hf14a":
|
||||||
|
|||||||
@@ -97,11 +97,38 @@ def _iso15_uid(dev) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def name_iso15(dev, uid15: str) -> str:
|
def name_iso15(dev, uid15: str) -> str:
|
||||||
"""Name a 15693 tag from its UID + a probe. ``E0 04`` = NXP ICODE; distinguish ICODE SLIX
|
"""Name a 15693 tag from its UID + probes. ``E0 04`` = NXP ICODE. NTAG5 shares ``E0 04 01`` with
|
||||||
(no READ_SIGNATURE) from SLIX2 (supports it, 0xBD). Non-NXP falls back to generic ISO15693.
|
ICODE DNA, so it is told apart by a functional probe (see below); SLIX2 is distinguished from
|
||||||
(ICODE DNA / NTAG5 also share E0 04 and would need a finer probe — a later refinement.)"""
|
plain SLIX by READ_SIGNATURE (0xBD). Non-NXP falls back to generic ISO15693."""
|
||||||
if not uid15.lower().startswith("e004"):
|
if not uid15.lower().startswith("e004"):
|
||||||
return "ISO15693"
|
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
|
# 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
|
# 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.
|
# can't false-positive; probe twice in case the real signature frame is dropped.
|
||||||
|
|||||||
@@ -268,6 +268,34 @@ class TestIdentify:
|
|||||||
r = identify(s)
|
r = identify(s)
|
||||||
assert r["found"] is False and s.transponder is None
|
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):
|
def test_no_flat_lf(self):
|
||||||
# nothing on HF and a flat LF envelope must report not-found, not a bogus "LF tag"
|
# 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
|
assert identify(RawSession(device=_mock_device()))["found"] is False
|
||||||
|
|||||||
Reference in New Issue
Block a user