From 92b4e8771dc1113801a3ba40ec5c059600e3b9ae Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 14 Jul 2026 13:07:50 -0700 Subject: [PATCH] fix(rawcli): writes take (location, data); multi-frame + CRC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A write needs both a location and data. NTAG WRITE (0xA2) already did; the two-phase 0xA0 writes (Type 2 COMPAT_WRITE, MIFARE Classic WRITE) only took the page/block. They now take (page/block, data) and build a frame *list* — the command frame plus the 16-byte data frame. - Catalog build() may now return a list of frames; _handle_call sends each in sequence and traces every exchange (so both phases of a write show). - 14a catalog commands now append the ISO14443-A CRC (ISO14A_APPEND_CRC), which real tags require — manual raw hex is still sent verbatim. 3 new/updated tests (two-phase build + send, CRC flag on reads). Co-Authored-By: Claude Opus 4.8 --- pm3py/cli/rawcli/app.py | 19 +++++++++++++------ pm3py/cli/rawcli/catalog.py | 10 ++++++---- tests/test_rawcli.py | 17 +++++++++++++++-- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/pm3py/cli/rawcli/app.py b/pm3py/cli/rawcli/app.py index 2fa0d8a..1cbfa48 100644 --- a/pm3py/cli/rawcli/app.py +++ b/pm3py/cli/rawcli/app.py @@ -40,13 +40,17 @@ def _connect(port): return None -def _raw_exchange(device, payload: bytes, protocol: str = "hf14a") -> bytes | None: - """Issue one raw exchange over the identified protocol, returning the response bytes.""" +ISO14A_APPEND_CRC = 0x20 + + +def _raw_exchange(device, payload: bytes, protocol: str = "hf14a", crc: bool = False) -> bytes | None: + """Issue one raw exchange over the identified protocol, returning the response bytes. ``crc`` + appends the ISO14443-A CRC (catalog commands need it; manual raw hex is sent verbatim).""" if device is None: return None try: if protocol == "hf14a": - resp = device.hf.iso14a.raw(payload) + resp = device.hf.iso14a.raw(payload, flags=ISO14A_APPEND_CRC if crc else 0) elif protocol == "hf15": raw = getattr(device.hf.iso15, "raw", None) resp = raw(payload) if raw else None @@ -111,12 +115,15 @@ def _handle_call(state: RawSession, cmd, out=print) -> None: out(f"unknown command {cmd.name!r} for {catalog.name}; try 'help'") return try: - payload = tc.build(*cmd.args) + built = tc.build(*cmd.args) except (TypeError, ValueError) as exc: out(f"usage: {tc.usage()} ({exc})") return - response = _raw_exchange(state.device, payload, protocol=catalog.protocol) - out(render_exchange(payload, response, protocol=catalog.protocol, is_tty=True)) + frames = built if isinstance(built, list) else [built] # multi-frame (e.g. two-phase write) + crc = catalog.protocol == "hf14a" # 14a commands need CRC to be accepted + for frame in frames: + response = _raw_exchange(state.device, frame, protocol=catalog.protocol, crc=crc) + out(render_exchange(frame, response, protocol=catalog.protocol, is_tty=True)) def _handle_tlv(state: RawSession, args, out=print) -> None: diff --git a/pm3py/cli/rawcli/catalog.py b/pm3py/cli/rawcli/catalog.py index ec40c87..d9b556f 100644 --- a/pm3py/cli/rawcli/catalog.py +++ b/pm3py/cli/rawcli/catalog.py @@ -68,8 +68,9 @@ TYPE2 = _catalog( _c("WRITE", ["page", "data"], lambda page, data: bytes([0xA2, _int(page)]) + _hex(data).ljust(4, b"\x00")[:4], "write 4 bytes to (0xA2)"), - _c("COMPAT_WRITE", ["page"], lambda page: bytes([0xA0, _int(page)]), - "compatibility write, two-phase (0xA0)"), + _c("COMPAT_WRITE", ["page", "data"], + lambda page, data: [bytes([0xA0, _int(page)]), _hex(data).ljust(16, b"\x00")[:16]], + "compatibility write: 16-byte to , two-phase (0xA0)"), _c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the tag (0x50 00)"), ) @@ -78,8 +79,9 @@ MFC = _catalog( "MIFARE Classic", "hf14a", _c("READ", ["block"], lambda block: bytes([0x30, _int(block)]), "read a 16-byte block (needs prior auth) (0x30)"), - _c("WRITE", ["block"], lambda block: bytes([0xA0, _int(block)]), - "start a block write, two-phase (0xA0)"), + _c("WRITE", ["block", "data"], + lambda block, data: [bytes([0xA0, _int(block)]), _hex(data).ljust(16, b"\x00")[:16]], + "write a 16-byte block, two-phase (0xA0) — needs prior auth"), _c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the card (0x50 00)"), ) diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index a33bbfb..7636ecd 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -270,6 +270,8 @@ class TestCatalog: assert TYPE2.get("GET_VERSION").build() == b"\x60" assert TYPE2.get("WRITE").build("4", "01020304") == b"\xA2\x04\x01\x02\x03\x04" assert TYPE2.get("PWD_AUTH").build("FFFFFFFF") == b"\x1B\xFF\xFF\xFF\xFF" + # two-phase write builds a frame list: command frame + 16-byte data frame + assert TYPE2.get("COMPAT_WRITE").build("4", "0102") == [b"\xA0\x04", b"\x01\x02" + b"\x00" * 14] def test_iso15_builds(self): assert ISO15.get("READ_BLOCK").build("4") == b"\x02\x20\x04" @@ -294,12 +296,23 @@ class TestFunctionCalls: def test_call_read_sends_correct_bytes(self, capsys): s = self._id() dispatch(s, "identify") - s.device.hf.iso14a.raw.return_value = {"data": b"\xAA\xBB\xCC\xDD"} + s.device.hf.iso14a.raw.return_value = {"raw": b"\xAA\xBB\xCC\xDD"} capsys.readouterr() dispatch(s, "READ(4)") out = _plain(capsys.readouterr().out) assert "30 04" in out # the built command hit the trace - s.device.hf.iso14a.raw.assert_called_with(b"\x30\x04") + s.device.hf.iso14a.raw.assert_called_with(b"\x30\x04", flags=0x20) # CRC appended + + def test_two_phase_write_sends_both_frames(self, capsys): + s = self._id() # SAK 0 -> Type 2 catalog + dispatch(s, "identify") + s.device.hf.iso14a.raw.return_value = {"raw": b"\x0A"} # ACK + s.device.hf.iso14a.raw.reset_mock() + dispatch(s, "COMPAT_WRITE(4, 01020304)") + calls = s.device.hf.iso14a.raw.call_args_list + assert len(calls) == 2 # command frame + data frame + assert calls[0].args[0] == b"\xA0\x04" + assert calls[1].args[0] == b"\x01\x02\x03\x04" + b"\x00" * 12 def test_call_without_identify(self, capsys): dispatch(RawSession(), "READ(4)")