Entry now tracks each raw byte's base, so hex and binary can share one line with a clean display: `30` (hex) then Ctrl-/ then `00000100` (binary) shows `30 00000100` and sends 0x30 0x04. The toggle only changes the base of the NEXT byte — it never rewrites what you've typed. Each byte auto-seals at its base width (2 hex / 8 binary); the next digit starts a fresh byte in the current mode; a digit invalid for its byte's base is dropped. Applies to raw byte entry only (function args stay base-10). - entry.type_digit / is_byte_line / reconcile_bases carry the logic (pure, unit-tested); the digit and backspace key bindings maintain session.byte_bases; the toggle just flips the mode. - parser.parse_bytes/parse_line take per-byte bases (explicit 0x/0b still wins). A line mixing a command with raw bytes (`READ 30`) raises instead of transmitting. - completer parses the opcode token in its own base, so `30`+binary still autocompletes the page. - help documents all of it (per-byte base, toggle, auto-seal, base-10 args, no command/byte mix). Also fixes a real bug found on hardware: the MFC catalog called hf.mfc (nonexistent) — it's hf.mf. Hardware-verified on a MIFARE Classic 1K: identify, CHK(0)=FFFFFFFFFFFF, READ(0) returns the manufacturer block, READ(4)/READ(1) the data blocks. Intermixed entry verified end-to-end in the live prompt (30 00000100 -> 0x30 0x04). 1351 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
240 lines
10 KiB
Python
240 lines
10 KiB
Python
"""Per-transponder command catalogs.
|
|
|
|
A declarative, enumerable command set for each tag family: each entry knows its parameters, how
|
|
to **build** the raw bytes to send, and its help text. rawcli surfaces these for ``help`` and for
|
|
function-style calls (``READ(4)`` → build → raw exchange → annotated trace). Homed here for now;
|
|
the richer per-vendor catalogs can migrate into the ``pm3py/reader/`` scaffold as they grow.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Callable
|
|
|
|
from .parser import parse_arg_int
|
|
|
|
|
|
@dataclass
|
|
class TagCommand:
|
|
name: str
|
|
params: tuple[str, ...]
|
|
build: Callable[..., bytes] | None = None # HF: string args -> raw payload bytes to exchange
|
|
help: str = ""
|
|
run: Callable[..., object] | None = None # LF: (device, *args) -> a human result line
|
|
|
|
def usage(self) -> str:
|
|
return f"{self.name}({', '.join(self.params)})"
|
|
|
|
def opcode(self) -> int | None:
|
|
"""The command's first sent byte, by building it with placeholder args — used to match raw
|
|
input back to a command. None when there's no build (LF run-only commands)."""
|
|
if self.build is None:
|
|
return None
|
|
try:
|
|
built = self.build(*(["00"] * len(self.params))) # "00" is valid for both _int and _hex
|
|
except Exception:
|
|
return None
|
|
if isinstance(built, list): # two-phase write: the first frame's opcode
|
|
built = built[0] if built else b""
|
|
return built[0] if built else None
|
|
|
|
|
|
@dataclass
|
|
class Catalog:
|
|
name: str
|
|
protocol: str
|
|
commands: dict[str, TagCommand]
|
|
|
|
def get(self, name: str) -> TagCommand | None:
|
|
return self.commands.get(name.upper())
|
|
|
|
def by_opcode(self, op: int) -> TagCommand | None:
|
|
"""The command whose first sent byte is ``op`` — for hinting a raw payload (``30 …`` →
|
|
READ). None if no command matches."""
|
|
for tc in self.commands.values():
|
|
if tc.opcode() == op:
|
|
return tc
|
|
return None
|
|
|
|
def names(self) -> list[str]:
|
|
return list(self.commands)
|
|
|
|
|
|
def _int(s: str) -> int:
|
|
return parse_arg_int(s) # base 10 by default; 0x/0b/0o to override
|
|
|
|
|
|
def _hex(s: str) -> bytes:
|
|
return bytes.fromhex(s.lower().replace("0x", "").replace(" ", ""))
|
|
|
|
|
|
def _catalog(name: str, protocol: str, *cmds: TagCommand) -> Catalog:
|
|
return Catalog(name, protocol, {c.name.upper(): c for c in cmds})
|
|
|
|
|
|
def _c(name, params, build=None, help="", run=None) -> TagCommand:
|
|
return TagCommand(name, tuple(params), build, help, run)
|
|
|
|
|
|
# ---- ISO 14443-A Type 2 (NTAG / Ultralight) ----
|
|
TYPE2 = _catalog(
|
|
"NTAG / Ultralight (Type 2)", "hf14a",
|
|
_c("READ", ["page"], lambda page: bytes([0x30, _int(page)]),
|
|
"read 4 pages starting at <page> (0x30)"),
|
|
_c("FAST_READ", ["start", "end"], lambda start, end: bytes([0x3A, _int(start), _int(end)]),
|
|
"read pages <start>..<end> in one frame (0x3A)"),
|
|
_c("GET_VERSION", [], lambda: bytes([0x60]), "product/version info (0x60)"),
|
|
_c("READ_SIG", [], lambda: bytes([0x3C, 0x00]), "read the ECC originality signature (0x3C)"),
|
|
_c("READ_CNT", ["counter"], lambda counter="0": bytes([0x39, _int(counter)]),
|
|
"read the 24-bit NFC counter (0x39)"),
|
|
_c("PWD_AUTH", ["password"],
|
|
lambda password: bytes([0x1B]) + _hex(password).ljust(4, b"\x00")[:4],
|
|
"authenticate with the 4-byte password → PACK (0x1B)"),
|
|
_c("WRITE", ["page", "data"],
|
|
lambda page, data: bytes([0xA2, _int(page)]) + _hex(data).ljust(4, b"\x00")[:4],
|
|
"write 4 bytes <data> to <page> (0xA2)"),
|
|
_c("COMPAT_WRITE", ["page", "data"],
|
|
lambda page, data: [bytes([0xA0, _int(page)]), _hex(data).ljust(16, b"\x00")[:16]],
|
|
"compatibility write: 16-byte <data> to <page>, two-phase (0xA0)"),
|
|
_c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the tag (0x50 00)"),
|
|
)
|
|
|
|
# ---- MIFARE Classic ---------------------------------------------------------------------------
|
|
# Classic reads/writes are gated by a crypto1 auth per sector, so — unlike NTAG — a bare 0x30 does
|
|
# nothing. These run via hf.mf, which does the auth + block op in firmware. build() still exposes
|
|
# the wire opcode (0x30/0xA0) for hex/binary completion. Key defaults to the transport key
|
|
# FFFFFFFFFFFF / key A; override as READ(<block>, <12-hex key>, A|B).
|
|
_MFC_KEYTYPE = {"A": 0, "B": 1, "0": 0, "1": 1}
|
|
# common transport / default keys tried by CHK when you don't know the sector key
|
|
_MFC_KEYS = ["FFFFFFFFFFFF", "A0A1A2A3A4A5", "D3F7D3F7D3F7", "000000000000",
|
|
"B0B1B2B3B4B5", "4D3A99C351DD", "1A982C7E459A", "AABBCCDDEEFF"]
|
|
|
|
|
|
def _kt(keytype) -> int:
|
|
return _MFC_KEYTYPE.get(str(keytype).upper(), 0)
|
|
|
|
|
|
def _mfc_read(dev, block, key="FFFFFFFFFFFF", keytype="A"):
|
|
r = dev.hf.mf.rdbl(_int(block), key=key, key_type=_kt(keytype))
|
|
if isinstance(r, dict) and r.get("success"):
|
|
return f"block {_int(block)} = {r['data']} (key {str(keytype).upper()})"
|
|
err = r.get("error") if isinstance(r, dict) else "?"
|
|
return f"READ block {_int(block)}: auth/read failed with key {str(keytype).upper()} {key} (status {err})"
|
|
|
|
|
|
def _mfc_write(dev, block, data, key="FFFFFFFFFFFF", keytype="A"):
|
|
r = dev.hf.mf.wrbl(_int(block), _hex(data).ljust(16, b"\x00")[:16], key=key, key_type=_kt(keytype))
|
|
ok = isinstance(r, dict) and r.get("success")
|
|
return f"WRITE block {_int(block)} <- {_hex(data).hex()}: {'ok' if ok else 'failed'} (key {str(keytype).upper()})"
|
|
|
|
|
|
def _mfc_chk(dev, block, keytype="A"):
|
|
r = dev.hf.mf.chk(_int(block), _MFC_KEYS, key_type=_kt(keytype))
|
|
if isinstance(r, dict) and r.get("found"):
|
|
return f"block {_int(block)} key {str(keytype).upper()} = {r['key']}"
|
|
return f"CHK block {_int(block)} key {str(keytype).upper()}: no key from the default list works"
|
|
|
|
|
|
MFC = _catalog(
|
|
"MIFARE Classic", "hf14a",
|
|
_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("CHK", ["block", "keytype"], run=_mfc_chk,
|
|
help="try the default key list against a block — reports the working key A/B"),
|
|
_c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the card (0x50 00)"),
|
|
)
|
|
|
|
# ---- ISO 15693 (flags byte 0x02 = high data rate, single tag) ----
|
|
ISO15 = _catalog(
|
|
"ISO 15693", "hf15",
|
|
_c("INVENTORY", [], lambda: bytes([0x26, 0x01, 0x00]), "anticollision inventory (0x01)"),
|
|
_c("READ_BLOCK", ["block"], lambda block: bytes([0x02, 0x20, _int(block)]),
|
|
"read a single block (0x20)"),
|
|
_c("WRITE_BLOCK", ["block", "data"],
|
|
lambda block, data: bytes([0x02, 0x21, _int(block)]) + _hex(data),
|
|
"write a single block (0x21)"),
|
|
_c("GET_SYSTEM_INFO", [], lambda: bytes([0x02, 0x2B]), "get system information (0x2B)"),
|
|
)
|
|
|
|
|
|
# ---- LF T5577 / ATA5577 (block read/write over the T55xx downlink; executes via lf.t55) ----
|
|
# build() exists only to expose the downlink opcode for hex/binary completion; execution goes
|
|
# through run() (the device method), since LF isn't a raw-byte exchange like 14a.
|
|
def _t55_read(dev, block):
|
|
b = _int(block)
|
|
if b == 0: # config block — the reliable one
|
|
from pm3py.lf.capture import read_config
|
|
r = read_config(dev)
|
|
if r:
|
|
return (f"block 0 = {r['config_hex']} ({r['modulation']}, RF/{r['data_bit_rate']}, "
|
|
f"{r['max_block']} blocks) -> {r['emulating']}")
|
|
return "READ block 0: no clean config recovered"
|
|
from pm3py.lf.capture import read_t55xx_block
|
|
word = read_t55xx_block(dev, b)
|
|
if word is None:
|
|
return f"READ block {b}: no clean response (LF block reads are demod-dependent)"
|
|
return f"block {b} = {word:08X} (best-effort — bit alignment not verified)"
|
|
|
|
|
|
def _t55_write(dev, block, data):
|
|
r = dev.lf.t55.writebl(_int(block), _int(data))
|
|
ok = isinstance(r, dict) and r.get("status") == 0
|
|
return f"WRITE block {_int(block)} <- {_int(data):08X}: {'ok' if ok else 'failed'}"
|
|
|
|
|
|
def _t55_wake(dev, password):
|
|
r = dev.lf.t55.wakeup(_int(password))
|
|
return f"WAKE: {'ok' if isinstance(r, dict) and r.get('status') == 0 else 'failed'}"
|
|
|
|
|
|
def _t55_detect(dev):
|
|
from pm3py.lf.capture import read_config
|
|
r = read_config(dev)
|
|
if not r:
|
|
return "DETECT: no T55xx config recovered"
|
|
return (f"config {r['config_hex']} ({r['modulation']}, RF/{r['data_bit_rate']}, "
|
|
f"{r['max_block']} blocks) -> {r['emulating']}")
|
|
|
|
|
|
def _lf_reread(dev):
|
|
from pm3py.lf import identify_lf
|
|
r = identify_lf(dev)
|
|
return r["label"] if r else "no LF credential decoded"
|
|
|
|
|
|
T5577 = _catalog(
|
|
"T5577 / ATA5577", "lf",
|
|
_c("READ", ["block"], build=lambda block: bytes([0x01, _int(block)]), run=_t55_read,
|
|
help="read a 32-bit block (0=config, 7=password, 1-6=data) — best-effort demod"),
|
|
_c("WRITE", ["block", "data"], build=lambda block, data: bytes([0x02, _int(block)]),
|
|
run=_t55_write, help="write 32-bit <data> to <block> (0x02)"),
|
|
_c("WAKE", ["password"], build=lambda password: bytes([0x03]), run=_t55_wake,
|
|
help="wake a password-protected tag with the block-7 password (0x03)"),
|
|
_c("DETECT", [], build=lambda: bytes([0x01, 0x00]), run=_t55_detect,
|
|
help="read + decode the config block (block 0)"),
|
|
)
|
|
|
|
# ---- LF read-only credentials (EM4100/HID/AWID/FDX-B): broadcast tags with no command set ----
|
|
LF_READONLY = _catalog(
|
|
"LF credential (read-only)", "lf",
|
|
_c("INFO", [], run=_lf_reread, help="re-read and decode the broadcast credential"),
|
|
)
|
|
|
|
|
|
def catalog_for(session) -> Catalog | None:
|
|
"""Resolve the command catalog for the session's identified transponder, or None."""
|
|
name = (session.transponder or "").upper()
|
|
if session.protocol == "hf15":
|
|
return ISO15
|
|
if session.protocol == "hf14a":
|
|
if not session.transponder:
|
|
return None
|
|
return MFC if ("CLASSIC" in name or "MINI" in name) else TYPE2
|
|
if session.protocol == "lf":
|
|
if not session.transponder:
|
|
return None
|
|
return T5577 if "T5577" in name else LF_READONLY
|
|
return None
|