From 8f136e7dc421313898d14e5d13a962283d3e3db3 Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 16 Jul 2026 17:20:11 -0700 Subject: [PATCH] =?UTF-8?q?feat(rawcli):=20finish=20T5577=20catalog=20?= =?UTF-8?q?=E2=80=94=20page/password,=20RESET,=20hex=20data=20words?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round out the ATA5577C command set to the datasheet-catalog standard: - RESET command (downlink opcode 00), backed by a new lf.t55.reset() that drives CMD_LF_T55XX_RESET_READ. Hardware-validated (status 0). - READ/WRITE gain optional (1 = traceability) and for password-mode access, threaded through read_config / read_t55xx_block. - WRITE data and all passwords now parse as hex words (like the pm3 client's -d/-p and every other rawcli WRITE); blocks/pages stay decimal. Previously T5577 WRITE data alone was base-10, so WRITE(0, 00088040) wrote decimal. Live-validated on a T5577 emulating EM4100 00FFFFFFFF: DETECT/READ(0) decode the config 00148040 (ASK/RF64/EM4100), RESET/WAKE return ok. Co-Authored-By: Claude Opus 4.8 --- pm3py/cli/rawcli/catalog.py | 82 ++++++++++++++++++++++++++----------- pm3py/core/lf.py | 12 ++++++ pm3py/lf/capture.py | 15 ++++--- tests/test_lf.py | 10 +++++ tests/test_rawcli.py | 52 ++++++++++++++++++++++- 5 files changed, 140 insertions(+), 31 deletions(-) diff --git a/pm3py/cli/rawcli/catalog.py b/pm3py/cli/rawcli/catalog.py index 7ac6f54..2fc3bce 100644 --- a/pm3py/cli/rawcli/catalog.py +++ b/pm3py/cli/rawcli/catalog.py @@ -460,40 +460,64 @@ _NTAG5 = _catalog( # ---- 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 +def _word(s) -> int: + """A 32-bit T55xx data/password word — parsed as hex (0x/spaces tolerated), matching the pm3 + client's ``-d``/``-p`` and every other rawcli WRITE. Blocks/pages stay decimal (``_int``).""" + h = str(s).lower().replace("0x", "").replace(" ", "") or "0" + return int(h, 16) & 0xFFFFFFFF + + +def _optpwd(password) -> int | None: + """A T55xx password argument (hex word), or None when omitted (the empty-string default).""" + return _word(password) if str(password) != "" else None + + +def _pwd_note(password) -> str: + return f" (pwd {_word(password):08X})" if str(password) != "" else "" + + +def _t55_read(dev, block, page="0", password=""): + b, pg, pwd = _int(block), _int(page), _optpwd(password) + if b == 0 and pg == 0: # config block — the reliable one from pm3py.lf.capture import read_config - r = read_config(dev) + r = read_config(dev, password=pwd) 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" + f"{r['max_block']} blocks) -> {r['emulating']}{_pwd_note(password)}") + return f"READ block 0: no clean config recovered{_pwd_note(password)}" from pm3py.lf.capture import read_t55xx_block - word = read_t55xx_block(dev, b) + word = read_t55xx_block(dev, b, page=pg, password=pwd) + where = f"block {b}" + (" (page 1)" if pg else "") 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)" + return f"READ {where}: no clean response (LF block reads are demod-dependent){_pwd_note(password)}" + return f"{where} = {word:08X} (best-effort — bit alignment not verified){_pwd_note(password)}" -def _t55_write(dev, block, data): - r = dev.lf.t55.writebl(_int(block), _int(data)) +def _t55_write(dev, block, data, page="0", password=""): + w = _word(data) + r = dev.lf.t55.writebl(_int(block), w, page=_int(page), password=_optpwd(password)) ok = isinstance(r, dict) and r.get("status") == 0 - return f"WRITE block {_int(block)} <- {_int(data):08X}: {'ok' if ok else 'failed'}" + where = f"block {_int(block)}" + (" (page 1)" if _int(page) else "") + return f"WRITE {where} <- {w:08X}: {'ok' if ok else 'failed'}{_pwd_note(password)}" def _t55_wake(dev, password): - r = dev.lf.t55.wakeup(_int(password)) + r = dev.lf.t55.wakeup(_word(password)) return f"WAKE: {'ok' if isinstance(r, dict) and r.get('status') == 0 else 'failed'}" -def _t55_detect(dev): +def _t55_reset(dev): + r = dev.lf.t55.reset() + return f"RESET: {'ok' if isinstance(r, dict) and r.get('status') == 0 else 'failed'}" + + +def _t55_detect(dev, password=""): from pm3py.lf.capture import read_config - r = read_config(dev) + r = read_config(dev, password=_optpwd(password)) if not r: - return "DETECT: no T55xx config recovered" + return f"DETECT: no T55xx config recovered{_pwd_note(password)}" return (f"config {r['config_hex']} ({r['modulation']}, RF/{r['data_bit_rate']}, " - f"{r['max_block']} blocks) -> {r['emulating']}") + f"{r['max_block']} blocks) -> {r['emulating']}{_pwd_note(password)}") def _lf_reread(dev): @@ -502,16 +526,26 @@ def _lf_reread(dev): return r["label"] if r else "no LF credential decoded" +# The ATA5577C downlink command set. The wire opcode is a 2-bit field (10/11 = read/write page +# 0/1, 00 = reset) followed by optional 32-bit password, lock bit, data and 3-bit address — a +# bit-level frame, not a byte exchange, so build() returns a synthetic opcode byte only for the +# hex/binary completion; execution goes through run() (lf.t55). READ/WRITE take an optional +# (1 = traceability data) and for password-mode access. 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 to (0x02)"), + _c("READ", ["block", "page", "password"], + build=lambda block, page="0", password="": bytes([0x01, _int(block)]), run=_t55_read, + help="read a 32-bit block (0=config, 7=password, 1-6=data); 1 = traceability, " + "optional — best-effort demod"), + _c("WRITE", ["block", "data", "page", "password"], + build=lambda block, data, page="0", password="": bytes([0x02, _int(block)]), + run=_t55_write, help="write 32-bit to ; optional / (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)"), + help="wake a password-protected tag with the block-7 password — AOR (0x03)"), + _c("RESET", [], build=lambda: bytes([0x00]), run=_t55_reset, + help="send the reset command (opcode 00) — realign the tag's bitstream"), + _c("DETECT", ["password"], build=lambda password="": bytes([0x01, 0x00]), run=_t55_detect, + help="read + decode the config block (block 0); optional "), ) # ---- LF read-only credentials (EM4100/HID/AWID/FDX-B): broadcast tags with no command set ---- diff --git a/pm3py/core/lf.py b/pm3py/core/lf.py index 2ba289f..2baacb5 100644 --- a/pm3py/core/lf.py +++ b/pm3py/core/lf.py @@ -48,6 +48,18 @@ class T55xxCommands: resp = await self._t.send_ng(Cmd.LF_T55XX_WAKEUP, payload) return {"status": resp.status} + async def reset(self, downlink_mode: int = 0) -> dict: + """Send the T55xx reset command (downlink opcode 00) and read the stream. + + Resets the tag's modem to the start of its bitstream so a subsequent read starts + aligned. The firmware (T55xxResetRead) takes a single flags byte carrying the + downlink mode; the captured stream lands in BigBuf. + """ + flags = (downlink_mode & 0x03) << 3 + payload = struct.pack(" dict | None: return demod_samples(data) if data else None -def read_config(device) -> dict | None: +def read_config(device, password: int | None = None) -> dict | None: """Probe for a T55xx by reading config block 0 and demodulating the response. A clean, - sane config both proves the chip is a T5577 and says what it's programmed to emulate.""" - data = _capture(device, lambda: _try(lambda: device.lf.t55.readbl(0), None)) + sane config both proves the chip is a T5577 and says what it's programmed to emulate. + ``password`` (when set) reads a password-protected tag in password mode.""" + data = _capture(device, lambda: _try(lambda: device.lf.t55.readbl(0, password=password), None)) return protocols.decode_t55xx_config(data) if data else None -def read_t55xx_block(device, block: int) -> int | None: +def read_t55xx_block(device, block: int, page: int = 0, password: int | None = None) -> int | None: """Best-effort read of a T55xx 32-bit data block: send the block read, demod the first repeating 32-bit word. A bare block has no header/CRC, so the bit alignment (rotation) can't be verified — this is inherently best-effort. Block 0 (config) is the reliable one; read it - via read_config, which resolves the rotation against known presets.""" - data = _capture(device, lambda: _try(lambda: device.lf.t55.readbl(int(block)), None)) + via read_config, which resolves the rotation against known presets. ``page`` 1 reaches the + traceability data; ``password`` reads a password-protected tag in password mode.""" + data = _capture(device, lambda: _try( + lambda: device.lf.t55.readbl(int(block), page=int(page), password=password), None)) if not data: return None for bits in ask.manchester_bits(data): diff --git a/tests/test_lf.py b/tests/test_lf.py index 1687f01..d541ef3 100644 --- a/tests/test_lf.py +++ b/tests/test_lf.py @@ -69,6 +69,16 @@ def test_lf_t55_wakeup(): assert pwd == 0x11223344 assert flags == (3 << 3) +def test_lf_t55_reset(): + t = AsyncMock() + lf = LFCommands(t) + t.send_ng.return_value = make_response(Cmd.LF_T55XX_RESET_READ, 0, b"") + asyncio.get_event_loop().run_until_complete(lf.t55.reset(downlink_mode=2)) + cmd, payload = t.send_ng.call_args.args[0], t.send_ng.call_args.args[1] + assert cmd == Cmd.LF_T55XX_RESET_READ + # single flags byte carrying downlink_mode<<3 + assert payload == struct.pack(" read_config; a password reads in password mode (readbl(0, password=...)) + dev = MagicMock() + dev.lf.t55.readbl.return_value = None # no envelope -> "no clean config" + dev.lf.download_samples.return_value = b"" + out = T5577.get("READ").run(dev, "0", "0", "0x11223344") + dev.lf.t55.readbl.assert_called_with(0, password=0x11223344) + assert "11223344" in out + + def test_read_block_forwards_page_and_password(self): + dev = MagicMock() + dev.lf.t55.readbl.return_value = None + dev.lf.download_samples.return_value = b"" + T5577.get("READ").run(dev, "2", "1", "0x11223344") + dev.lf.t55.readbl.assert_called_with(2, page=1, password=0x11223344) + + class TestFunctionCalls: def _id(self, sak=0x00): return RawSession(device=_mock_device(