diff --git a/pm3py/cli/rawcli/catalog.py b/pm3py/cli/rawcli/catalog.py index 97878b9..0f287f0 100644 --- a/pm3py/cli/rawcli/catalog.py +++ b/pm3py/cli/rawcli/catalog.py @@ -134,15 +134,89 @@ def _mfc_chk(dev, block, keytype="A"): return f"CHK block {_int(block)} key {str(keytype).upper()}: no key from the default list works" +def _mfc_auth(dev, block, key, keytype): + # a successful authenticated read == the key authenticates that sector (standalone crypto1 auth + # isn't a firmware single-shot; auth is otherwise implicit in READ/WRITE) + r = dev.hf.mf.rdbl(_int(block), key=key, key_type=_kt(keytype)) + ok = isinstance(r, dict) and r.get("success") + return f"AUTH_{str(keytype).upper()} block {_int(block)} key {key}: {'ok' if ok else 'auth failed'}" + + +def _mfc_auth_a(dev, block, key="FFFFFFFFFFFF"): + return _mfc_auth(dev, block, key, "A") + + +def _mfc_auth_b(dev, block, key="FFFFFFFFFFFF"): + return _mfc_auth(dev, block, key, "B") + + +def _mfc_value(dev, block, action, value, verb, key, keytype): + r = dev.hf.mf.value(_int(block), action, value=value, key=key, key_type=_kt(keytype)) + ok = isinstance(r, dict) and r.get("success") + return f"{verb} block {_int(block)}: {'ok' if ok else 'failed'} (key {str(keytype).upper()})" + + +def _mfc_incr(dev, block, value="1", key="FFFFFFFFFFFF", keytype="A"): + return _mfc_value(dev, block, 0, _int(value), f"INCREMENT by {_int(value)}", key, keytype) + + +def _mfc_decr(dev, block, value="1", key="FFFFFFFFFFFF", keytype="A"): + return _mfc_value(dev, block, 1, _int(value), f"DECREMENT by {_int(value)}", key, keytype) + + +def _mfc_restore(dev, block, key="FFFFFFFFFFFF", keytype="A"): + return _mfc_value(dev, block, 2, 0, "RESTORE (in place)", key, keytype) + + +def _mfc_transfer(dev, block, dest, key="FFFFFFFFFFFF", keytype="A"): + # datasheet TRANSFER commits the transfer buffer; the firmware fuses it with the value op, so + # expose it as a value-block copy: restore then transfer to + r = dev.hf.mf.value(_int(block), 2, transfer_block=_int(dest), key=key, key_type=_kt(keytype)) + ok = isinstance(r, dict) and r.get("success") + return f"TRANSFER block {_int(block)} -> {_int(dest)}: {'ok' if ok else 'failed'} (key {str(keytype).upper()})" + + +def _mfc_personalize(dev, option="0"): + return "PERSONALIZE_UID (0x40) — MIFARE Classic EV1 command; opcode shown for completion, execution not yet wired" + + +def _mfc_setmod(dev, option="0"): + return "SET_MOD_TYPE (0x43) — MIFARE Classic EV1 command; opcode shown for completion, execution not yet wired" + + +# The MIFARE Classic datasheet wire command set (MF1S50/EV1). All ops after activation are crypto1- +# gated, so they run via hf.mf (auth + op in firmware); build() exposes the wire opcode for the +# hex/binary completion and the block map (params[0]=="block"). CHK is a pm3py key-finder (not +# datasheet) kept because you need a key before anything else works. MFC = _catalog( "MIFARE Classic", "hf14a", + _c("AUTH_A", ["block", "key"], build=lambda block, key="0": bytes([0x60, _int(block)]), + run=_mfc_auth_a, help="authenticate a sector with Key A — reports if the key works (0x60)"), + _c("AUTH_B", ["block", "key"], build=lambda block, key="0": bytes([0x61, _int(block)]), + run=_mfc_auth_b, help="authenticate a sector with Key B — reports if the key works (0x61)"), _c("READ", ["block", "key", "keytype"], build=lambda block, key="0", keytype="0": bytes([0x30, _int(block)]), run=_mfc_read, help="auth + read a 16-byte block (key def FFFFFFFFFFFF/A) (0x30)"), _c("WRITE", ["block", "data", "key", "keytype"], build=lambda block, data, key="0", keytype="0": [bytes([0xA0, _int(block)]), _hex(data).ljust(16, b"\x00")[:16]], run=_mfc_write, help="auth + write a 16-byte block (0xA0)"), + _c("INCREMENT", ["block", "value", "key", "keytype"], + build=lambda block, value="0", key="0", keytype="0": bytes([0xC1, _int(block)]), + run=_mfc_incr, help="increment a value block by and commit (0xC1 + transfer)"), + _c("DECREMENT", ["block", "value", "key", "keytype"], + build=lambda block, value="0", key="0", keytype="0": bytes([0xC0, _int(block)]), + run=_mfc_decr, help="decrement a value block by and commit (0xC0 + transfer)"), + _c("RESTORE", ["block", "key", "keytype"], + build=lambda block, key="0", keytype="0": bytes([0xC2, _int(block)]), + run=_mfc_restore, help="restore/refresh a value block in place (0xC2 + transfer)"), + _c("TRANSFER", ["block", "dest", "key", "keytype"], + build=lambda block, dest="0", key="0", keytype="0": bytes([0xB0, _int(block)]), + run=_mfc_transfer, help="copy a value block -> (0xC2 restore + 0xB0 transfer)"), _c("CHK", ["block", "keytype"], run=_mfc_chk, help="try the default key list against a block — reports the working key A/B"), + _c("PERSONALIZE_UID", ["option"], build=lambda option="0": bytes([0x40, _int(option)]), + run=_mfc_personalize, help="EV1: UID/anticollision personalization (0x40)"), + _c("SET_MOD_TYPE", ["option"], build=lambda option="0": bytes([0x43, _int(option)]), + run=_mfc_setmod, help="EV1: set modulation type (0x43)"), _c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the card (0x50 00)"), ) diff --git a/pm3py/core/hf_mfc.py b/pm3py/core/hf_mfc.py index d97ff7d..ffec77c 100644 --- a/pm3py/core/hf_mfc.py +++ b/pm3py/core/hf_mfc.py @@ -7,6 +7,16 @@ MF_KEY_A = 0 MF_KEY_B = 1 MFBLOCK_SIZE = 16 +# value-block actions (firmware MifareValue datain[9]) +MF_VALUE_INCREMENT = 0 +MF_VALUE_DECREMENT = 1 +MF_VALUE_RESTORE = 2 + + +def _sector_of(block: int) -> int: + """Sector number for a block: sectors 0-31 are 4 blocks, 32-39 are 16 blocks.""" + return block // 4 if block < 128 else 32 + (block - 128) // 16 + class HFMFCommands: """MIFARE Classic commands.""" @@ -60,6 +70,45 @@ class HFMFCommands: success = resp.oldarg[0] > 0 if resp.oldarg else resp.status == 0 return {"success": success, "block": block} + async def value(self, block: int, action: int, value: int = 0, + transfer_block: int | None = None, + key: str | bytes = "FFFFFFFFFFFF", key_type: int = MF_KEY_A, + transfer_key: str | bytes = "FFFFFFFFFFFF", + transfer_key_type: int | None = None) -> dict: + """MIFARE value-block operation (increment / decrement / restore + transfer/commit). + + ``action``: 0=increment, 1=decrement, 2=restore (see ``MF_VALUE_*``). ``value`` is the + signed increment/decrement operand. The op writes the internal transfer buffer and commits + it to ``transfer_block`` — or back to ``block`` in place when None. A transfer that crosses + into another sector nested-auths there with ``transfer_key`` / ``transfer_key_type``. + Wire opcodes 0xC1/0xC0/0xC2 (op) then 0xB0 (transfer) are issued by the firmware. + """ + key_bytes = self._parse_key(key) + transfer_key_bytes = self._parse_key(transfer_key) + if transfer_key_type is None: + transfer_key_type = key_type + # firmware: transferBlk==0 means "transfer in place" (commit to `block`) + tblk = 0 if transfer_block is None else transfer_block + commit_block = tblk if tblk != 0 else block + + # cmddata[34]: key[0:6], action[9], transferBlk[10], value-block[11:27] (operand at 11:15), + # transferKey[27:33], needNestedAuth[33] (mirrors client CmdHF14AMfValue) + cmddata = bytearray(34) + cmddata[0:6] = key_bytes + cmddata[9] = action & 0xFF + cmddata[10] = tblk & 0xFF + struct.pack_into(" 0 if resp.oldarg else resp.status == 0 + return {"success": success, "block": commit_block} + async def rdsc(self, sector: int, key: str | bytes = "FFFFFFFFFFFF", key_type: int = MF_KEY_A) -> dict: """Read an entire MIFARE Classic sector.""" diff --git a/tests/test_hf_mf.py b/tests/test_hf_mf.py index ba2a949..7fb818c 100644 --- a/tests/test_hf_mf.py +++ b/tests/test_hf_mf.py @@ -35,6 +35,40 @@ def test_mf_wrbl(): assert kwargs["arg1"] == 0 +def test_mf_value_increment_in_place(): + t = AsyncMock() + mf = HFMFCommands(t) + t.send_mix.return_value = PM3Response(cmd=Cmd.ACK, status=0, reason=0, + ng=False, data=b"", oldarg=[1, 0, 0]) + result = asyncio.get_event_loop().run_until_complete( + mf.value(block=5, action=0, value=10, key="FFFFFFFFFFFF")) + assert result == {"success": True, "block": 5} # in-place commit + args, kwargs = t.send_mix.call_args.args, t.send_mix.call_args.kwargs + assert args[0] == Cmd.HF_MIFARE_VALUE + assert kwargs["arg0"] == 5 and kwargs["arg1"] == 0 and kwargs["arg2"] == 0 + p = kwargs["payload"] + assert len(p) == 34 + assert p[:6] == bytes.fromhex("FFFFFFFFFFFF") # key + assert p[9] == 0 # action = increment + assert p[10] == 0 # transfer in place (commit to block) + assert struct.unpack_from(" no nested auth + + +def test_mf_value_transfer_cross_sector(): + t = AsyncMock() + mf = HFMFCommands(t) + t.send_mix.return_value = PM3Response(cmd=Cmd.ACK, status=0, reason=0, + ng=False, data=b"", oldarg=[1, 0, 0]) + # block 4 (sector 1) restore -> block 8 (sector 2): cross-sector => nested-auth flag set + result = asyncio.get_event_loop().run_until_complete( + mf.value(block=4, action=2, transfer_block=8, key="A0A1A2A3A4A5")) + assert result == {"success": True, "block": 8} + p = t.send_mix.call_args.kwargs["payload"] + assert p[9] == 2 and p[10] == 8 # restore, transfer to block 8 + assert p[33] == 1 # crosses sector -> nested auth + + def test_mf_nested(): t = AsyncMock() mf = HFMFCommands(t) diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index 1526fba..f4d805c 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -388,6 +388,35 @@ class TestCatalog: dev.hf.mf.rdbl.return_value = {"success": False, "error": 1} assert "failed" in MFC.get("READ").run(dev, "4") # clear failure line + def test_mfc_datasheet_command_set(self): + # the full MIFARE Classic wire command set is present with datasheet opcodes + opcodes = {n: MFC.get(n).opcode() for n in MFC.names()} + assert opcodes["AUTH_A"] == 0x60 and opcodes["AUTH_B"] == 0x61 + assert opcodes["INCREMENT"] == 0xC1 and opcodes["DECREMENT"] == 0xC0 + assert opcodes["RESTORE"] == 0xC2 and opcodes["TRANSFER"] == 0xB0 + assert opcodes["PERSONALIZE_UID"] == 0x40 and opcodes["SET_MOD_TYPE"] == 0x43 + for op, name in [(0x60, "AUTH_A"), (0xC1, "INCREMENT"), (0xB0, "TRANSFER"), (0x40, "PERSONALIZE_UID")]: + assert MFC.by_opcode(op).name == name # raw byte maps back to the command + # every page/block command's first param is a block -> gets the memory map + for n in ("AUTH_A", "AUTH_B", "READ", "WRITE", "INCREMENT", "DECREMENT", "RESTORE", "TRANSFER"): + assert MFC.get(n).params[0] == "block" + + def test_mfc_value_ops_run(self): + dev = MagicMock() + dev.hf.mf.rdbl.return_value = {"success": True, "block": 4, "data": "aa" * 16} + dev.hf.mf.value.return_value = {"success": True, "block": 4} + MFC.get("AUTH_A").run(dev, "3") + dev.hf.mf.rdbl.assert_called_with(3, key="FFFFFFFFFFFF", key_type=0) # AUTH_A -> key A + MFC.get("AUTH_B").run(dev, "3", "A0A1A2A3A4A5") + dev.hf.mf.rdbl.assert_called_with(3, key="A0A1A2A3A4A5", key_type=1) # AUTH_B -> key B + MFC.get("INCREMENT").run(dev, "4", "10") + dev.hf.mf.value.assert_called_with(4, 0, value=10, key="FFFFFFFFFFFF", key_type=0) + MFC.get("DECREMENT").run(dev, "4", "3", "A0A1A2A3A4A5", "B") + dev.hf.mf.value.assert_called_with(4, 1, value=3, key="A0A1A2A3A4A5", key_type=1) + MFC.get("TRANSFER").run(dev, "4", "8") # copy block 4 -> 8 (restore+transfer) + dev.hf.mf.value.assert_called_with(4, 2, transfer_block=8, key="FFFFFFFFFFFF", key_type=0) + assert "not yet wired" in MFC.get("PERSONALIZE_UID").run(dev, "2") # EV1 opcode-only + def test_iso15_builds(self): assert ISO15.get("READ_BLOCK").build("4") == b"\x02\x20\x04" assert ISO15.get("INVENTORY").build() == b"\x26\x01\x00" @@ -586,6 +615,22 @@ class TestCompleter: gv = next(c for c in cs if "GET_VERSION" in self._disp(c)) assert gv.text == "01100000" # raw binary byte, not the command name + def test_mfc_opcode_completion(self): + # the new MIFARE Classic datasheet opcodes surface by hex AND binary value, inserting the byte + s = RawSession() + s.protocol, s.transponder = "hf14a", "MIFARE Classic 1K" + for word, name, byte in [("c1", "INCREMENT", "C1"), ("60", "AUTH_A", "60"), ("b0", "TRANSFER", "B0")]: + cs = self._complete(s, word) + hit = next(c for c in cs if name in self._disp(c)) + assert hit.text == byte # inserts the raw opcode byte + s.entry_mode = "bin" + cs = self._complete(s, "11000001") # 0xC1 in binary + assert any("INCREMENT" in self._disp(c) for c in cs) + s.entry_mode = "hex" + # the block argument gets the memory map for these commands too + assert any("manufacturer" in (c.display_meta_text or "") + for c in self._complete(s, "INCREMENT(")) + def test_raw_page_byte_completion(self): # after a page-command opcode in raw entry, the page byte gets the memory map (raw bytes) s = RawSession()