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:
@@ -195,6 +195,17 @@ class SimSession:
|
|||||||
b"\x00" * 16, b"\x00" * 16,
|
b"\x00" * 16, b"\x00" * 16,
|
||||||
0,
|
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)
|
frame = encode_ng_frame(Cmd.HF_ISO14443A_SIMULATE, payload)
|
||||||
ser.write(frame)
|
ser.write(frame)
|
||||||
time.sleep(0.2) # let firmware start the sim before streaming
|
time.sleep(0.2) # let firmware start the sim before streaming
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ NDEF_TLV_TYPE = 0x03
|
|||||||
TERMINATOR_TLV = 0xFE
|
TERMINATOR_TLV = 0xFE
|
||||||
NDEF_MAGIC = 0xE1
|
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):
|
class NfcType2Tag(Tag14443A_3):
|
||||||
"""NFC Forum Type 2 Tag (NTAG-like).
|
"""NFC Forum Type 2 Tag (NTAG-like).
|
||||||
@@ -67,9 +73,10 @@ class NfcType2Tag(Tag14443A_3):
|
|||||||
f"NDEF message too large: {len(message)} bytes, "
|
f"NDEF message too large: {len(message)} bytes, "
|
||||||
f"capacity is {self._ndef_capacity} bytes")
|
f"capacity is {self._ndef_capacity} bytes")
|
||||||
|
|
||||||
# Clear data area
|
# Clear only the NDEF data area — never the lock / config / PWD / PACK
|
||||||
self._memory[self._ndef_data_offset:] = bytes(
|
# pages that follow it on NTAG-class parts (clearing to end wipes them).
|
||||||
len(self._memory) - self._ndef_data_offset)
|
end = self._ndef_area_end()
|
||||||
|
self._memory[self._ndef_data_offset:end] = bytes(end - self._ndef_data_offset)
|
||||||
|
|
||||||
offset = self._ndef_data_offset
|
offset = self._ndef_data_offset
|
||||||
if len(message) < 0xFF:
|
if len(message) < 0xFF:
|
||||||
@@ -82,9 +89,72 @@ class NfcType2Tag(Tag14443A_3):
|
|||||||
self._memory[offset:offset + len(tlv)] = tlv
|
self._memory[offset:offset + len(tlv)] = tlv
|
||||||
|
|
||||||
def clear_ndef(self) -> None:
|
def clear_ndef(self) -> None:
|
||||||
"""Remove NDEF message."""
|
"""Remove NDEF message (leaves lock/config/PWD/PACK pages intact)."""
|
||||||
self._memory[self._ndef_data_offset:] = bytes(
|
end = self._ndef_area_end()
|
||||||
len(self._memory) - self._ndef_data_offset)
|
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:
|
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||||
if not frame.data:
|
if not frame.data:
|
||||||
|
|||||||
101
tests/test_sim_14a_eml_sync.py
Normal file
101
tests/test_sim_14a_eml_sync.py
Normal 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()
|
||||||
Reference in New Issue
Block a user