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:
@@ -94,11 +94,12 @@ class RawCompleter(Completer):
|
|||||||
else:
|
else:
|
||||||
base, body = "hex", low
|
base, body = "hex", low
|
||||||
for page, role in landmark_pages(self._session.transponder):
|
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":
|
if base == "bin":
|
||||||
val, forms = f"{page:08b}", (f"{page:08b}", f"{page:b}")
|
val = f"{page:08b}"
|
||||||
else:
|
else:
|
||||||
val, forms = f"{page:02X}", (f"{page:02x}", f"{page:x}")
|
val = f"{page:02X}"
|
||||||
if body and not any(f.startswith(body) for f in forms):
|
if body and not val.lower().startswith(body):
|
||||||
continue
|
continue
|
||||||
yield Completion(val, start_position=-len(partial),
|
yield Completion(val, start_position=-len(partial),
|
||||||
display=f"{val} {role}", display_meta=role)
|
display=f"{val} {role}", display_meta=role)
|
||||||
|
|||||||
@@ -12,28 +12,24 @@ avoid an import-order circular, with a test guarding against drift.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Page layout per NTAG21x / Ultralight EV1 IC (user range inclusive; keys are identify's names).
|
# 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 = {
|
_LAYOUTS = {
|
||||||
"NTAG210": {"user": (0x04, 0x0F), "cfg0": 0x10, "cfg1": 0x11, "pwd": 0x12, "pack": 0x13},
|
"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},
|
"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},
|
"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},
|
"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},
|
"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},
|
"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},
|
"MIFARE Ultralight EV1 (MF0UL21)": {"user": (0x04, 0x23), "lock": 0x24, "cfg0": 0x25, "cfg1": 0x26, "pwd": 0x27, "pack": 0x28, "fam": "ul"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_ROLES = {
|
# The pages guaranteed on every Type 2 tag (down to the smallest, NTAG210): the static header and
|
||||||
"cfg0": "CFG0 — MIRROR / MIRROR_PAGE / AUTH0",
|
# user memory 0x04-0x0F. Used when only the NTAG/Ultralight *family* is known — GET_VERSION couldn't
|
||||||
"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
|
|
||||||
# complete on a flaky link, or an original Ultralight has no GET_VERSION. Config pages differ by
|
# 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)}
|
_GENERIC_TYPE2 = {"user": (0x04, 0x0F)}
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +47,29 @@ def _resolve_layout(transponder: str | None) -> dict | None:
|
|||||||
return 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:
|
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)."""
|
"""Role of a T5577 block: 0 = config, 7 = password, 1-6 = data (the emulated tag content)."""
|
||||||
if block is None:
|
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:
|
def page_role(transponder: str | None, page: int | None) -> str | None:
|
||||||
"""The role of ``page``/``block`` on ``transponder`` (user memory / CC / AUTH0 / PWD /
|
"""The role of ``page``/``block`` on ``transponder`` — the full Type 2 header (UID / static
|
||||||
T5577 config / password / …), or None."""
|
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():
|
if transponder and "T5577" in transponder.upper():
|
||||||
return _t5577_block_role(page)
|
return _t5577_block_role(page)
|
||||||
layout = _resolve_layout(transponder)
|
layout = _resolve_layout(transponder)
|
||||||
if layout is None or page is None:
|
if layout is None or page is None:
|
||||||
return None
|
return None
|
||||||
if 0 <= page <= 2:
|
if 0 <= page <= 1:
|
||||||
return "UID / serial number"
|
return "UID / serial number"
|
||||||
|
if page == 2:
|
||||||
|
return "static lock bytes + internal (locks pages 03-0F)"
|
||||||
if page == 3:
|
if page == 3:
|
||||||
return "Capability Container (CC)"
|
return _page3_role(layout)
|
||||||
lo, hi = layout["user"]
|
lo, hi = layout["user"]
|
||||||
if lo <= page <= hi:
|
if lo <= page <= hi:
|
||||||
return "user memory"
|
return "user memory"
|
||||||
for key, role in _ROLES.items():
|
if page == layout.get("lock"):
|
||||||
if page == layout.get(key):
|
return "dynamic lock bytes"
|
||||||
return role
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def landmark_pages(transponder: str | None) -> list[tuple[int, str]]:
|
def landmark_pages(transponder: str | None) -> list[tuple[int, str]]:
|
||||||
"""Notable pages/blocks with their datasheet role, for argument autocomplete inside a
|
"""Notable pages/blocks with their datasheet role, for the completion menu's memory map. Every
|
||||||
``READ(<page>)`` / ``WRITE(<block>)`` call — so the completion menu lists the memory map
|
named region of the identified tag — UID, static lock, CC/OTP, user, dynamic lock, CFG0/CFG1,
|
||||||
(``0x03 → Capability Container``, ``0xE5 → PWD``, …). Ordered by page. Empty when the tag
|
PWD, PACK. Ordered by page; empty when the tag has no known layout."""
|
||||||
has no known layout."""
|
|
||||||
if transponder and "T5577" in transponder.upper():
|
if transponder and "T5577" in transponder.upper():
|
||||||
return [(b, _t5577_block_role(b)) for b in range(8)]
|
return [(b, _t5577_block_role(b)) for b in range(8)]
|
||||||
layout = _resolve_layout(transponder)
|
layout = _resolve_layout(transponder)
|
||||||
if layout is None:
|
if layout is None:
|
||||||
return []
|
return []
|
||||||
out = {0: "UID / serial number", 3: "Capability Container (CC)",
|
pages = {0, 2, 3, layout["user"][0]}
|
||||||
layout["user"][0]: "user memory"}
|
|
||||||
for key in ("lock", "cfg0", "cfg1", "pwd", "pack"):
|
for key in ("lock", "cfg0", "cfg1", "pwd", "pack"):
|
||||||
if key in layout:
|
if key in layout:
|
||||||
out[layout[key]] = _ROLES.get(key, key)
|
pages.add(layout[key])
|
||||||
return sorted(out.items())
|
return [(p, page_role(transponder, p)) for p in sorted(pages) if page_role(transponder, p)]
|
||||||
|
|||||||
@@ -634,23 +634,43 @@ class TestMemoryHint:
|
|||||||
def test_page_role(self):
|
def test_page_role(self):
|
||||||
from pm3py.cli.rawcli.memory import page_role
|
from pm3py.cli.rawcli.memory import page_role
|
||||||
assert page_role("NTAG213", 0) == "UID / serial number"
|
assert page_role("NTAG213", 0) == "UID / serial number"
|
||||||
|
assert page_role("NTAG213", 1) == "UID / serial number"
|
||||||
|
assert "static lock" in page_role("NTAG213", 2) # page 2 is lock/internal, not UID
|
||||||
assert page_role("NTAG213", 3) == "Capability Container (CC)"
|
assert page_role("NTAG213", 3) == "Capability Container (CC)"
|
||||||
assert page_role("NTAG213", 4) == "user memory"
|
assert page_role("NTAG213", 4) == "user memory"
|
||||||
|
assert page_role("NTAG213", 0x28) == "dynamic lock bytes"
|
||||||
assert "AUTH0" in page_role("NTAG213", 0x29) # CFG0
|
assert "AUTH0" in page_role("NTAG213", 0x29) # CFG0
|
||||||
assert page_role("NTAG213", 0x2A).startswith("CFG1")
|
assert page_role("NTAG213", 0x2A).startswith("CFG1")
|
||||||
assert "PWD" in page_role("NTAG213", 0x2B)
|
assert "PWD" in page_role("NTAG213", 0x2B)
|
||||||
|
assert "PACK" in page_role("NTAG213", 0x2C)
|
||||||
assert page_role("NTAG216", 0x04) == "user memory" # different IC, different pages
|
assert page_role("NTAG216", 0x04) == "user memory" # different IC, different pages
|
||||||
assert "AUTH0" in page_role("NTAG216", 0xE3)
|
assert "AUTH0" in page_role("NTAG216", 0xE3)
|
||||||
|
|
||||||
|
def test_page_role_family_differences(self):
|
||||||
|
from pm3py.cli.rawcli.memory import page_role
|
||||||
|
# NTAG 213/215/216 have the NFC counter bits in CFG1; 210/212 don't
|
||||||
|
assert "NFC_CNT_EN" in page_role("NTAG213", 0x2A)
|
||||||
|
assert "NFC_CNT_EN" not in page_role("NTAG210", 0x11) # CFG1, no counter
|
||||||
|
assert "NFC_CNT_EN" not in page_role("NTAG212", 0x26)
|
||||||
|
# NTAG has the ASCII mirror in CFG0; Ultralight EV1 does not
|
||||||
|
assert "MIRROR" in page_role("NTAG213", 0x29)
|
||||||
|
assert "MIRROR" not in page_role("MIFARE Ultralight EV1 (MF0UL11)", 0x10)
|
||||||
|
# page 3 is CC on NTAG, OTP on Ultralight
|
||||||
|
assert page_role("MIFARE Ultralight EV1 (MF0UL11)", 3) == "OTP (one-time programmable)"
|
||||||
|
assert page_role("MIFARE Ultralight EV1 (MF0UL21)", 3).startswith("OTP")
|
||||||
|
# UL21 has a dynamic lock page (like NTAG212); UL11 doesn't (like NTAG210)
|
||||||
|
assert page_role("MIFARE Ultralight EV1 (MF0UL21)", 0x24) == "dynamic lock bytes"
|
||||||
|
|
||||||
def test_generic_type2_fallback(self):
|
def test_generic_type2_fallback(self):
|
||||||
from pm3py.cli.rawcli.memory import landmark_pages, page_role
|
from pm3py.cli.rawcli.memory import landmark_pages, page_role
|
||||||
# only the family is known (flaky/absent GET_VERSION) -> universal pages still resolve
|
# only the family is known (flaky/absent GET_VERSION) -> the shared header still resolves
|
||||||
for name in ("MIFARE Ultralight / NTAG", "NTAG (144 B)"):
|
for name in ("MIFARE Ultralight / NTAG", "NTAG (144 B)"):
|
||||||
assert page_role(name, 4) == "user memory"
|
assert page_role(name, 4) == "user memory"
|
||||||
assert page_role(name, 0) == "UID / serial number"
|
assert page_role(name, 0) == "UID / serial number"
|
||||||
assert page_role(name, 3) == "Capability Container (CC)"
|
assert "static lock" in page_role(name, 2)
|
||||||
|
assert page_role(name, 3) == "Capability Container (CC) / OTP" # family unknown
|
||||||
assert page_role(name, 0x20) is None # model-specific -> not guessed
|
assert page_role(name, 0x20) is None # model-specific -> not guessed
|
||||||
assert [p for p, _ in landmark_pages(name)] == [0, 3, 4] # no config landmarks
|
assert [p for p, _ in landmark_pages(name)] == [0, 2, 3, 4] # header, no config
|
||||||
assert page_role("MIFARE Classic 1K", 4) is None # not Type 2 -> nothing
|
assert page_role("MIFARE Classic 1K", 4) is None # not Type 2 -> nothing
|
||||||
|
|
||||||
def test_t5577_block_roles(self):
|
def test_t5577_block_roles(self):
|
||||||
@@ -668,6 +688,18 @@ class TestMemoryHint:
|
|||||||
L = _LAYOUTS[name]
|
L = _LAYOUTS[name]
|
||||||
assert (L["cfg0"], L["cfg1"], L["pwd"], L["pack"]) == \
|
assert (L["cfg0"], L["cfg1"], L["pwd"], L["pack"]) == \
|
||||||
(cls._cfg0_page, cls._cfg1_page, cls._pwd_page, cls._pack_page)
|
(cls._cfg0_page, cls._cfg1_page, cls._pwd_page, cls._pack_page)
|
||||||
|
assert L["fam"] == "ntag"
|
||||||
|
assert L.get("cnt", False) == cls._has_nfc_counter # CFG1 NFC-counter bits
|
||||||
|
|
||||||
|
def test_ultralight_layouts_match_models(self):
|
||||||
|
from pm3py.cli.rawcli.memory import _LAYOUTS
|
||||||
|
from pm3py.sim import MF0UL11, MF0UL21
|
||||||
|
for cls, name in [(MF0UL11, "MIFARE Ultralight EV1 (MF0UL11)"),
|
||||||
|
(MF0UL21, "MIFARE Ultralight EV1 (MF0UL21)")]:
|
||||||
|
L = _LAYOUTS[name]
|
||||||
|
assert (L["cfg0"], L["cfg1"], L["pwd"], L["pack"]) == \
|
||||||
|
(cls._cfg0_page, cls._cfg1_page, cls._pwd_page, cls._pack_page)
|
||||||
|
assert L["fam"] == "ul" # page 3 is OTP, no ASCII mirror
|
||||||
|
|
||||||
|
|
||||||
class TestKeyBindings:
|
class TestKeyBindings:
|
||||||
|
|||||||
Reference in New Issue
Block a user