diff --git a/pm3py/sim/sim_session.py b/pm3py/sim/sim_session.py index e4dab34..3c1eee3 100644 --- a/pm3py/sim/sim_session.py +++ b/pm3py/sim/sim_session.py @@ -195,6 +195,17 @@ class SimSession: b"\x00" * 16, b"\x00" * 16, 0, ) + + # Load the model's memory into emulator RAM BEFORE starting the sim, so the + # reader sees real page data (UID pages, CC, NDEF, PWD, PACK) instead of a + # stale/empty buffer. The MFU/NTAG sim reads pages + version + signature from + # this mfu_dump image (firmware is data-driven for tagtype 2/7). Handled by + # the firmware command dispatch here (sim not yet running); tag.sync() reuses + # the same push to update a running sim. + if hasattr(tag, "_build_mfu_dump"): + tag.sync() + time.sleep(0.05) + frame = encode_ng_frame(Cmd.HF_ISO14443A_SIMULATE, payload) ser.write(frame) time.sleep(0.2) # let firmware start the sim before streaming diff --git a/pm3py/transponders/hf/iso14443a/ndef.py b/pm3py/transponders/hf/iso14443a/ndef.py index 55a27ef..49e78ca 100644 --- a/pm3py/transponders/hf/iso14443a/ndef.py +++ b/pm3py/transponders/hf/iso14443a/ndef.py @@ -13,6 +13,12 @@ NDEF_TLV_TYPE = 0x03 TERMINATOR_TLV = 0xFE NDEF_MAGIC = 0xE1 +# Firmware emulator-memory layout (must match firmware include/mifare.h). +# The MFU/NTAG sim (tagtype 2/7) reads pages/version/signature from a 56-byte +# mfu_dump_t prefix and serves page reads from the data area that follows it. +MFU_DUMP_PREFIX_LENGTH = 56 +CMD_HF_MIFARE_EML_MEMSET = 0x0602 # pm3_cmd.h; {u16 blockno,u8 cnt,u8 width,data} + class NfcType2Tag(Tag14443A_3): """NFC Forum Type 2 Tag (NTAG-like). @@ -67,9 +73,10 @@ class NfcType2Tag(Tag14443A_3): f"NDEF message too large: {len(message)} bytes, " f"capacity is {self._ndef_capacity} bytes") - # Clear data area - self._memory[self._ndef_data_offset:] = bytes( - len(self._memory) - self._ndef_data_offset) + # Clear only the NDEF data area — never the lock / config / PWD / PACK + # pages that follow it on NTAG-class parts (clearing to end wipes them). + end = self._ndef_area_end() + self._memory[self._ndef_data_offset:end] = bytes(end - self._ndef_data_offset) offset = self._ndef_data_offset if len(message) < 0xFF: @@ -82,9 +89,72 @@ class NfcType2Tag(Tag14443A_3): self._memory[offset:offset + len(tlv)] = tlv def clear_ndef(self) -> None: - """Remove NDEF message.""" - self._memory[self._ndef_data_offset:] = bytes( - len(self._memory) - self._ndef_data_offset) + """Remove NDEF message (leaves lock/config/PWD/PACK pages intact).""" + end = self._ndef_area_end() + self._memory[self._ndef_data_offset:end] = bytes(end - self._ndef_data_offset) + + def _ndef_area_end(self) -> int: + """Byte offset just past the NDEF data area. + + Bounded by the CC-declared size (page 3 byte 2 × 8) so the NDEF area never + overlaps the lock/config/PWD/PACK pages at the top of an NTAG's memory. + Falls back to end-of-memory when no CC size is present. + """ + cc_size = self._memory[3 * PAGE_SIZE + 2] * 8 + if cc_size: + return min(self._ndef_data_offset + cc_size, len(self._memory)) + return len(self._memory) + + # ----- emulator-memory sync (14a sim) ----- + + def _build_mfu_dump(self) -> bytes: + """Assemble the firmware ``mfu_dump_t`` image: 56-byte prefix + page data. + + The 14a MFU/NTAG sim (tagtype 2/7) is data-driven — firmware reads + ``pages``, ``version`` and ``signature`` from this prefix and serves page + reads from the data area at offset 56. Loaded into emulator RAM so the + reader sees the model's real memory (UID pages, CC, NDEF, PWD, PACK). + """ + prefix = bytearray(MFU_DUMP_PREFIX_LENGTH) + version = getattr(self, "_version_bytes", None) or b"\x00" * 8 + prefix[0:8] = bytes(version)[:8].ljust(8, b"\x00") # version[8] + # tbo[2] @8, tbo1 @10 stay 0 + prefix[11] = (self._total_pages - 1) & 0xFF # pages = last page idx + signature = getattr(self, "_signature", None) or b"\x00" * 32 + prefix[12:44] = bytes(signature)[:32].ljust(32, b"\x00") # signature[32] + # counter_tearing[3][4] @44: NTAG21x NFC counter lives at index 2 (24-bit LE) + counter = int(getattr(self, "_nfc_counter", 0) or 0) & 0xFFFFFF + prefix[52:55] = counter.to_bytes(3, "little") + return bytes(prefix) + bytes(self._memory) + + def sync(self) -> None: + """Push the full tag memory into the running sim's emulator RAM. + + Call after mutating any memory — pages 0/1, CC, NDEF, PWD, PACK — so the + firmware serves the updated bytes. READ/FAST_READ and PWD_AUTH read the + emulator buffer live, so changes take effect on the next reader command. + Requires binding to a live sim (``SimSession.start_14a`` sets ``_serial``); + for a change to land while a sim is *already running*, the firmware must + accept EML updates mid-sim (the live-sync firmware build). + """ + if self._serial is None: + raise RuntimeError( + "Not bound to a live session — call SimSession.start_14a(tag) first") + import time + from pm3py.core.transport import encode_ng_frame + + image = self._build_mfu_dump() + if len(image) % PAGE_SIZE: + image += b"\x00" * (PAGE_SIZE - len(image) % PAGE_SIZE) + total_blocks = len(image) // PAGE_SIZE + MAX_BLOCKS = 120 # 120*4 = 480B payload (+4B header) < 512B NG cap + for start in range(0, total_blocks, MAX_BLOCKS): + n = min(MAX_BLOCKS, total_blocks - start) + chunk = image[start * PAGE_SIZE:(start + n) * PAGE_SIZE] + # EML_MEMSET: {u16 blockno, u8 blockcnt, u8 blockwidth, data[]} + payload = struct.pack(" RFFrame | None: if not frame.data: diff --git a/tests/test_sim_14a_eml_sync.py b/tests/test_sim_14a_eml_sync.py new file mode 100644 index 0000000..af948c6 --- /dev/null +++ b/tests/test_sim_14a_eml_sync.py @@ -0,0 +1,101 @@ +"""14a sim memory sync: mfu_dump_t builder, EML_MEMSET push, config-page safety. + +Covers the P0 fix — loading a Type 2 (NTAG/Ultralight) model's memory into the +firmware emulator so the reader sees real page data (UID/CC/NDEF/PWD/PACK). +""" +import struct +from unittest.mock import MagicMock, patch + +from pm3py.sim import NTAG213, NTAG216, ndef_uri +from pm3py.transponders.hf.iso14443a.ndef import ( + MFU_DUMP_PREFIX_LENGTH, CMD_HF_MIFARE_EML_MEMSET, PAGE_SIZE, +) + + +def test_mfu_dump_layout(): + t = NTAG213() + t.set_ndef(ndef_uri("https://dngr.us/rick")) + img = t._build_mfu_dump() + + assert len(img) == MFU_DUMP_PREFIX_LENGTH + t._total_pages * PAGE_SIZE + assert img[0:8] == t._version_bytes # version[8] + assert img[11] == t._total_pages - 1 # pages = last page index + assert img[12:44] == bytes(t._signature)[:32].ljust(32, b"\x00") # signature[32] + # page data begins at offset 56 and mirrors _memory + assert img[MFU_DUMP_PREFIX_LENGTH:] == bytes(t._memory) + # NDEF TLV present at page 4 + assert img[MFU_DUMP_PREFIX_LENGTH + 4 * PAGE_SIZE] == 0x03 + + +def test_set_ndef_preserves_config_pages(): + """set_ndef must not wipe the lock/config/PWD/PACK pages above the NDEF area.""" + t = NTAG213() + assert t.get_page(0x2B) == b"\xff\xff\xff\xff" # PWD default + assert t.get_page(0x29)[3] == 0xFF # CFG0 AUTH0 + + t.set_ndef(ndef_uri("https://dngr.us/rick")) + + assert t.get_page(0x2B) == b"\xff\xff\xff\xff" # PWD survived + assert t.get_page(0x29)[3] == 0xFF # AUTH0 survived + assert t.get_page(4)[0] == 0x03 # NDEF TLV written + + +def test_clear_ndef_preserves_config_pages(): + t = NTAG213() + t.set_ndef(ndef_uri("https://dngr.us/rick")) + t.clear_ndef() + assert t.get_page(0x2B) == b"\xff\xff\xff\xff" # PWD still intact + assert t.get_page(4) == b"\x00\x00\x00\x00" # NDEF area cleared + + +def _capture_eml_writes(tag): + """Run tag.sync() with a mock serial; return list of (cmd, payload).""" + captured = [] + + def fake_encode(cmd, payload=b""): + captured.append((cmd, payload)) + return b"" + + tag._serial = MagicMock() + with patch("pm3py.core.transport.encode_ng_frame", side_effect=fake_encode): + tag.sync() + return captured + + +def test_sync_pushes_full_image_via_eml_memset(): + t = NTAG213() + t.set_ndef(ndef_uri("https://dngr.us/rick")) + image = t._build_mfu_dump() + + frames = _capture_eml_writes(t) + assert frames, "sync() wrote no frames" + + reassembled = bytearray() + for cmd, payload in frames: + assert cmd == CMD_HF_MIFARE_EML_MEMSET + blockno, blockcnt, blockwidth = struct.unpack_from(" multiple EML_MEMSET.""" + t = NTAG216() + frames = _capture_eml_writes(t) + assert len(frames) >= 2 + # every chunk stays within the NG payload cap + for _cmd, payload in frames: + assert len(payload) <= 512 + + +def test_sync_requires_live_session(): + import pytest + t = NTAG213() + t._serial = None + with pytest.raises(RuntimeError, match="live session"): + t.sync()