feat(sim): load 14a tag memory into emulator RAM (mfu_dump build + tag.sync)

- NfcType2Tag._build_mfu_dump(): assemble the firmware mfu_dump_t (56-byte prefix
  version/pages/signature + page data) so the sim serves real memory (UID/CC/NDEF/
  PWD/PACK). Firmware is data-driven for tagtype 2/7, so an NTAG213 dump presents
  correctly under tagtype 7.
- NfcType2Tag.sync(): push the image via CMD_HF_MIFARE_EML_MEMSET (same primitive
  as the client's hf mfu esetblk), chunked. Updates a live sim with the fw host-poll.
- start_14a: load the dump before HF_ISO14443A_SIMULATE so the reader sees real
  page data instead of an empty buffer (works on current fw; no reflash for this).
- fix: set_ndef/clear_ndef cleared memory to the end, wiping the lock/config/PWD/
  PACK pages on NTAG parts. Bounded to the CC-declared NDEF area now.
- tests: mfu_dump layout, config-page preservation, EML_MEMSET framing/chunking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-05 18:29:12 -07:00
parent 3123d4bc6a
commit fb3dc5cd68
3 changed files with 188 additions and 6 deletions

View File

@@ -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

View File

@@ -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("<HBB", start, n, PAGE_SIZE) + chunk
self._serial.write(encode_ng_frame(CMD_HF_MIFARE_EML_MEMSET, payload))
time.sleep(0.02)
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
if not frame.data:

View File

@@ -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("<HBB", payload, 0)
assert blockwidth == PAGE_SIZE
data = payload[4:]
assert len(data) == blockcnt * blockwidth
assert blockno * blockwidth == len(reassembled) # contiguous, in order
reassembled += data
assert bytes(reassembled) == image
def test_sync_chunks_large_tag():
"""NTAG216 (~231 pages) exceeds one 512-byte frame -> 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()