feat(hf): rename mfc -> mf (MIFARE Classic), add mfu (Ultralight/NTAG)
hf.mfc renamed to hf.mf to match the C client's 'hf mf'. New hf.mfu (HFMFUCommands) provides rdbl/wrbl/dump over the dedicated CMD_HF_MIFAREU_READBL/WRITEBL using the firmware mful_readblock_t / mful_writeblock_t structs; a READ returns 4 pages (16 bytes) and the firmware replies them directly. keytype selects auth (0=none, 1=UL-C, 2=EV1/NTAG pwd, 3=UL-AES). Wire formats verified against firmware structs; needs a bench pass for the auth/write-length paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ from .hf import HFCommands
|
|||||||
from .hf_iso14a import HF14ACommands
|
from .hf_iso14a import HF14ACommands
|
||||||
from .hf_iso15 import HF15Commands
|
from .hf_iso15 import HF15Commands
|
||||||
from .hf_mfc import HFMFCommands
|
from .hf_mfc import HFMFCommands
|
||||||
|
from .hf_mfu import HFMFUCommands
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -71,7 +72,8 @@ class Proxmark3:
|
|||||||
self.hf = HFCommands(self._transport)
|
self.hf = HFCommands(self._transport)
|
||||||
self.hf.iso14a = HF14ACommands(self._transport)
|
self.hf.iso14a = HF14ACommands(self._transport)
|
||||||
self.hf.iso15 = HF15Commands(self._transport)
|
self.hf.iso15 = HF15Commands(self._transport)
|
||||||
self.hf.mfc = HFMFCommands(self._transport)
|
self.hf.mf = HFMFCommands(self._transport)
|
||||||
|
self.hf.mfu = HFMFUCommands(self._transport)
|
||||||
self.firmware = FirmwareInfo()
|
self.firmware = FirmwareInfo()
|
||||||
|
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
@@ -156,7 +158,8 @@ class _SyncProxy:
|
|||||||
def __getattr__(self, name):
|
def __getattr__(self, name):
|
||||||
attr = getattr(self._target, name)
|
attr = getattr(self._target, name)
|
||||||
if isinstance(attr, (HWCommands, LFCommands, HFCommands,
|
if isinstance(attr, (HWCommands, LFCommands, HFCommands,
|
||||||
HF14ACommands, HF15Commands, HFMFCommands)):
|
HF14ACommands, HF15Commands, HFMFCommands,
|
||||||
|
HFMFUCommands)):
|
||||||
return _SyncProxy(attr, self._loop)
|
return _SyncProxy(attr, self._loop)
|
||||||
# Sub-modules attached dynamically (e.g. hf.iso14a, hf.mfc)
|
# Sub-modules attached dynamically (e.g. hf.iso14a, hf.mfc)
|
||||||
if hasattr(attr, '_t'):
|
if hasattr(attr, '_t'):
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ class HFCommands:
|
|||||||
# Sub-modules attached by client.py
|
# Sub-modules attached by client.py
|
||||||
self.iso14a = None # type: ignore
|
self.iso14a = None # type: ignore
|
||||||
self.iso15 = None # type: ignore
|
self.iso15 = None # type: ignore
|
||||||
self.mfc = None # type: ignore
|
self.mf = None # MIFARE Classic # type: ignore
|
||||||
|
self.mfu = None # MIFARE Ultralight / NTAG # type: ignore
|
||||||
|
|
||||||
async def tune(self, on_progress: ProgressCallback = None) -> dict:
|
async def tune(self, on_progress: ProgressCallback = None) -> dict:
|
||||||
"""Measure HF antenna voltage.
|
"""Measure HF antenna voltage.
|
||||||
|
|||||||
64
pm3py/core/hf_mfu.py
Normal file
64
pm3py/core/hf_mfu.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""MIFARE Ultralight / NTAG commands: hf.mfu.*
|
||||||
|
|
||||||
|
ISO14443-3A tags with 4-byte pages. A READ (0x30) returns 4 consecutive
|
||||||
|
pages (16 bytes). keytype selects the auth scheme:
|
||||||
|
0 = none, 1 = UL-C (16-byte 3DES key), 2 = UL-EV1/NTAG (4-byte pwd),
|
||||||
|
3 = UL-AES (16-byte key).
|
||||||
|
"""
|
||||||
|
import struct
|
||||||
|
from .protocol import Cmd
|
||||||
|
from .transport import PM3Transport
|
||||||
|
|
||||||
|
|
||||||
|
def _key_bytes(key) -> bytes:
|
||||||
|
if key is None:
|
||||||
|
return b""
|
||||||
|
if isinstance(key, str):
|
||||||
|
return bytes.fromhex(key)
|
||||||
|
return bytes(key)
|
||||||
|
|
||||||
|
|
||||||
|
class HFMFUCommands:
|
||||||
|
"""MIFARE Ultralight / NTAG commands."""
|
||||||
|
|
||||||
|
def __init__(self, transport: PM3Transport):
|
||||||
|
self._t = transport
|
||||||
|
|
||||||
|
async def rdbl(self, block: int, key=None, keytype: int = 0) -> dict:
|
||||||
|
"""Read starting at page ``block``; returns 16 bytes (4 pages)."""
|
||||||
|
kb = _key_bytes(key)
|
||||||
|
# mful_readblock_t { bool use_schann; u8 block_no; u8 num_of_blocks;
|
||||||
|
# u8 keytype; u8 keylen; u8 key[16] }
|
||||||
|
payload = struct.pack("<BBBBB16s", 0, block & 0xFF, 1, keytype & 0xFF,
|
||||||
|
len(kb), kb.ljust(16, b"\x00")[:16])
|
||||||
|
resp = await self._t.send_ng(Cmd.HF_MIFAREU_READBL, payload, timeout=5.0)
|
||||||
|
if resp.status != 0:
|
||||||
|
return {"success": False, "block": block, "error": resp.status}
|
||||||
|
return {"success": True, "block": block,
|
||||||
|
"data": resp.data.hex(), "raw": bytes(resp.data)}
|
||||||
|
|
||||||
|
async def wrbl(self, block: int, data, key=None, keytype: int = 0) -> dict:
|
||||||
|
"""Write 4 bytes to page ``block``."""
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = bytes.fromhex(data)
|
||||||
|
kb = _key_bytes(key)
|
||||||
|
# mful_writeblock_t { bool use_schann; u8 block_no; u8 keytype;
|
||||||
|
# u8 keylen; u8 key[16]; u8 data[16] }
|
||||||
|
payload = struct.pack("<BBBB16s16s", 0, block & 0xFF, keytype & 0xFF,
|
||||||
|
len(kb), kb.ljust(16, b"\x00")[:16],
|
||||||
|
bytes(data).ljust(16, b"\x00")[:16])
|
||||||
|
resp = await self._t.send_ng(Cmd.HF_MIFAREU_WRITEBL, payload, timeout=5.0)
|
||||||
|
return {"success": resp.status == 0, "block": block}
|
||||||
|
|
||||||
|
async def dump(self, start: int = 0, pages: int = 16) -> dict:
|
||||||
|
"""Read ``pages`` pages from ``start`` (each READ returns 4 pages)."""
|
||||||
|
out = bytearray()
|
||||||
|
page = start
|
||||||
|
while page < start + pages:
|
||||||
|
r = await self.rdbl(page)
|
||||||
|
if not r.get("success"):
|
||||||
|
break
|
||||||
|
out += r["raw"][:16]
|
||||||
|
page += 4
|
||||||
|
return {"success": len(out) > 0, "pages": len(out) // 4,
|
||||||
|
"data": out.hex(), "raw": bytes(out)}
|
||||||
@@ -108,3 +108,35 @@ def test_mf_sniff_routes_to_14a():
|
|||||||
assert t.send_mix.call_args.args[0] == Cmd.HF_ISO14443A_SNIFF
|
assert t.send_mix.call_args.args[0] == Cmd.HF_ISO14443A_SNIFF
|
||||||
assert t.send_mix.call_args.kwargs["arg0"] == 0
|
assert t.send_mix.call_args.kwargs["arg0"] == 0
|
||||||
t.send_ng.assert_not_called()
|
t.send_ng.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mfu_rdbl():
|
||||||
|
import struct as _struct
|
||||||
|
from pm3py.core.hf_mfu import HFMFUCommands
|
||||||
|
t = AsyncMock()
|
||||||
|
mfu = HFMFUCommands(t)
|
||||||
|
# firmware replies 16 bytes (4 pages) directly
|
||||||
|
t.send_ng.return_value = PM3Response(cmd=Cmd.HF_MIFAREU_READBL, status=0,
|
||||||
|
reason=0, ng=True, data=bytes(range(16)))
|
||||||
|
r = asyncio.get_event_loop().run_until_complete(mfu.rdbl(block=4))
|
||||||
|
assert r["success"] is True
|
||||||
|
assert r["data"] == "000102030405060708090a0b0c0d0e0f"
|
||||||
|
# request packs mful_readblock_t {use_schann,block_no,num,keytype,keylen,key[16]} = 21 bytes
|
||||||
|
payload = t.send_ng.call_args.args[1]
|
||||||
|
assert len(payload) == 21
|
||||||
|
assert payload[1] == 4 # block_no
|
||||||
|
assert t.send_ng.call_args.args[0] == Cmd.HF_MIFAREU_READBL
|
||||||
|
|
||||||
|
|
||||||
|
def test_mfu_wrbl():
|
||||||
|
from pm3py.core.hf_mfu import HFMFUCommands
|
||||||
|
t = AsyncMock()
|
||||||
|
mfu = HFMFUCommands(t)
|
||||||
|
t.send_ng.return_value = PM3Response(cmd=Cmd.HF_MIFAREU_WRITEBL, status=0,
|
||||||
|
reason=0, ng=True, data=b"")
|
||||||
|
r = asyncio.get_event_loop().run_until_complete(mfu.wrbl(block=5, data="aabbccdd"))
|
||||||
|
assert r["success"] is True
|
||||||
|
payload = t.send_ng.call_args.args[1]
|
||||||
|
assert len(payload) == 36 # {use_schann,block,keytype,keylen,key[16],data[16]}
|
||||||
|
assert payload[1] == 5
|
||||||
|
assert payload[20:24] == bytes.fromhex("aabbccdd")
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ def test_full_api_surface():
|
|||||||
assert hasattr(pm3.lf, "t55")
|
assert hasattr(pm3.lf, "t55")
|
||||||
assert hasattr(pm3.hf, "iso14a")
|
assert hasattr(pm3.hf, "iso14a")
|
||||||
assert hasattr(pm3.hf, "iso15")
|
assert hasattr(pm3.hf, "iso15")
|
||||||
assert hasattr(pm3.hf, "mfc")
|
assert hasattr(pm3.hf, "mf")
|
||||||
assert hasattr(pm3, "send_ng")
|
assert hasattr(pm3, "send_ng")
|
||||||
assert hasattr(pm3, "send_mix")
|
assert hasattr(pm3, "send_mix")
|
||||||
assert hasattr(pm3, "firmware")
|
assert hasattr(pm3, "firmware")
|
||||||
@@ -45,9 +45,9 @@ def test_mf_rdbl_returns_hex():
|
|||||||
block = bytes(range(16))
|
block = bytes(range(16))
|
||||||
mock_transport.send_ng.return_value = PM3Response(
|
mock_transport.send_ng.return_value = PM3Response(
|
||||||
cmd=Cmd.HF_MIFARE_READBL, status=0, reason=0, ng=True, data=block)
|
cmd=Cmd.HF_MIFARE_READBL, status=0, reason=0, ng=True, data=block)
|
||||||
pm3.hf.mfc._t = mock_transport
|
pm3.hf.mf._t = mock_transport
|
||||||
result = asyncio.get_event_loop().run_until_complete(
|
result = asyncio.get_event_loop().run_until_complete(
|
||||||
pm3.hf.mfc.rdbl(block=4, key="FFFFFFFFFFFF"))
|
pm3.hf.mf.rdbl(block=4, key="FFFFFFFFFFFF"))
|
||||||
assert result["data"] == "000102030405060708090a0b0c0d0e0f"
|
assert result["data"] == "000102030405060708090a0b0c0d0e0f"
|
||||||
assert result["raw"] == block
|
assert result["raw"] == block
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user