From a5c77023989e94aa3fc1e3dc4fcf8a76c191d423 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 15 Jul 2026 19:18:05 -0700 Subject: [PATCH] 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 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 --- pm3py/cli/rawcli/completer.py | 7 +-- pm3py/cli/rawcli/memory.py | 93 ++++++++++++++++++++++------------- tests/test_rawcli.py | 38 ++++++++++++-- 3 files changed, 99 insertions(+), 39 deletions(-) diff --git a/pm3py/cli/rawcli/completer.py b/pm3py/cli/rawcli/completer.py index 5a130c5..353d247 100644 --- a/pm3py/cli/rawcli/completer.py +++ b/pm3py/cli/rawcli/completer.py @@ -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) diff --git a/pm3py/cli/rawcli/memory.py b/pm3py/cli/rawcli/memory.py index 935495b..b548ca7 100644 --- a/pm3py/cli/rawcli/memory.py +++ b/pm3py/cli/rawcli/memory.py @@ -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()`` / ``WRITE()`` 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)] diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index fa9c579..29eaed0 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -634,23 +634,43 @@ class TestMemoryHint: def test_page_role(self): from pm3py.cli.rawcli.memory import page_role 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", 4) == "user memory" + assert page_role("NTAG213", 0x28) == "dynamic lock bytes" assert "AUTH0" in page_role("NTAG213", 0x29) # CFG0 assert page_role("NTAG213", 0x2A).startswith("CFG1") 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 "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): 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)"): assert page_role(name, 4) == "user memory" 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 [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 def test_t5577_block_roles(self): @@ -668,6 +688,18 @@ class TestMemoryHint: 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"] == "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: