feat(rawcli): complete + family-accurate NTAG21x / Ultralight EV1 memory map

The page hinting was coarse and partly wrong: pages 0-2 were all "UID", page 3 was always
"CC" (it's OTP on Ultralight), config fields were abbreviated, and the NTAG210/212 (no counter)
and Ultralight (no ASCII mirror) differences were ignored. Now every named region is covered
and the fields track the actual IC:

  00-01  UID / serial number
  02     static lock bytes + internal (locks pages 03-0F)
  03     Capability Container (CC)   [NTAG]   /   OTP (one-time programmable)   [Ultralight]
  04..N  user memory
  <lock> dynamic lock bytes          (present on 212/213/215/216 and MF0UL21; absent on 210/UL11)
  CFG0   AUTH0, MIRROR (mode/page/byte on NTAG only), STRG_MOD_EN
  CFG1   ACCESS: PROT, CFGLCK, [NFC_CNT_EN, NFC_CNT_PWD_PROT on 213/215/216], AUTHLIM
  PWD    32-bit password
  PACK   password acknowledge + RFUI

_LAYOUTS gains a family tag ("ntag"/"ul") and a counter flag; page_role/landmark_pages derive
page 3 (CC vs OTP) and the CFG0/CFG1 field lists from them. The generic fallback (unknown model)
now also names the static-lock and CC/OTP header pages. Raw-byte completion matches the
fixed-width byte form only (so partial "2" no longer wrongly hits page 02).

Hardware-verified on NTAG213: both READ( and raw "30 " list the full 9-region map. Tests cover
the family differences (counter bits, mirror, OTP, dynamic-lock presence) and layout/model drift
for the Ultralight parts too. 1330 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-15 19:18:05 -07:00
parent 65be53da8c
commit a5c7702398
3 changed files with 99 additions and 39 deletions

View File

@@ -94,11 +94,12 @@ class RawCompleter(Completer):
else:
base, body = "hex", low
for page, role in landmark_pages(self._session.transponder):
# a raw byte is fixed-width (2 hex / 8 binary digits) — match the full-width form only
if base == "bin":
val, forms = f"{page:08b}", (f"{page:08b}", f"{page:b}")
val = f"{page:08b}"
else:
val, forms = f"{page:02X}", (f"{page:02x}", f"{page:x}")
if body and not any(f.startswith(body) for f in forms):
val = f"{page:02X}"
if body and not val.lower().startswith(body):
continue
yield Completion(val, start_position=-len(partial),
display=f"{val} {role}", display_meta=role)

View File

@@ -12,28 +12,24 @@ avoid an import-order circular, with a test guarding against drift.
from __future__ import annotations
# Page layout per NTAG21x / Ultralight EV1 IC (user range inclusive; keys are identify's names).
# The static Type-2 header (pages 0-3) and PWD/PACK/config structure are shared; the family only
# differs in page 3 (CC on NTAG, OTP on Ultralight), the ASCII mirror (NTAG only), and the CFG1
# NFC-counter bits (NTAG 213/215/216 only). "lock" is the *dynamic* lock page (absent on the
# smallest parts); "cnt" flags the CFG1 NFC-counter bits.
_LAYOUTS = {
"NTAG210": {"user": (0x04, 0x0F), "cfg0": 0x10, "cfg1": 0x11, "pwd": 0x12, "pack": 0x13},
"NTAG212": {"user": (0x04, 0x23), "lock": 0x24, "cfg0": 0x25, "cfg1": 0x26, "pwd": 0x27, "pack": 0x28},
"NTAG213": {"user": (0x04, 0x27), "lock": 0x28, "cfg0": 0x29, "cfg1": 0x2A, "pwd": 0x2B, "pack": 0x2C},
"NTAG215": {"user": (0x04, 0x81), "lock": 0x82, "cfg0": 0x83, "cfg1": 0x84, "pwd": 0x85, "pack": 0x86},
"NTAG216": {"user": (0x04, 0xE1), "lock": 0xE2, "cfg0": 0xE3, "cfg1": 0xE4, "pwd": 0xE5, "pack": 0xE6},
"MIFARE Ultralight EV1 (MF0UL11)": {"user": (0x04, 0x0F), "cfg0": 0x10, "cfg1": 0x11, "pwd": 0x12, "pack": 0x13},
"MIFARE Ultralight EV1 (MF0UL21)": {"user": (0x04, 0x23), "lock": 0x24, "cfg0": 0x25, "cfg1": 0x26, "pwd": 0x27, "pack": 0x28},
"NTAG210": {"user": (0x04, 0x0F), "cfg0": 0x10, "cfg1": 0x11, "pwd": 0x12, "pack": 0x13, "fam": "ntag"},
"NTAG212": {"user": (0x04, 0x23), "lock": 0x24, "cfg0": 0x25, "cfg1": 0x26, "pwd": 0x27, "pack": 0x28, "fam": "ntag"},
"NTAG213": {"user": (0x04, 0x27), "lock": 0x28, "cfg0": 0x29, "cfg1": 0x2A, "pwd": 0x2B, "pack": 0x2C, "fam": "ntag", "cnt": True},
"NTAG215": {"user": (0x04, 0x81), "lock": 0x82, "cfg0": 0x83, "cfg1": 0x84, "pwd": 0x85, "pack": 0x86, "fam": "ntag", "cnt": True},
"NTAG216": {"user": (0x04, 0xE1), "lock": 0xE2, "cfg0": 0xE3, "cfg1": 0xE4, "pwd": 0xE5, "pack": 0xE6, "fam": "ntag", "cnt": True},
"MIFARE Ultralight EV1 (MF0UL11)": {"user": (0x04, 0x0F), "cfg0": 0x10, "cfg1": 0x11, "pwd": 0x12, "pack": 0x13, "fam": "ul"},
"MIFARE Ultralight EV1 (MF0UL21)": {"user": (0x04, 0x23), "lock": 0x24, "cfg0": 0x25, "cfg1": 0x26, "pwd": 0x27, "pack": 0x28, "fam": "ul"},
}
_ROLES = {
"cfg0": "CFG0 — MIRROR / MIRROR_PAGE / AUTH0",
"cfg1": "CFG1 — ACCESS (PROT / CFGLCK / AUTHLIM)",
"pwd": "PWD (32-bit password)",
"pack": "PACK (password acknowledge)",
"lock": "dynamic lock bytes",
}
# The pages guaranteed on every Type 2 tag (down to the smallest, NTAG210): UID/CC and user
# memory 0x04-0x0F. Used when only the NTAG/Ultralight *family* is known — GET_VERSION couldn't
# The pages guaranteed on every Type 2 tag (down to the smallest, NTAG210): the static header and
# user memory 0x04-0x0F. Used when only the NTAG/Ultralight *family* is known — GET_VERSION couldn't
# complete on a flaky link, or an original Ultralight has no GET_VERSION. Config pages differ by
# model, so they're left unnamed rather than guessed.
# model, so they're left unnamed rather than guessed; page 3 could be CC or OTP.
_GENERIC_TYPE2 = {"user": (0x04, 0x0F)}
@@ -51,6 +47,29 @@ def _resolve_layout(transponder: str | None) -> dict | None:
return None
def _page3_role(layout: dict) -> str:
fam = layout.get("fam")
if fam == "ul":
return "OTP (one-time programmable)"
if fam == "ntag":
return "Capability Container (CC)"
return "Capability Container (CC) / OTP" # generic: family unknown
def _cfg0_role(layout: dict) -> str:
# CFG0: AUTH0 (first protected page) + strong-modulation; NTAG adds the ASCII mirror
if layout.get("fam") == "ntag":
return "CFG0 — AUTH0 (first protected page), MIRROR (mode/page/byte), STRG_MOD_EN"
return "CFG0 — AUTH0 (first protected page), STRG_MOD_EN"
def _cfg1_role(layout: dict) -> str:
# CFG1 ACCESS byte: read/write protection, config lock, auth-retry limit; NTAG counters add
# the NFC-counter enable/protect bits
bits = "PROT, CFGLCK, " + ("NFC_CNT_EN, NFC_CNT_PWD_PROT, " if layout.get("cnt") else "") + "AUTHLIM"
return f"CFG1 — ACCESS ({bits})"
def _t5577_block_role(block: int | None) -> str | None:
"""Role of a T5577 block: 0 = config, 7 = password, 1-6 = data (the emulated tag content)."""
if block is None:
@@ -65,39 +84,47 @@ def _t5577_block_role(block: int | None) -> str | None:
def page_role(transponder: str | None, page: int | None) -> str | None:
"""The role of ``page``/``block`` on ``transponder`` (user memory / CC / AUTH0 / PWD /
T5577 config / password / …), or None."""
"""The role of ``page``/``block`` on ``transponder`` — the full Type 2 header (UID / static
lock / CC-or-OTP), user memory, dynamic lock, CFG0/CFG1 (family-accurate fields), PWD, PACK —
or a T5577 block role, or None when unknown."""
if transponder and "T5577" in transponder.upper():
return _t5577_block_role(page)
layout = _resolve_layout(transponder)
if layout is None or page is None:
return None
if 0 <= page <= 2:
if 0 <= page <= 1:
return "UID / serial number"
if page == 2:
return "static lock bytes + internal (locks pages 03-0F)"
if page == 3:
return "Capability Container (CC)"
return _page3_role(layout)
lo, hi = layout["user"]
if lo <= page <= hi:
return "user memory"
for key, role in _ROLES.items():
if page == layout.get(key):
return role
if page == layout.get("lock"):
return "dynamic lock bytes"
if page == layout.get("cfg0"):
return _cfg0_role(layout)
if page == layout.get("cfg1"):
return _cfg1_role(layout)
if page == layout.get("pwd"):
return "PWD (32-bit password)"
if page == layout.get("pack"):
return "PACK (password acknowledge) + RFUI"
return None
def landmark_pages(transponder: str | None) -> list[tuple[int, str]]:
"""Notable pages/blocks with their datasheet role, for argument autocomplete inside a
``READ(<page>)`` / ``WRITE(<block>)`` call — so the completion menu lists the memory map
(``0x03 → Capability Container``, ``0xE5 → PWD``, …). Ordered by page. Empty when the tag
has no known layout."""
"""Notable pages/blocks with their datasheet role, for the completion menu's memory map. Every
named region of the identified tag — UID, static lock, CC/OTP, user, dynamic lock, CFG0/CFG1,
PWD, PACK. Ordered by page; empty when the tag has no known layout."""
if transponder and "T5577" in transponder.upper():
return [(b, _t5577_block_role(b)) for b in range(8)]
layout = _resolve_layout(transponder)
if layout is None:
return []
out = {0: "UID / serial number", 3: "Capability Container (CC)",
layout["user"][0]: "user memory"}
pages = {0, 2, 3, layout["user"][0]}
for key in ("lock", "cfg0", "cfg1", "pwd", "pack"):
if key in layout:
out[layout[key]] = _ROLES.get(key, key)
return sorted(out.items())
pages.add(layout[key])
return [(p, page_role(transponder, p)) for p in sorted(pages) if page_role(transponder, p)]