feat(rawcli): NTAG 5 datasheet catalog (config + SRAM + pick-random-UID)

_NTAG5 = the SLIX2 command set minus STAY_QUIET_PERSISTENT (which NTAG5 rejects per the
NTP5210/NTA5332 tables) plus the NTAG5-specific NXP customs: READ_CONFIG (0xC0),
WRITE_CONFIG (0xC1, session registers 0xA0 STATUS_REG / 0xA1 CONFIG_REG), PICK_RANDOM_UID
(0xC2), and READ_SRAM (0xD2) / WRITE_SRAM (0xD3) for the Link/Boost variants. Sourced
from transponders/hf/iso15693/nxp/ntag5_*, routed by catalog_for, verified live.

Not yet wired: auto-identify of NTAG5. NTAG5 and ICODE DNA share UID E0 04 01 18, so
they can't be told apart by UID — routing needs a functional probe (READ_CONFIG of a
session register), which needs a brief NTAG5 tap to nail without risking DNA
misclassification. AES/ISO-29167-10 commands (0x35/0x39/0x3A) noted as a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-16 13:14:33 -07:00
parent d0339bceb5
commit 9e18a10274
3 changed files with 53 additions and 7 deletions

View File

@@ -48,12 +48,16 @@ rendered dropdown on a real terminal is the only thing left unautomated.
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` distinguishes SLIX vs SLIX2 (READ_SIGNATURE 0xBD
probe). **NTAG5 and ICODE DNA still fall through to "ICODE SLIX"** (they share `E0 04`); NTAG5 probe). **NTAG5 and ICODE DNA both carry UID `E0 04 01 18`** (identical type indicator — model
needs a finer probe (IC-reference / GET_SYSTEM_INFO) before its catalog can be routed. comment confirms), so they can't be told apart by UID. Routing NTAG5 needs a **functional
3. **Catalogs**`_SLIX` (plain) and `_SLIX2` (`_SLIX` + READ_SIGNATURE 0xBD / probe** (e.g. READ_CONFIG 0xC0 of session register 0xA0/0xA1 — NTAG5 has them, DNA doesn't).
PASSWORD_PROTECTION_64BIT 0xBB / STAY_QUIET_PERSISTENT 0xBC, 80 blocks) done, dispatched by ⚠ HARDWARE-DEPENDENT: needs a brief NTAG5 tap to capture the discriminating response (and ideally
`catalog_for`, verified live. **NTAG5 catalog** (READ/WRITE_CONFIG, the NTAG5 config registers, a DNA capture to confirm it differs) before wiring — otherwise it risks misclassifying DNA.
password/AES) still to build — source from `transponders/hf/iso15693/nxp/ntag5_*`. 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).
**Follow-up:** the AES / ISO 29167-10 commands AUTHENTICATE (0x35) / CHALLENGE (0x39) / READBUFFER
(0x3A) for Link/Boost (AES-capable) variants — a crypto sub-protocol with its own param format.
4. Autocomplete UX ✅ (hex + binary, command names + opcode-by-value; the byte-line completion bug 4. Autocomplete UX ✅ (hex + binary, command names + opcode-by-value; the byte-line completion bug
is fixed). is fixed).

View File

@@ -432,6 +432,31 @@ _SLIX2 = _catalog(
) )
# NTAG 5 (NTP5210 Switch / NTA5332 Link/Boost): the SLIX2 command set minus STAY_QUIET_PERSISTENT
# (NTAG5 rejects it) plus config memory + session registers, PICK RANDOM UID, and — on Link/Boost —
# SRAM. NTAG5 shares the DNA UID (E0 04 01 18), so routing here needs a functional identify probe.
_NTAG5 = _catalog(
"NTAG 5", "hf15",
*[c for c in _SLIX2.commands.values() if c.name != "STAY_QUIET_PERSISTENT"],
_c("READ_CONFIG", ["block", "count"],
lambda block, count: iso15_frame(0xC0, bytes([_int(block), _int(count) - 1]), mfg=NXP_MFG),
"read config / session-register blocks (0xA0 STATUS_REG, 0xA1 CONFIG_REG) (0xC0)", opcode=0xC0),
_c("WRITE_CONFIG", ["block", "data"],
lambda block, data: iso15_frame(
0xC1, bytes([_int(block)]) + _hex(data).ljust(4, b"\x00")[:4], mfg=NXP_MFG),
"write a 4-byte config / session-register block (0xC1)", opcode=0xC1),
_c("PICK_RANDOM_UID", [], lambda: iso15_frame(0xC2, mfg=NXP_MFG),
"generate a random UID for privacy mode — AES variants only (0xC2)", opcode=0xC2),
_c("READ_SRAM", ["block", "count"],
lambda block, count: iso15_frame(0xD2, bytes([_int(block), _int(count) - 1]), mfg=NXP_MFG),
"read SRAM blocks — Link/Boost only (0xD2)", opcode=0xD2),
_c("WRITE_SRAM", ["block", "data"],
lambda block, data: iso15_frame(
0xD3, bytes([_int(block)]) + _hex(data).ljust(4, b"\x00")[:4], mfg=NXP_MFG),
"write a 4-byte SRAM block — Link/Boost only (0xD3)", opcode=0xD3),
)
# ---- LF T5577 / ATA5577 (block read/write over the T55xx downlink; executes via lf.t55) ---- # ---- LF T5577 / ATA5577 (block read/write over the T55xx downlink; executes via lf.t55) ----
# build() exists only to expose the downlink opcode for hex/binary completion; execution goes # build() exists only to expose the downlink opcode for hex/binary completion; execution goes
# through run() (the device method), since LF isn't a raw-byte exchange like 14a. # through run() (the device method), since LF isn't a raw-byte exchange like 14a.
@@ -500,6 +525,8 @@ def catalog_for(session) -> Catalog | None:
"""Resolve the command catalog for the session's identified transponder, or None.""" """Resolve the command catalog for the session's identified transponder, or None."""
name = (session.transponder or "").upper() name = (session.transponder or "").upper()
if session.protocol == "hf15": if session.protocol == "hf15":
if "NTAG5" in name or "NTAG 5" in name:
return _NTAG5
if "SLIX2" in name: if "SLIX2" in name:
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

View File

@@ -11,7 +11,7 @@ from pm3py.cli.rawcli.trace_view import render_exchange
from pm3py.cli.rawcli.parser import parse_token, parse_bytes, parse_line from pm3py.cli.rawcli.parser import parse_token, parse_bytes, parse_line
from pm3py.cli.rawcli.entry import type_digit, is_byte_line, reconcile_bases from pm3py.cli.rawcli.entry import type_digit, is_byte_line, reconcile_bases
from pm3py.cli.rawcli.identify import identify, clear, name_14a, format_summary from pm3py.cli.rawcli.identify import identify, clear, name_14a, format_summary
from pm3py.cli.rawcli.catalog import (TYPE2, ISO15, T5577, LF_READONLY, MFC, _SLIX, _SLIX2, from pm3py.cli.rawcli.catalog import (TYPE2, ISO15, T5577, LF_READONLY, MFC, _SLIX, _SLIX2, _NTAG5,
iso15_frame, iso15_flags_decode, catalog_for, catalog_for as _cf) iso15_frame, iso15_flags_decode, catalog_for, catalog_for as _cf)
from pm3py.cli.rawcli.tlv import parse_tlv, format_tlv from pm3py.cli.rawcli.tlv import parse_tlv, format_tlv
from pm3py.cli.rawcli.completer import RawCompleter from pm3py.cli.rawcli.completer import RawCompleter
@@ -496,6 +496,21 @@ class TestCatalog:
s.transponder = "ICODE SLIX2"; assert catalog_for(s) is _SLIX2 s.transponder = "ICODE SLIX2"; assert catalog_for(s) is _SLIX2
s.transponder = "ICODE SLIX"; assert catalog_for(s) is _SLIX s.transponder = "ICODE SLIX"; assert catalog_for(s) is _SLIX
def test_ntag5_catalog(self):
# NTAG5 = SLIX2 minus STAY_QUIET_PERSISTENT (rejected), plus config / SRAM / pick-random-UID
assert "STAY_QUIET_PERSISTENT" not in _NTAG5.commands
assert "READ_SIGNATURE" in _NTAG5.commands # inherited from SLIX2
for name, op in [("READ_CONFIG", 0xC0), ("WRITE_CONFIG", 0xC1), ("PICK_RANDOM_UID", 0xC2),
("READ_SRAM", 0xD2), ("WRITE_SRAM", 0xD3)]:
assert _NTAG5.get(name).opcode() == op
assert _NTAG5.by_opcode(op).name == name
assert _NTAG5.get("READ_CONFIG").build("0xA1", "1") == bytes.fromhex("02c004a100") # CONFIG_REG
assert _NTAG5.get("WRITE_CONFIG").build("0xA1", "20000000") == bytes.fromhex("02c104a120000000")
assert _NTAG5.get("PICK_RANDOM_UID").build() == b"\x02\xC2\x04"
# dispatch (checked before SLIX2/SLIX): an identified NTAG5 resolves here
s = RawSession(); s.protocol, s.transponder = "hf15", "NTAG 5"
assert catalog_for(s) is _NTAG5
def test_resolver(self): def test_resolver(self):
s = RawSession() s = RawSession()
assert catalog_for(s) is None # nothing identified assert catalog_for(s) is None # nothing identified