From 260c1adad83250ce752493856e26a57ce1121051 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 15 Jul 2026 19:35:33 -0700 Subject: [PATCH] feat(rawcli): MIFARE Classic block-level memory map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MFC gets the same memory-map hinting as NTAG, at the block level: block 0 manufacturer block — UID, BCC, SAK, ATQA, vendor data sector trailer Key A [0-5], access bits [6-8] + GPB [9], Key B [10-15] other blocks data block (sector N) Handles 1K (16 sectors), Mini (5 sectors) and 4K — including the eight 16-block sectors 32-39 (trailer = last block, e.g. 143 = sector 32, 255 = sector 39). landmark_pages lists block 0 + every sector trailer (the map skeleton; data blocks are uniform), so READ( / raw 30 surface it in the completion dropdown. Also routes "MIFARE Mini" to the MFC catalog (was falling through to Type 2). Tests cover the block roles, large-sector trailers, bounds, Mini routing, and the dropdown map. Path verified end-to-end with a mocked SAK-0x08 scan (identify -> catalog -> completer). Live hardware verify still needs an actual Classic card on the antenna. 1339 green. Co-Authored-By: Claude Opus 4.8 --- pm3py/cli/rawcli/catalog.py | 2 +- pm3py/cli/rawcli/memory.py | 54 +++++++++++++++++++++++++++++++++++++ tests/test_rawcli.py | 37 ++++++++++++++++++++++--- 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/pm3py/cli/rawcli/catalog.py b/pm3py/cli/rawcli/catalog.py index 7f0ff6d..75ecc38 100644 --- a/pm3py/cli/rawcli/catalog.py +++ b/pm3py/cli/rawcli/catalog.py @@ -194,7 +194,7 @@ def catalog_for(session) -> Catalog | None: if session.protocol == "hf14a": if not session.transponder: return None - return MFC if "CLASSIC" in name else TYPE2 + return MFC if ("CLASSIC" in name or "MINI" in name) else TYPE2 if session.protocol == "lf": if not session.transponder: return None diff --git a/pm3py/cli/rawcli/memory.py b/pm3py/cli/rawcli/memory.py index b548ca7..bd978c6 100644 --- a/pm3py/cli/rawcli/memory.py +++ b/pm3py/cli/rawcli/memory.py @@ -70,6 +70,56 @@ def _cfg1_role(layout: dict) -> str: return f"CFG1 — ACCESS ({bits})" +# ---- MIFARE Classic block map -------------------------------------------------------------- +# 1K/Mini: 4-block sectors throughout. 4K: sectors 0-31 are 4 blocks, sectors 32-39 are 16 blocks. +# The sector trailer (last block of each sector) holds Key A / access bits+GPB / Key B; block 0 is +# the manufacturer block. Everything else is a data block. +_MFC_MAX_BLOCK = {"mini": 19, "1k": 63, "4k": 255} + + +def _mfc_size(transponder: str | None) -> str | None: + """'mini' / '1k' / '4k' for a MIFARE Classic name, else None.""" + if not transponder: + return None + up = transponder.upper() + if "MINI" in up and "MIFARE" in up: + return "mini" + if "CLASSIC" in up: + return "4k" if "4K" in up else "1k" + return None + + +def _mfc_sector(size: str, block: int) -> tuple[int, bool]: + """(sector number, is-trailer) for ``block`` on a Classic of ``size``.""" + if size == "4k" and block >= 128: # the eight 16-block sectors 32-39 + sector = 32 + (block - 128) // 16 + return sector, (block - 128) % 16 == 15 + sector = block // 4 + return sector, block % 4 == 3 + + +def _mfc_block_role(transponder: str | None, block: int | None) -> str | None: + size = _mfc_size(transponder) + if size is None or block is None or not (0 <= block <= _MFC_MAX_BLOCK[size]): + return None + if block == 0: + return "manufacturer block — UID, BCC, SAK, ATQA, vendor data" + sector, is_trailer = _mfc_sector(size, block) + if is_trailer: + return f"sector {sector} trailer — Key A [0-5], access bits [6-8] + GPB [9], Key B [10-15]" + return f"data block (sector {sector})" + + +def _mfc_landmarks(transponder: str | None) -> list[tuple[int, str]]: + """Block 0 + every sector trailer — the Classic memory-map skeleton (data blocks are uniform).""" + size = _mfc_size(transponder) + if size is None: + return [] + blocks = [0] + [b for b in range(1, _MFC_MAX_BLOCK[size] + 1) + if _mfc_sector(size, b)[1]] + return [(b, _mfc_block_role(transponder, b)) for b in blocks] + + 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: @@ -89,6 +139,8 @@ def page_role(transponder: str | None, page: int | None) -> str | None: or a T5577 block role, or None when unknown.""" if transponder and "T5577" in transponder.upper(): return _t5577_block_role(page) + if _mfc_size(transponder): + return _mfc_block_role(transponder, page) layout = _resolve_layout(transponder) if layout is None or page is None: return None @@ -120,6 +172,8 @@ def landmark_pages(transponder: str | None) -> list[tuple[int, str]]: 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)] + if _mfc_size(transponder): + return _mfc_landmarks(transponder) layout = _resolve_layout(transponder) if layout is None: return [] diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index 29eaed0..d6c04c7 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -370,6 +370,8 @@ class TestCatalog: assert catalog_for(s) is TYPE2 s.transponder = "MIFARE Classic 1K" assert catalog_for(s).name == "MIFARE Classic" + s.transponder = "MIFARE Mini" # Mini is a Classic -> MFC, not Type 2 + assert catalog_for(s).name == "MIFARE Classic" s.protocol, s.transponder = "hf15", "ISO15693" assert catalog_for(s) is ISO15 @@ -600,10 +602,16 @@ class TestCompleter: assert "config" in by_text["0x00"] assert "password" in by_text["0x07"] - def test_page_arg_none_without_layout(self): + def test_page_arg_mfc_block_map(self): + # MIFARE Classic gets its block map in the dropdown, same as NTAG pages s = RawSession() - s.protocol, s.transponder = "hf14a", "MIFARE Classic 1K" # no page layout - assert self._complete(s, "READ(") == [] + s.protocol, s.transponder = "hf14a", "MIFARE Classic 1K" + by_text = {c.text: c.display_meta_text for c in self._complete(s, "READ(")} + assert "manufacturer" in by_text["0x00"] + assert by_text["0x03"].startswith("sector 0 trailer") + assert "0x3F" in by_text # block 63, sector 15 trailer + # raw byte form works too + assert "manufacturer" in {c.text: c.display_meta_text for c in self._complete(s, "30 ")}["00"] def test_page_arg_binary_mode(self): # in binary entry mode the landmarks render as full 8-bit values (each bit visible) @@ -671,7 +679,7 @@ class TestMemoryHint: 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, 2, 3, 4] # header, no config - assert page_role("MIFARE Classic 1K", 4) is None # not Type 2 -> nothing + assert page_role("MIFARE DESFire", 4) is None # no map for this tag -> nothing def test_t5577_block_roles(self): from pm3py.cli.rawcli.memory import page_role @@ -680,6 +688,27 @@ class TestMemoryHint: assert "password" in page_role(tp, 7) assert "data" in page_role(tp, 3) + def test_mfc_block_roles(self): + from pm3py.cli.rawcli.memory import page_role, landmark_pages + # 1K: block 0 manufacturer, every 4th block a sector trailer, rest data + assert "manufacturer" in page_role("MIFARE Classic 1K", 0) + assert page_role("MIFARE Classic 1K", 1) == "data block (sector 0)" + assert page_role("MIFARE Classic 1K", 3).startswith("sector 0 trailer") + assert "Key A" in page_role("MIFARE Classic 1K", 3) and "Key B" in page_role("MIFARE Classic 1K", 3) + assert page_role("MIFARE Classic 1K", 63).startswith("sector 15 trailer") + assert page_role("MIFARE Classic 1K", 64) is None # out of range + # 4K large sectors 32-39 are 16 blocks each (trailer = last block) + assert page_role("MIFARE Classic 4K", 143).startswith("sector 32 trailer") + assert page_role("MIFARE Classic 4K", 255).startswith("sector 39 trailer") + assert page_role("MIFARE Classic 4K", 254) == "data block (sector 39)" + # Mini is a 5-sector Classic + assert page_role("MIFARE Mini", 19).startswith("sector 4 trailer") + assert page_role("MIFARE Mini", 20) is None + # landmarks = block 0 + every trailer + assert [b for b, _ in landmark_pages("MIFARE Classic 1K")] == [0, 3, 7, 11, 15, 19, 23, + 27, 31, 35, 39, 43, 47, 51, 55, 59, 63] + assert len(landmark_pages("MIFARE Classic 4K")) == 41 # block 0 + 40 sectors + def test_layouts_match_models(self): from pm3py.cli.rawcli.memory import _LAYOUTS from pm3py.sim import NTAG210, NTAG212, NTAG213, NTAG215, NTAG216