Compare commits
122 Commits
4be8100cb6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
907522f0ae | ||
|
|
a990d725ef | ||
|
|
e3a394859c | ||
|
|
4226c9a11c | ||
|
|
8833753c45 | ||
|
|
8f136e7dc4 | ||
|
|
218e6dfc2a | ||
|
|
4684d0cc83 | ||
|
|
9e18a10274 | ||
|
|
d0339bceb5 | ||
|
|
72815e34eb | ||
|
|
251e276fbb | ||
|
|
7b028f4ddc | ||
|
|
a61e9724a6 | ||
|
|
2ba570a7ef | ||
|
|
4b5df12a76 | ||
|
|
6e46916dd9 | ||
|
|
c675f6b278 | ||
|
|
6ab8582308 | ||
|
|
9c54015735 | ||
|
|
3b4ec4541b | ||
|
|
fd16fa8f90 | ||
|
|
0586e2a41a | ||
|
|
d59a3c017b | ||
|
|
0f77eb9b83 | ||
|
|
4f66a93de9 | ||
|
|
925d2c92f0 | ||
|
|
2b087a302a | ||
|
|
f04703c309 | ||
|
|
260c1adad8 | ||
|
|
56fcfec648 | ||
|
|
a5c7702398 | ||
|
|
65be53da8c | ||
|
|
a3cef180d2 | ||
|
|
899142f23a | ||
|
|
33e713549d | ||
|
|
c53c087e84 | ||
|
|
b6fdb7bca4 | ||
|
|
260cb8eb9e | ||
|
|
a501759c43 | ||
|
|
a0b70a1984 | ||
|
|
6c5409c111 | ||
|
|
e82b94a490 | ||
|
|
e57e6ed268 | ||
|
|
26da98ede6 | ||
|
|
a06f4d12b8 | ||
|
|
938a571155 | ||
|
|
f09946c4a3 | ||
|
|
81625a0e93 | ||
|
|
b536294a7a | ||
|
|
2f221644f9 | ||
|
|
5dd8e086cc | ||
|
|
42d5f800cb | ||
|
|
7e8d924045 | ||
|
|
e4a3aec46f | ||
|
|
3c56d40667 | ||
|
|
8726681e06 | ||
|
|
0775df4d54 | ||
|
|
c04e50efd0 | ||
|
|
7b5af3f9d2 | ||
|
|
2f9e825bd4 | ||
|
|
92b4e8771d | ||
|
|
cecfc03ba7 | ||
|
|
1370526883 | ||
|
|
7703b638f3 | ||
|
|
41db552e05 | ||
|
|
6b4a4551d4 | ||
|
|
5f57cc4202 | ||
|
|
3e7f47a740 | ||
|
|
ae3fd3d4ea | ||
|
|
f76663f798 | ||
|
|
4e7181d289 | ||
|
|
9c84f9e7b0 | ||
|
|
e024294ec6 | ||
|
|
54e52f029f | ||
|
|
028ce9c21e | ||
|
|
5d4af2c8e4 | ||
|
|
130bcd1a90 | ||
|
|
f9e56e32ef | ||
|
|
e795b5bb1d | ||
|
|
f8d01a9120 | ||
|
|
0f597bc933 | ||
|
|
2063a33a2b | ||
|
|
3aabb64402 | ||
|
|
042f49a9ec | ||
|
|
829f254194 | ||
|
|
0ba5256d02 | ||
|
|
c0ae7b90c8 | ||
|
|
9f926a176c | ||
|
|
b0b46b3919 | ||
|
|
762e305dd9 | ||
|
|
42a9146e6c | ||
|
|
6e77beea1e | ||
|
|
14844bc700 | ||
|
|
fb3dc5cd68 | ||
|
|
3123d4bc6a | ||
|
|
615d4a6115 | ||
|
|
a24f0f803f | ||
|
|
77ff1bb076 | ||
|
|
9843ecdc30 | ||
|
|
dc626faa60 | ||
|
|
950628ef4e | ||
|
|
64cc6794d9 | ||
|
|
d2ac64f100 | ||
|
|
b87f12253d | ||
|
|
21107e8de7 | ||
|
|
fa31c3ad1a | ||
|
|
382732b881 | ||
|
|
3aab155004 | ||
|
|
3f42ca7b70 | ||
|
|
75bf0e19cb | ||
|
|
137ec1e721 | ||
|
|
739e28e113 | ||
|
|
a90a01f2aa | ||
|
|
820701511c | ||
|
|
3d4634a406 | ||
|
|
625104ea03 | ||
|
|
01357e4e78 | ||
|
|
f2b23f768b | ||
|
|
35f16b7664 | ||
|
|
687c92a7f7 | ||
|
|
8f0da9640c |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -2,9 +2,17 @@ __pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
build/
|
||||
dist/
|
||||
venv/
|
||||
.venv/
|
||||
.worktrees/
|
||||
*.pdf
|
||||
|
||||
# Local tooling / editor / agent state (not shared)
|
||||
.claude/
|
||||
.vscode/
|
||||
.mcp.json
|
||||
|
||||
# Capture data (sniffs, sims, taginfo scans)
|
||||
data/
|
||||
|
||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,4 +1,4 @@
|
||||
[submodule "firmware"]
|
||||
path = firmware
|
||||
url = https://github.com/dangerous-tac0s/proxmark3-pm3py.git
|
||||
url = ssh://git@git.dngr.us:20022/MikeFaith/proxmark3-pm3py.git
|
||||
branch = main
|
||||
|
||||
73
CLAUDE.md
73
CLAUDE.md
@@ -30,6 +30,9 @@ pm3py/
|
||||
hf_iso15.py # ISO 15693 — scan, rdbl, wrbl, thin sniff cmd
|
||||
hf_mfc.py # MIFARE Classic — rdbl, wrbl, rdsc, chk, nested, cident
|
||||
lf.py # LF commands + T55xxCommands + LFSearchResult
|
||||
flash.py # pure-Python firmware flasher (OLD-frame bootloader protocol)
|
||||
_firmware.py # firmware pin (matches_pin) — the fork build pm3py corresponds to
|
||||
flash_cli.py # `pm3flash` console-script (detect → build/resolve → flash → verify)
|
||||
trace/ # firmware trace infrastructure (shared by sniff, sim, reader)
|
||||
trace.py # parse_tracelog, TRACELOG_HDR_SIZE
|
||||
decode_iso15.py # ISO 15693 command/response decoders
|
||||
@@ -54,8 +57,24 @@ pm3py/
|
||||
replay.py # Trace replay
|
||||
access_control/ # Wiegand, OSDP
|
||||
transponders/ # tag/transponder models (extracted from sim/)
|
||||
bitfield.py # BitField descriptor + Register base — self-describing config/
|
||||
# frame registers (T5577, EM4100, NTAG21x, Ultralight EV1/C, MFC
|
||||
# access bits, NTAG I2C NC_REG/NS_REG/REG_LOCK/PT_I2C, NTAG5
|
||||
# STATUS_REG/CONFIG_REG, NXP AES key privileges). Documented attrs -> shell
|
||||
# completion + repr decode table. Layouts cross-checked vs the
|
||||
# firmware submodule + NXP datasheets.
|
||||
hf/iso14443a/ # Tag14443A_3/4, MifareClassic, DESFire, NfcType2/4
|
||||
# nxp/type2,ntag21x,ultralight,ntag_i2c
|
||||
# (NTAG210-216, Ultralight/C/EV1, NTAG I2C plus)
|
||||
# st/st25tn (Type 2), st/st25ta (Type 4)
|
||||
# infineon/optiga_nbt (OPTIGA Authenticate NBT, Type 4)
|
||||
hf/iso14443b/ # base.py: Tag14443B (SRIX slot-marker) +
|
||||
# Tag14443B_4 (standard REQB/ATTRIB + ISO-DEP).
|
||||
# Vendor models (organised by mfg, like the rest):
|
||||
# st/st25tb, ti/rf430cl330h (NFC Type 4B)
|
||||
hf/iso15693/ # Tag15693, NfcType5, NXP ICODE/SLIX2/DNA/NTAG5
|
||||
# st/st25tv, st/st25dv, infineon/myd_vicinity
|
||||
# ti/tagit (Tag-it HF-I), ti/rf430frl (sensor)
|
||||
lf/ # EM4100, HID, T5577
|
||||
reader/ # higher-level reader modes by protocol/vendor (scaffold)
|
||||
```
|
||||
@@ -66,7 +85,7 @@ pm3py/
|
||||
pm3.hw.ping() # hardware
|
||||
pm3.hf.iso14a.scan() # ISO 14443-A
|
||||
pm3.hf.iso15.rdbl(4) # ISO 15693
|
||||
pm3.hf.mfc.rdbl(0) # MIFARE Classic
|
||||
pm3.hf.mf.rdbl(0) # MIFARE Classic
|
||||
pm3.lf.t55.readbl(0) # T55xx
|
||||
pm3.hf.tune() # HF antenna tune
|
||||
pm3.hf.dropfield() # drop field
|
||||
@@ -127,6 +146,25 @@ Design doc: `docs/PYTHON_SIM_DESIGN.md`. Firmware patch in `firmware/` submodule
|
||||
|
||||
Firmware maintenance: atomic single-file commits for easy rebase against upstream PM3. See design doc for CI workflow.
|
||||
|
||||
## Firmware flashing
|
||||
|
||||
pm3py flashes the fork firmware itself (`pm3.flasher`, `pm3flash` CLI) — no C `pm3-flash`.
|
||||
Key facts for maintainers:
|
||||
- **OLD frame, not NG.** The bootloader speaks the legacy 544-byte fixed frame (no
|
||||
magic/CRC); `core/transport.py` has `send_old`/`reopen`, `core/flash.py` the `Flasher`.
|
||||
- **fullimage-only by default.** The bootrom region is refused unless `allow_bootrom=True` —
|
||||
a bad OS write is recoverable (proven on hardware); a bad bootrom write bricks to JTAG.
|
||||
- **Merge segments sharing a flash page** (`build_blocks`): the SAM7 erase-programs whole
|
||||
pages, so two adjacent PT_LOAD segments landing in one page must be written once with both
|
||||
segments' data (else 0xFF padding clobbers real bytes — the on-hardware bug we hit).
|
||||
- **Platform ≠ is_rdv4.** `is_rdv4` is the running firmware's compile-time flag, not the
|
||||
board, so `--build`/`build_firmware` require an explicit `PLATFORM` (`PM3GENERIC` = Easy,
|
||||
`PM3RDV4`). Build target: `make -C firmware PLATFORM=... armsrc/all`.
|
||||
- **The pin.** `pm3py/_firmware.py` `FIRMWARE_PIN` is the fork build pm3py corresponds to
|
||||
(currently the submodule SHA); bump it when you bump the submodule. `matches()` does a
|
||||
7-char SHA-prefix test on the device version string. Auto-download of the pinned release
|
||||
is the open follow-up (see the DT-Gitea migration plan).
|
||||
|
||||
## Adding new commands
|
||||
|
||||
1. Find the `CMD_*` constant in `include/pm3_cmd.h` and add to `Cmd` enum in `core/protocol.py`
|
||||
@@ -135,3 +173,36 @@ Firmware maintenance: atomic single-file commits for easy rebase against upstrea
|
||||
4. Parse the response using `struct.unpack_from` on `resp.data`
|
||||
5. Return a dict with human-readable keys
|
||||
6. Write test with `AsyncMock` transport — no hardware needed
|
||||
|
||||
## pyws integration
|
||||
|
||||
pm3py ships a [pyws](https://github.com/dangerousthings/pyws) workspace plugin
|
||||
(`pm3py/pyws_plugin.py`), so pm3py is a first-class pyws workspace: `pyws` drops you into a
|
||||
shell with the device connected and the transponder/sim classes in scope. pyws is a separate,
|
||||
domain-agnostic engine; the plugin is the only pm3py↔pyws coupling. Plan/design:
|
||||
`docs/plans/2026-07-05-pm3py-pyws-plugin-plan.md`.
|
||||
|
||||
- **Discovery.** Registered via the `pyws.plugins` entry point in `pyproject.toml`
|
||||
(`pm3py = "pm3py.pyws_plugin:Pm3pyPlugin"`). pyws must be installed alongside pm3py
|
||||
(editable during dev: `.venv/bin/pip install -e /path/to/pyws`; a `[pyws]` extra is the
|
||||
packaging target once pyws is published).
|
||||
- **One connection mode per workspace.** `reader` (async `Proxmark3`) OR `sim`
|
||||
(`SimSession`) — never both live; they contend on `/dev/ttyACM*`. Declaring both refuses
|
||||
the second.
|
||||
- **`reader` resource** → the connected `Proxmark3` (`.hw/.hf/.lf`). Missing device is
|
||||
non-fatal (reports disconnected).
|
||||
- **`sim` resource** → a `Sim` facade over `SimSession`: opens the port on load; the sim is
|
||||
armed **explicitly** with `sim.start(tag)` (dispatches 14a/15693 by tag type). `sim.stop()`,
|
||||
`sim.push(tag)` (→ `tag.sync()`), `sim.frames` (decoded trace).
|
||||
- **Namespace.** The transponder classes (NTAG21x, Ultralight, MifareClassic, Type5/15693,
|
||||
implant factories), `SimSession`, `Proxmark3`, NDEF helpers — injected so `startup.py` needs
|
||||
no imports.
|
||||
- **Replay safety (effects).** `Pm3pyPlugin.effects` declares pattern policies against the
|
||||
full dotted call path. pyws replays safe-by-default (deny-list): **writes** (`*.wrbl`,
|
||||
`*.writebl`, `tag.sync`, `sim.push`), **field** (`tune`, `dropfield`), **sim**
|
||||
(`sim.start`, firmware sims) and **destructive** ops are withheld on autoreplay; **reads**
|
||||
and pure model edits (`tag.set_page`/`set_ndef`) replay. So reconstructing a session rebuilds
|
||||
the tag *model* but never re-pushes to firmware — your explicit `sim.start(tag)` does that.
|
||||
- **Tests.** `tests/test_pyws_plugin.py`, hardware-free (injected clients/sessions,
|
||||
`AsyncMock`). Uses pm3py's shared-event-loop `_run` convention (NOT `asyncio.run`, which
|
||||
closes the loop and poisons later tests).
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Dangerous Things
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
722
README.md
722
README.md
@@ -1,179 +1,715 @@
|
||||
# pm3py
|
||||
|
||||
Pure-Python library for talking to a [Proxmark3](https://github.com/RfidResearchGroup/proxmark3) over its NG wire protocol. Returns structured Python dicts instead of parsing terminal output. Async-first with a sync wrapper for REPL use.
|
||||
Pure-Python async client for the [Proxmark3](https://github.com/RfidResearchGroup/proxmark3), speaking its NG wire protocol directly over USB serial. Structured Python dicts instead of parsed terminal text, with a sync proxy for REPL use.
|
||||
|
||||
**No dependency on the C client.** Speaks the wire protocol directly over USB serial.
|
||||
- **Pure Python** — no dependency on the C client binary; speaks the wire protocol directly over USB serial.
|
||||
- **Structured results** — every command returns a Python dict, not scraped terminal output.
|
||||
- **Async-first, REPL-friendly** — `async`/`await` core with a synchronous proxy (`Proxmark3.sync()`) that supports `dir()`, `help()`, and tab-completion.
|
||||
- **Live sniff & simulation** — stream decoded reader↔tag frames into your REPL in real time; edit an emulated tag and push it live.
|
||||
- **Software-defined tags** — pure-Python transponder models (ISO 14443-A, ISO 15693, MIFARE Classic, NTAG/Type-2, NXP ICODE/SLIX2/NTAG5, LF) served from a firmware response table.
|
||||
- **Interactive raw console** — [`pm3py rawcli`](#pm3py-rawcli--interactive-raw-command-console) drops into a Proxmark-style TUI for raw command entry: a live annotated trace, hex/binary toggle, and function-style transponder commands.
|
||||
- **Autocompleting client** — [`pm3py client`](#pm3py-client--autocompleting-console-for-the-stock-proxmark3-client) wraps the **stock Proxmark3 client** (so it works with any Proxmark/firmware) with full command-tree completion, press-→ help, and the client's own colored output — and can [spin off a single-file drop-in](#distributing-a-drop-in) for anyone on the stock repo.
|
||||
|
||||
## Install
|
||||
|
||||
Not on PyPI yet — install from a clone. Use `--recurse-submodules` so the firmware fork
|
||||
(`firmware/`) comes with it (needed for flashing and the live-sim features):
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules <repo-url> pm3py
|
||||
cd pm3py
|
||||
pip install -e .
|
||||
pip install -e . # or `pip install .`
|
||||
```
|
||||
|
||||
Requires Python 3.10+ and `pyserial-asyncio`.
|
||||
Requires **Python 3.10+**; dependencies (`pyserial-asyncio`, `bitarray`, `pycryptodome`,
|
||||
`prompt_toolkit`, `pexpect`) install automatically. It's pure Python + pyserial, so it also runs on a **Raspberry Pi**
|
||||
(aarch64) — see [Flashing firmware](#flashing-firmware-pm3flash) for the Pi bring-up.
|
||||
|
||||
## Quick Start
|
||||
|
||||
pm3py is async-first, but the sync REPL proxy is the easiest way to start. `Proxmark3.sync()` auto-detects the serial port, connects, probes the firmware, and returns a proxy where every command runs synchronously:
|
||||
|
||||
```python
|
||||
from pm3py import Proxmark3
|
||||
|
||||
# Sync mode — auto-detects the device
|
||||
pm3 = Proxmark3.sync()
|
||||
print(pm3.firmware.version_string)
|
||||
print(pm3.hw.ping())
|
||||
print(pm3.hw.version())
|
||||
pm3.close()
|
||||
pm3 = Proxmark3.sync() # auto-detect; or Proxmark3.sync("/dev/ttyACM0")
|
||||
print(pm3.firmware) # version + compatibility, populated on connect
|
||||
print(pm3.hw.ping()) # {'success': True, 'length': 32, 'data': '00010203...'}
|
||||
print(pm3.hw.version()["version_string"])
|
||||
print(pm3.hf.iso14a.scan()) # scan a 14443-A card
|
||||
pm3.close() # disconnect + close the event loop
|
||||
```
|
||||
|
||||
Or with async:
|
||||
The sync proxy is REPL-friendly: `dir(pm3)`, `dir(pm3.hf)`, tab-completion, and `help(pm3.hf.iso15.rdbl)` all work and report each command's real signature (the proxy uses `functools.wraps` + `__dir__`, so wrapped methods keep their `__doc__` and parameters instead of showing `(*args, **kwargs)`).
|
||||
|
||||
Prefer async? Use the context manager directly:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from pm3py import Proxmark3
|
||||
|
||||
async def main():
|
||||
async with Proxmark3() as pm3:
|
||||
async with Proxmark3() as pm3: # auto-detect, or Proxmark3("/dev/ttyACM0")
|
||||
print(await pm3.hw.ping())
|
||||
print(await pm3.hf.a14.scan())
|
||||
print(await pm3.hf.iso14a.scan())
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## API
|
||||
All command methods return structured Python dicts, not parsed terminal text. The command tree mirrors the C client:
|
||||
|
||||
All commands return Python dicts. The API mirrors the C client's command tree:
|
||||
| Sub-module | Protocol |
|
||||
|------------|----------|
|
||||
| `pm3.hw.*` | Hardware / device control |
|
||||
| `pm3.lf.*`, `pm3.lf.t55.*` | Low frequency + T55xx |
|
||||
| `pm3.hf.*` | HF core (tune, search, sniff, dropfield) |
|
||||
| `pm3.hf.iso14a.*` | ISO 14443-A |
|
||||
| `pm3.hf.iso15.*` | ISO 15693 |
|
||||
| `pm3.hf.mf.*` | MIFARE Classic |
|
||||
| `pm3.hf.mfu.*` | MIFARE Ultralight / NTAG |
|
||||
|
||||
## API
|
||||
|
||||
### Hardware — `pm3.hw.*`
|
||||
|
||||
```python
|
||||
pm3.hw.ping() # verify connectivity
|
||||
pm3.hw.version() # firmware version, chip ID
|
||||
pm3.hw.status() # device status
|
||||
pm3.hw.capabilities() # compiled features, platform (Easy vs RDV4)
|
||||
pm3.hw.tune() # full antenna tuning (LF + HF voltages)
|
||||
pm3.hw.led("green", on=True) # LED control with color names
|
||||
pm3.hw.led("red", pulse=True, speed=300)# PWM effects (validates hardware support)
|
||||
pm3.hw.led("all", on=False) # turn off
|
||||
pm3.hw.dbg(level=2) # set debug level
|
||||
pm3.hw.break_loop() # stop long-running operations
|
||||
pm3.hw.reset() # reset device
|
||||
pm3.hw.ping() # {'success': True, 'length': 32, 'data': '...'}
|
||||
pm3.hw.ping(length=64) # ping with a custom echo payload length
|
||||
pm3.hw.version() # {'chip_id': '0x...', 'section_size': ..., 'version_string': '...'}
|
||||
pm3.hw.status() # {'status': 0}
|
||||
pm3.hw.capabilities() # {'version', 'baudrate', 'bigbuf_size', 'is_rdv4',
|
||||
# 'compiled_with_lf', 'compiled_with_iso14443a',
|
||||
# 'compiled_with_iso15693'}
|
||||
pm3.hw.tune() # full antenna tuning — nested {'lf': {...}, 'hf': {...}}
|
||||
pm3.hw.dbg() # get debug level -> {'level': N}
|
||||
pm3.hw.dbg(level=2) # set debug level 0-4
|
||||
pm3.hw.led("green", on=True) # LED control by color name
|
||||
pm3.hw.led("all", off=True) # turn everything off
|
||||
pm3.hw.break_loop() # stop a long-running firmware operation
|
||||
pm3.hw.reset() # reset the device (fire-and-forget)
|
||||
```
|
||||
|
||||
LED colors are platform-aware — `"green"` maps to the correct physical LED on both PM3 Easy and RDV4. PWM operations (brightness, pulse, fade) validate hardware capability and raise `PM3Error` if the LED doesn't support it.
|
||||
`hw.tune()` runs the full LF+HF antenna sweep and returns both bands at once:
|
||||
|
||||
```python
|
||||
t = pm3.hw.tune()
|
||||
print(t["lf"]["125kHz_V"], t["lf"]["134kHz_V"], t["lf"]["peak_freq_kHz"])
|
||||
print(t["hf"]["13.56MHz_V"])
|
||||
```
|
||||
|
||||
> **`hw.led` needs our firmware fork** (`CMD_LED_CONTROL` @ `0x011B`). It is not in
|
||||
> stock upstream firmware — there `0x011A` is `CMD_SET_HF_FIELD_TIMEOUT` and `hw.led`
|
||||
> is a no-op. With the fork flashed (the `firmware/` submodule) it drives the LEDs as below.
|
||||
|
||||
**LED control** is platform-aware — color names map to the correct physical LED on both PM3 Easy and RDV4. Accepts color names (`"green"`, `"red"`, `"orange"`, `"blue"`/`"red2"`, `"all"`), letters (`"a"`-`"d"`), comma-separated combos (`"green,red"`), or a bitmask int. Pick one action kwarg:
|
||||
|
||||
```python
|
||||
pm3.hw.led("red", on=True) # on
|
||||
pm3.hw.led("a", toggle=True) # toggle
|
||||
pm3.hw.led("green", blink=True, speed=300, count=10) # blink (all LEDs)
|
||||
pm3.hw.led("green", pulse=True) # PWM pulse — PWM-capable LEDs only
|
||||
pm3.hw.led("green", brightness=50) # PWM brightness 0-100
|
||||
```
|
||||
|
||||
PWM effects (`brightness`, `pulse`, `fade`) validate hardware support and raise `PM3Error` if the target LED can't do PWM (Easy: A,B; RDV4: A,D).
|
||||
|
||||
Also available: `pm3.hw.tearoff(delay_us=..., on=True)` and `pm3.hw.standalone(mode="14a")` for tear-off timing and standalone modes.
|
||||
|
||||
### Low Frequency — `pm3.lf.*`
|
||||
|
||||
```python
|
||||
pm3.lf.tune(divisor=95) # LF antenna voltage at frequency
|
||||
pm3.lf.read(samples=30000) # capture ADC samples
|
||||
pm3.lf.config() # get sampling config
|
||||
pm3.lf.config(divisor=88) # set config (134 kHz)
|
||||
pm3.lf.search() # capture + return LFSearchResult
|
||||
pm3.lf.sniff() # alias for read
|
||||
pm3.lf.sim(gap=0, data=b"...") # simulate from buffer
|
||||
|
||||
# T55xx tags
|
||||
pm3.lf.t55.readbl(block=0)
|
||||
pm3.lf.t55.writebl(block=1, data=0xDEADBEEF)
|
||||
pm3.lf.t55.wakeup(password=0x12345678)
|
||||
pm3.lf.tune() # LF antenna voltage @125kHz (divisor 95)
|
||||
pm3.lf.tune(divisor=88) # @134kHz — see return shape below
|
||||
pm3.lf.read(samples=12288) # capture ADC samples into device buffer
|
||||
pm3.lf.sniff() # alias for read()
|
||||
pm3.lf.config() # get current sampling config
|
||||
pm3.lf.config(divisor=88) # set config (134 kHz); fire-and-forget then re-read
|
||||
pm3.lf.search() # capture + return an LFSearchResult
|
||||
pm3.lf.sim(gap=0, data=b"...") # simulate an LF tag from the sample buffer
|
||||
```
|
||||
|
||||
`lf.tune(divisor=...)` sends the START then MEASURE steps and returns the antenna voltage:
|
||||
|
||||
```python
|
||||
pm3.lf.tune(divisor=95)
|
||||
# {'voltage_mV': 24875, 'voltage_V': 24.875, 'frequency_kHz': 125.0, 'divisor': 95}
|
||||
```
|
||||
|
||||
Frequency is `12000 / (divisor + 1)` kHz — `95` = 125 kHz, `88` = 134 kHz.
|
||||
|
||||
`lf.search()` returns an `LFSearchResult` with callable next steps:
|
||||
|
||||
```python
|
||||
result = pm3.lf.search()
|
||||
print(result) # shows available actions
|
||||
raw = result.download() # download raw ADC samples
|
||||
result.tune() # check antenna voltage
|
||||
result.read_t55xx(block=0) # try reading as T55xx
|
||||
print(result) # prints available actions + sample count
|
||||
raw = result.download() # raw ADC samples as bytes (1 byte/sample, 0-255)
|
||||
result.tune() # check antenna voltage (tag present?)
|
||||
result.read_t55xx(block=0) # try reading it as a T55xx
|
||||
```
|
||||
|
||||
Full tag demodulation (EM410x, HID, AWID, etc.) requires the C client's DSP stack.
|
||||
**Pure-Python LF demodulation — `pm3py.lf`.** Demod no longer needs the C client's DSP stack:
|
||||
`pm3py.lf` ports the ASK/FSK/biphase pipeline and decodes the captured samples directly
|
||||
(EM4100, HID, AWID, FDX-B, and the T55xx config block). Hardware-validated.
|
||||
|
||||
```python
|
||||
from pm3py.lf import identify_lf, read_config
|
||||
identify_lf(pm3) # -> {'label': 'T5577 (EM4100 20260716FF)', 'chip': ..., 'emitted': {...}}
|
||||
read_config(pm3) # decode a T55xx block-0 config (modulation, bit-rate, block count)
|
||||
```
|
||||
|
||||
**T55xx tags — `pm3.lf.t55.*`:**
|
||||
|
||||
```python
|
||||
pm3.lf.t55.readbl(block=0) # -> {'status', 'data': hex, 'raw': bytes}
|
||||
pm3.lf.t55.readbl(block=1, password=0x12345678) # password read
|
||||
pm3.lf.t55.writebl(block=1, data=0xDEADBEEF) # write a 32-bit block
|
||||
pm3.lf.t55.writebl(block=1, data=0x00, password=0x12345678)
|
||||
pm3.lf.t55.wakeup(password=0x12345678) # password wakeup
|
||||
pm3.lf.t55.config() # downlink timing table (client-side)
|
||||
```
|
||||
|
||||
### High Frequency — `pm3.hf.*`
|
||||
|
||||
```python
|
||||
pm3.hf.tune() # HF antenna voltage
|
||||
pm3.hf.search() # try 14a then 15693
|
||||
pm3.hf.sniff() # sniff HF traffic
|
||||
pm3.hf.dropfield() # turn off field
|
||||
```
|
||||
|
||||
### ISO 14443-A — `pm3.hf.a14.*`
|
||||
Core HF operations. `tune()` runs the firmware START/MEASURE/STOP state machine internally and reports the antenna voltage; `search()` probes 14443-A then 15693.
|
||||
|
||||
```python
|
||||
pm3.hf.a14.scan() # → {uid, atqa, sak, ats}
|
||||
pm3.hf.a14.raw(data=b"\x50\x00") # raw APDU
|
||||
pm3.hf.a14.sniff()
|
||||
pm3.hf.a14.sim(uid=b"\x01\x02\x03\x04")
|
||||
pm3.hf.tune() # → {'voltage_mV': 4521, 'voltage_V': 4.521}
|
||||
pm3.hf.search() # try 14a then 15693 → {'iso14443a': {...}, 'found': True}
|
||||
pm3.hf.sniff() # sniff raw HF, returns {'status', 'samples'}
|
||||
pm3.hf.dropfield() # turn the HF field off (fire-and-forget)
|
||||
```
|
||||
|
||||
`search()` returns a dict with an `iso14443a` and/or `iso15693` key when a tag answers, plus `found: bool`. `sniff()` accepts `samples_to_skip`, `triggers_to_skip`, `skip_mode`, `skip_ratio` and returns the captured sample count; for decoded, protocol-aware sniffing (including live streaming) use `SniffSession` instead.
|
||||
|
||||
### ISO 14443-A — `pm3.hf.iso14a.*`
|
||||
|
||||
```python
|
||||
pm3.hf.iso14a.scan() # → {'found', 'uid', 'atqa', 'sak', 'ats', ...}
|
||||
pm3.hf.iso14a.raw(b"\x30\x00") # raw frame → {'status', 'data', 'raw'}
|
||||
pm3.hf.iso14a.sniff() # sniff 14443-A, returns {'status'}
|
||||
pm3.hf.iso14a.sim(tagtype=1, uid=b"\x01\x02\x03\x04") # simulate a tag
|
||||
```
|
||||
|
||||
`scan()` selects the card and returns UID/ATQA/SAK plus `ats` and a `select_status` (1=OK+ATS, 2=OK no ATS, 3=proprietary). `raw()` sends a raw frame with the RAW + NO_DISCONNECT flags already set; pass extra `flags` and a `timeout_14a` if needed. `sim()` emulates a tag — `tagtype` picks the model (1=MIFARE Classic 1k, 2=Ultralight, 3=DESFire, 4=ISO14443-4, 7=MFU EV1/NTAG215, 8=MFC 4k, 11=JCOP, 13=ULC, 14=ULAES, ...), `uid` is 4/7/10 raw bytes (empty = take UID from emulator memory), and `exit_after` stops after N reader reads.
|
||||
|
||||
For a full software-defined tag served from the firmware response table (NDEF, custom commands, live trace), use `SimSession` with an `NfcType2Tag` — see the simulation section.
|
||||
|
||||
### ISO 15693 — `pm3.hf.iso15.*`
|
||||
|
||||
```python
|
||||
pm3.hf.iso15.scan() # → {uid, dsfid}
|
||||
pm3.hf.iso15.rdbl(block=0) # read block
|
||||
pm3.hf.iso15.wrbl(block=0, data="00112233")
|
||||
pm3.hf.iso15.sniff()
|
||||
pm3.hf.iso15.scan() # quick single-tag → {'found', 'uid', 'dsfid', 'response_flags'}
|
||||
pm3.hf.iso15.inventory() # 16-slot anti-collision → list of tag dicts
|
||||
pm3.hf.iso15.rdbl(0) # read block 0
|
||||
pm3.hf.iso15.wrbl(0, "00112233") # write block 0
|
||||
pm3.hf.iso15.sniff() # sniff 15693 (blocks until button/timeout)
|
||||
```
|
||||
|
||||
`scan()` is the "is one tag present" convenience and returns a single dict. `inventory()` runs a real anti-collision round:
|
||||
|
||||
```python
|
||||
# 16-slot round (default) → LIST of tags (empty list if none)
|
||||
pm3.hf.iso15.inventory()
|
||||
# → [{'uid': 'e0040150...', 'dsfid': 0, 'flags': 0, 'slot': 3}, ...]
|
||||
|
||||
# single slot → ONE tag dict (or None)
|
||||
pm3.hf.iso15.inventory(slots=1)
|
||||
# → {'uid': 'e0040150...', 'dsfid': 0, 'flags': 0}
|
||||
|
||||
# filter by AFI (request-level, only matching tags answer)
|
||||
pm3.hf.iso15.inventory(afi=0x00)
|
||||
|
||||
# filter by DSFID (client-side, on the returned records)
|
||||
pm3.hf.iso15.inventory(dsfid=0)
|
||||
```
|
||||
|
||||
UIDs come back in display order (already reversed from wire order). Block I/O optionally takes a UID for addressed mode:
|
||||
|
||||
```python
|
||||
pm3.hf.iso15.rdbl(4) # unaddressed read
|
||||
pm3.hf.iso15.rdbl(4, uid="e004015012345678") # addressed read
|
||||
# → {'success': True, 'block': 4, 'locked': False, 'data': '00112233', 'raw': b'...'}
|
||||
|
||||
pm3.hf.iso15.wrbl(4, "00112233") # unaddressed write (bytes or hex str)
|
||||
pm3.hf.iso15.wrbl(4, b"\x00\x11\x22\x33", uid="e004015012345678")
|
||||
# → {'success': True, 'block': 4}
|
||||
```
|
||||
|
||||
`raw_inventory(inv_flags=0x26)` is a diagnostic that returns the unparsed device response (`0x26` = 1 slot, `0x06` = 16 slots). For decoded/live sniffing use `SniffSession`.
|
||||
|
||||
### MIFARE Classic — `pm3.hf.mf.*`
|
||||
|
||||
> **Renamed:** the MIFARE Classic sub-module used to be `pm3.hf.mfc`. It is now
|
||||
> `pm3.hf.mf` (the backing module is still `pm3py/core/hf_mfc.py`). Update any
|
||||
> old scripts.
|
||||
|
||||
Keys are 6 bytes, given as a hex string or `bytes`. `key_type` is `0` for
|
||||
key A (default) or `1` for key B.
|
||||
|
||||
```python
|
||||
pm3.hf.mf.rdbl(block=0) # read block (default key FF..FF)
|
||||
pm3.hf.mf.rdbl(block=4, key="A0A1A2A3A4A5", key_type=1) # key B
|
||||
pm3.hf.mf.wrbl(block=4, key="FFFFFFFFFFFF", data="00" * 16)
|
||||
pm3.hf.mf.rdsc(sector=0) # read entire sector
|
||||
pm3.hf.mf.chk(block=0, keys=["FFFFFFFFFFFF", "A0A1A2A3A4A5"])
|
||||
pm3.hf.mf.sniff()
|
||||
pm3.hf.mf.sim(uid="01020304", size="1k")
|
||||
pm3.hf.mf.nested(block=0, key="FFFFFFFFFFFF")
|
||||
pm3.hf.mf.cident() # identify magic card type
|
||||
pm3 = Proxmark3.sync()
|
||||
|
||||
# Read one block (default key FFFFFFFFFFFF, key A)
|
||||
pm3.hf.mf.rdbl(0)
|
||||
# → {'success': True, 'block': 0, 'data': '0403...', 'raw': b'\x04\x03...'}
|
||||
|
||||
pm3.hf.mf.rdbl(4, key="A0A1A2A3A4A5", key_type=1) # key B
|
||||
|
||||
# Write a block (data must be 16 bytes)
|
||||
pm3.hf.mf.wrbl(4, data="00112233445566778899AABBCCDDEEFF",
|
||||
key="FFFFFFFFFFFF", key_type=0)
|
||||
# → {'success': True, 'block': 4}
|
||||
|
||||
# Read a whole sector (sectors 0–31 = 4 blocks, 32–39 = 16 blocks)
|
||||
pm3.hf.mf.rdsc(0) # → {'sector': 0, 'first_block': 0, 'num_blocks': 4, 'blocks': {...}}
|
||||
|
||||
# Key-check a block against a list of candidate keys
|
||||
pm3.hf.mf.chk(0, keys=["FFFFFFFFFFFF", "A0A1A2A3A4A5", "D3F7D3F7D3F7"])
|
||||
# → {'found': True, 'key': 'ffffffffffff', 'key_type': 0} (or {'found': False})
|
||||
```
|
||||
|
||||
### Raw Commands
|
||||
**Nested attack** — recover an unknown key using one known key on the card:
|
||||
|
||||
For anything not wrapped:
|
||||
```python
|
||||
pm3.hf.mf.nested(block=0, key="FFFFFFFFFFFF", key_type=0,
|
||||
target_block=4, target_key_type=1, calibrate=True)
|
||||
# → {'success': True, 'is_ok': 0, 'block': 4, 'key_type': 1,
|
||||
# 'cuid': '...', 'nt_a': '...', 'ks_a': '...', 'nt_b': '...', 'ks_b': '...'}
|
||||
```
|
||||
|
||||
**Magic-card identification** — detect gen1/gen2 "magic" cards:
|
||||
|
||||
```python
|
||||
pm3.hf.mf.cident()
|
||||
# → {'status': 0, 'magic': 0, 'is_magic': False}
|
||||
```
|
||||
|
||||
**Sniff / simulate:**
|
||||
|
||||
```python
|
||||
pm3.hf.mf.sniff() # routed through the ISO14443-A sniffer
|
||||
|
||||
# Simulate a card. size ∈ {"mini","1k","2k","4k"}; UID is 4, 7, or 10 bytes.
|
||||
pm3.hf.mf.sim(uid="01020304", size="1k")
|
||||
pm3.hf.mf.sim(uid="04AABBCCDDEE80", size="4k",
|
||||
atqa="0044", sak=0x18, exit_after=1, interactive=True)
|
||||
```
|
||||
|
||||
For full software-defined MIFARE Classic simulation (response table served from
|
||||
firmware, live trace), see the sim framework section and `MifareClassicTag`.
|
||||
|
||||
### MIFARE Ultralight / NTAG — `pm3.hf.mfu.*`
|
||||
|
||||
New sub-module for ISO14443-3A tags with 4-byte pages (MIFARE Ultralight,
|
||||
UL-C, UL-EV1, UL-AES, and the NTAG21x family). A `READ` returns 4 consecutive
|
||||
pages (16 bytes) at once; a `WRITE` sets a single 4-byte page.
|
||||
|
||||
`keytype` selects the auth scheme (pass the matching `key`):
|
||||
|
||||
| keytype | scheme | key length |
|
||||
|---------|--------|-----------|
|
||||
| `0` | none (default) | — |
|
||||
| `1` | UL-C (3DES) | 16 bytes |
|
||||
| `2` | UL-EV1 / NTAG password | 4 bytes |
|
||||
| `3` | UL-AES | 16 bytes |
|
||||
|
||||
```python
|
||||
pm3 = Proxmark3.sync()
|
||||
|
||||
# Read starting at page 4 → 16 bytes (pages 4,5,6,7)
|
||||
pm3.hf.mfu.rdbl(4)
|
||||
# → {'success': True, 'block': 4, 'data': '01020304...', 'raw': b'\x01\x02...'}
|
||||
|
||||
# Password-authenticated read (UL-EV1 / NTAG, 4-byte pwd)
|
||||
pm3.hf.mfu.rdbl(4, key="FFFFFFFF", keytype=2)
|
||||
|
||||
# Write one 4-byte page
|
||||
pm3.hf.mfu.wrbl(4, data="DEADBEEF")
|
||||
# → {'success': True, 'block': 4}
|
||||
|
||||
# Dump pages (each underlying READ pulls 4 pages; stops early on failure)
|
||||
pm3.hf.mfu.dump(start=0, pages=16)
|
||||
# → {'success': True, 'pages': 16, 'data': '...', 'raw': b'...'}
|
||||
```
|
||||
|
||||
For software-defined NTAG/Type-2 simulation (NDEF, served from a firmware
|
||||
response table with live trace), use `NfcType2Tag` with `SimSession` — see the
|
||||
sim framework section.
|
||||
|
||||
## Live Sniff & Simulation
|
||||
|
||||
pm3py can drive the Proxmark3 as a **live sniffer** and as a **software-defined card simulator**, with reader↔tag traffic streamed into your REPL in real time. Both use custom firmware (the `firmware/` submodule / `proxmark3-pm3py`) and open the serial port directly, so they run from the plain sync REPL — no asyncio needed.
|
||||
|
||||
### Live sniff — `SniffSession`
|
||||
|
||||
`SniffSession` starts a firmware sniff and streams decoded frames to the console from a background thread. The Proxmark3 button **toggles pause/resume** while streaming, so you can walk a card up to the field, pause, inspect, and resume without touching the keyboard. Captured frames accumulate across pause/resume cycles in `session.entries`.
|
||||
|
||||
```python
|
||||
from pm3py.sniff.session import SniffSession
|
||||
|
||||
s = SniffSession.open() # opens /dev/ttyACM0 by default
|
||||
s.start_15693(live=True) # streams ISO 15693 trace live; PM3 button pauses/resumes
|
||||
# ... present a tag to the antenna; decoded reader/tag frames print as they arrive ...
|
||||
s.stop() # end the sniff (sends BREAK_LOOP)
|
||||
|
||||
print(len(s.entries)) # all captured frames across pause/resume cycles
|
||||
print(s.state) # 'sniffing' | 'paused' | 'stopped'
|
||||
s.entries[0] # {'protocol','direction','duration','timestamp','data','data_hex','decoded'}
|
||||
s.close() # stop (if needed) and close the serial port
|
||||
```
|
||||
|
||||
- `SniffSession.open(port="/dev/ttyACM0", baudrate=115200)` — open the port.
|
||||
- `start_15693(live=True, idle_timeout_ms=400)` — live streaming with button-toggle. Pass `live=False` for the legacy BigBuf batch path (for batch decoding you can also use `pm3.hf.iso15.sniff()`).
|
||||
- `s.state`, `s.entries`, `s.clear()` — inspect and reset accumulated capture.
|
||||
- `s.stop()` / `s.close()` — halt streaming / release the port.
|
||||
|
||||
Status transitions (Started / Paused / Resumed / Stopped) print inline as the button is pressed.
|
||||
|
||||
### Live sim — `SimSession`
|
||||
|
||||
`SimSession` compiles a Python tag model into a firmware **response table**, uploads it, and starts the tag simulation. The firmware answers standard commands (inventory, read, write) autonomously at wire speed. With `trace=True`, every reader↔tag frame is decoded and streamed live from a background thread. You can then **edit the tag in the REPL and push the changes live** with `tag.sync()` — no restart of the sim required.
|
||||
|
||||
```python
|
||||
from pm3py.sim import SimSession, IcodeSlix2Tag, ndef_uri
|
||||
|
||||
# Build a tag model (uid=None auto-generates a valid E0 04 02.. SLIX2 UID)
|
||||
tag = IcodeSlix2Tag(uid=None, ndef_message=ndef_uri("https://dngr.us"))
|
||||
|
||||
s = SimSession.open() # opens /dev/ttyACM0 by default
|
||||
s.start_15693(tag, compile=True, trace=True) # upload table, start sim, stream frames live
|
||||
# ... a phone / reader now reads the emulated tag; frames print as they arrive ...
|
||||
|
||||
# Live edit: change the NDEF payload and push it to the firmware emulator memory
|
||||
tag.set_ndef(ndef_uri("https://example.org/new"))
|
||||
tag.sync() # firmware now serves the updated content
|
||||
|
||||
s.stop() # stop the sim (sends BREAK_LOOP)
|
||||
s.close() # stop (if needed) and close the port
|
||||
```
|
||||
|
||||
- `SimSession.open(port="/dev/ttyACM0", baudrate=115200)` — open the port for a synchronous sim.
|
||||
- `start_15693(tag, compile=True, trace=False)` — compile+upload the response table and start the 15693 sim; `trace=True` streams decoded reader↔tag traffic (and reports RF field strength via the optional `on_field_strength` callback).
|
||||
- `tag.sync()` — push in-REPL edits (UID, memory, NDEF, access conditions) to firmware emulator memory. It does a smart sync: EML memory push for data changes, response-table recompile for access-condition changes. The tag must be bound to a live session (`start_15693` binds it automatically).
|
||||
- `s.stop()` / `s.close()` — halt the sim / release the port.
|
||||
|
||||
A 14443-A sim path (`await s.start_14a(tag)`, WTX relay) also exists but is **async and experimental** — it requires the async `PM3Transport` (not the synchronous `SimSession.open()` port above) and is still under development.
|
||||
|
||||
### Structured / scriptable access
|
||||
|
||||
For scripting you usually want the frames as **data**, not printed colored text. Both
|
||||
`start_15693(...)` methods take two optional kwargs (defaults preserve the console
|
||||
behavior above):
|
||||
|
||||
- **`on_frame=callback`** — invoked per decoded frame (from the reader thread) with a
|
||||
plain dict. Passing it also auto-enables trace streaming on the sim.
|
||||
- **`quiet=True`** — don't print frames to the console (the callback and the `entries`
|
||||
accumulator still fill).
|
||||
|
||||
Every frame is an ANSI-free dict:
|
||||
|
||||
```python
|
||||
{'protocol': int, 'direction': int, # 0 = reader→tag, 1 = tag→reader
|
||||
'timestamp': int|None, 'duration': int|None,
|
||||
'data': bytes, 'data_hex': str,
|
||||
'decoded': str|None} # e.g. 'INVENTORY', 'READ BLOCK 4'
|
||||
```
|
||||
*(SimSession frames also carry `'crc_fail': bool`; sniff frames carry `timestamp`/`duration`.)*
|
||||
|
||||
**Push model** — react to frames as they arrive, no printing:
|
||||
|
||||
```python
|
||||
from pm3py.sniff.session import SniffSession
|
||||
|
||||
reads = []
|
||||
s = SniffSession.open()
|
||||
s.start_15693(on_frame=lambda f: reads.append(f["decoded"]), quiet=True)
|
||||
# ... run a reader against a tag ...
|
||||
s.stop(); s.close()
|
||||
print(reads) # ['INVENTORY', 'READ BLOCK 0', ...] — pure data, no ANSI
|
||||
```
|
||||
|
||||
**Pull model** — run quiet, then read the accumulator afterward:
|
||||
|
||||
```python
|
||||
from pm3py.sim import SimSession, IcodeSlix2Tag
|
||||
|
||||
s = SimSession.open()
|
||||
s.start_15693(IcodeSlix2Tag(uid=None), trace=True, quiet=True) # capture, no printing
|
||||
# ... phone reads the tag ...
|
||||
s.stop()
|
||||
for f in s.entries: # list of frame dicts (a copy)
|
||||
print(f["direction"], f["data_hex"], f["decoded"])
|
||||
s.clear(); s.close()
|
||||
```
|
||||
|
||||
`SimSession.entries` mirrors `SniffSession.entries`; both have `.clear()`.
|
||||
|
||||
### Card simulation
|
||||
|
||||
The simulation framework (`pm3py/sim/` infrastructure, `pm3py/transponders/` tag models) provides pure-Python transponder models served from the firmware response table. Import everything from `pm3py.sim`.
|
||||
|
||||
**ISO 15693 (`start_15693`)**
|
||||
- `NfcType5Tag` — generic NFC Forum Type 5 tag with NDEF (`ndef_text`, `ndef_uri`, `ndef_mime`).
|
||||
- `IcodeSlix2Tag` (and `IcodeSlixTag`, `Icode3Tag`, `IcodeDnaTag`, `NxpIcodeTag`) — NXP ICODE family with passwords, privacy mode, EAS/AFI, originality signature.
|
||||
- `Ntag5SwitchTag`, `Ntag5LinkTag`, `Ntag5BoostTag` (base `Ntag5PlatformTag`) — NXP NTAG 5 dual-interface family.
|
||||
|
||||
**ISO 14443-A (`start_14a`, async/experimental)**
|
||||
- `NfcType2Tag` — NFC Forum Type 2 (MIFARE Ultralight / NTAG-style) with NDEF.
|
||||
- `NfcType4Tag`, `MifareClassicTag`, `DesfireTag` — Type 4, MIFARE Classic (Crypto1), and DESFire models.
|
||||
|
||||
```python
|
||||
from pm3py.sim import SimSession, NfcType5Tag, ndef_uri
|
||||
|
||||
# uid=None auto-generates a valid E0-prefixed 15693 UID
|
||||
tag = NfcType5Tag(uid=None, ndef_message=ndef_uri("https://dngr.us"))
|
||||
s = SimSession.open()
|
||||
s.start_15693(tag, trace=True) # emulate an NFC Type 5 tag to a 15693 reader
|
||||
s.stop(); s.close()
|
||||
```
|
||||
|
||||
NDEF helpers `ndef_text(text, lang="en")`, `ndef_uri(uri)`, and `ndef_mime(mime_type, data)` build the message bytes passed as `ndef_message=` (or via `tag.set_ndef(...)` for live edits). `uid=None` on the 15693 models auto-generates a valid `E0`-prefixed UID with the correct manufacturer/IC-type bytes so phones identify the IC correctly.
|
||||
|
||||
## Raw Commands
|
||||
|
||||
For anything not yet wrapped, drop down to the raw NG/MIX frame senders. Both are
|
||||
regular client methods, so they work through the sync REPL proxy or `await` in async
|
||||
code:
|
||||
|
||||
```python
|
||||
from pm3py import Cmd
|
||||
|
||||
resp = pm3.send_ng(Cmd.STATUS) # NG frame
|
||||
resp = pm3.send_mix(Cmd.HF_ISO14443A_READER, arg0=0x0103) # MIX frame
|
||||
# NG frame: cmd + raw payload bytes
|
||||
resp = pm3.send_ng(Cmd.STATUS)
|
||||
resp = pm3.send_ng(Cmd.CAPABILITIES)
|
||||
|
||||
# MIX frame: cmd + three uint64 args + optional payload
|
||||
resp = pm3.send_mix(Cmd.HF_ISO14443A_READER, arg0=0x0103)
|
||||
|
||||
# resp is a PM3Response: resp.cmd, resp.status, resp.data (bytes)
|
||||
print(resp.status, resp.data.hex(), len(resp.data))
|
||||
```
|
||||
|
||||
Signatures: `send_ng(cmd, payload=b"", timeout=2.0)` and
|
||||
`send_mix(cmd, arg0=0, arg1=0, arg2=0, payload=b"", timeout=2.0)`. Both return a
|
||||
`PM3Response` (fields: `cmd`, `status`, `reason`, `ng`, `data`, `oldarg`). Use these
|
||||
only for commands the firmware replies to — fire-and-forget commands (e.g.
|
||||
`HF_DROPFIELD`) send no response and would block until the timeout; call the wrapped
|
||||
method (`pm3.hf.dropfield()`) for those.
|
||||
|
||||
> **REPL discovery:** the sync proxy returned by `Proxmark3.sync()` implements
|
||||
> `__dir__` and uses `functools.wraps` on every wrapped method, so `dir(pm3)`,
|
||||
> tab-completion, and `help(pm3.hf.iso15.rdbl)` all surface the real sub-modules,
|
||||
> method names, and signatures (e.g. `rdbl(block, uid=None)`) instead of an opaque
|
||||
> `(*args, **kwargs)` wrapper.
|
||||
|
||||
## `pm3py rawcli` — interactive raw-command console
|
||||
|
||||
`pm3py rawcli` opens an interactive, Proxmark-style console for driving a connected device at the
|
||||
raw level — a live annotated trace, flexible hex/binary entry, and function-style transponder
|
||||
commands. It's a **separate tool from the Python REPL**: its own command grammar, not Python.
|
||||
|
||||
```bash
|
||||
pm3py rawcli # auto-detect the device (runs offline if none attached)
|
||||
```
|
||||
|
||||
```text
|
||||
[raw / 0x] identify # probe the field; sets HF/LF + transponder, keeps the link
|
||||
NTAG213 (HF / 14a) UID 04A1B2C3
|
||||
|
||||
[raw / hf / NTAG213 / 0x] READ(4) # function-style command from the tag's catalog
|
||||
[Rdr] Reader → Tag: 30 04 READ page 4
|
||||
[Rdr] Tag → Reader: 01 02 03 04 …
|
||||
|
||||
[raw / hf / NTAG213 / 0x] 30 04 # …or send raw bytes directly
|
||||
[raw / hf / NTAG213 / 0b] 30 00000100 # per-byte base: hex 30 + a binary byte → sends 0x30 0x04
|
||||
[raw / hf / NTAG213 / 0x] tlv 00 03 09 D1 01 05 54 02 65 6E 48 69 FE # decode a TLV / NDEF block
|
||||
```
|
||||
|
||||
- **Breadcrumb prompt** `[raw / hf|lf / <transponder> / 0x|0b]` reflects the field, the identified
|
||||
tag, and the current numeric entry mode.
|
||||
- **Per-byte base entry** — type hex (`30 04`) or binary; **Ctrl-/** toggles `0x`/`0b` and digits
|
||||
auto-group into bytes (a byte seals at 2 hex / 8 binary). Base is tracked **per byte**: the
|
||||
toggle only changes the base of the *next* byte, so `30` then toggle then `00000100` reads as
|
||||
`30 00000100` and sends `0x30 0x04`. A `0x`/`0b` prefix overrides for one token; function-call
|
||||
args are base-10 (`READ(4)`, `READ(0x2B)`). Mixing a command with raw bytes on one line is an
|
||||
error, not a transmit.
|
||||
- **`identify`** probes 14a → 15693 → LF and keeps the tag selected; **`transponder`** / **`close`**
|
||||
manage the connection.
|
||||
- **Function-style commands** from the identified tag's catalog: NTAG/Ultralight (`READ`,
|
||||
`FAST_READ`, `GET_VERSION`, `WRITE`, …), **MIFARE Classic** (`CHK(block)` to find the sector
|
||||
key, then authenticated `READ(block[, key][, A|B])` / `WRITE`), ISO 15693, and LF T5577
|
||||
(`READ`/`WRITE`/`DETECT`). `help` lists them, `help READ` shows one.
|
||||
- **Completion + memory map** — command names, opcodes matched by the hex/binary value you type,
|
||||
and the identified tag's **datasheet memory map** all surface in the completion menu, rendered
|
||||
in whichever base you're entering. Page/block roles are named (UID, CC/OTP, user memory,
|
||||
CFG0/CFG1, PWD/PACK on NTAG21x/Ultralight; manufacturer block and sector trailers — Key A /
|
||||
access bits / Key B — on MIFARE Classic).
|
||||
- **Live annotated trace** — every exchange prints the reader↔tag frames with decoded annotations.
|
||||
- **TLV / NDEF** — `tlv <hex>` prints a structured multi-line breakdown; a multi-line editor
|
||||
composes long TLVs.
|
||||
|
||||
## `pm3py client` — autocompleting console for the stock Proxmark3 client
|
||||
|
||||
Where `rawcli` speaks pm3py's own protocol stack, `pm3py client` wraps the **standard Proxmark3 C
|
||||
client** — so it works with any Proxmark3 and any firmware the stock client supports. You get the
|
||||
real client's exact behavior and its native colored output, with autocompletion and inline help
|
||||
layered over its full command tree.
|
||||
|
||||
```bash
|
||||
pm3py client # auto-detect the device and the client
|
||||
pm3py client --driver pexpect # force the binary backend (no SWIG build needed)
|
||||
pm3py client --port /dev/ttyACM0 --commands /path/to/commands.json
|
||||
```
|
||||
|
||||
```text
|
||||
[client / usb] pm3py --> hf mf rd⇥ # Tab completes to `hf mf rdbl`, then descends to its options
|
||||
[client / usb] pm3py --> hf mf rdbl --blk 0 -k FFFFFFFFFFFF
|
||||
[+] 0 | 04 A1 B2 C3 D4 08 04 00 62 63 64 65 66 67 68 69 | # the client's own colored output
|
||||
[client / usb] pm3py --> hf 14a in⇥ # → on the highlighted `info` opens its full help in the bar
|
||||
```
|
||||
|
||||
- **Full command-tree completion** — completes each level (`hf` → `hf mf` → `hf mf rdbl`) and a
|
||||
command's option flags (`--blk`, `-k`), each with its description as the tooltip. **Tab** commits
|
||||
the highlighted item and descends to the next level.
|
||||
- **Press-→ help** — with a completion highlighted, **→** expands its full help (description, usage,
|
||||
options, notes) in the bottom bar; **←** / **Esc** / **Ctrl-g** backs out. Otherwise the bar shows
|
||||
a one-line hint for the current command.
|
||||
- **The client's own output** — commands run through the real client and print its native colored
|
||||
output verbatim; the breadcrumb `[client / usb]` reflects the live connection.
|
||||
- **Two backends** (`--driver auto`, the default):
|
||||
- **SWIG** — the in-process `_pm3` bindings (`console()` + captured output); needs the client's
|
||||
`experimental_lib` extension built, and a connected device.
|
||||
- **pexpect** — spawns the `proxmark3` binary and reads its prompts; no build required, and runs
|
||||
offline. Used automatically when `_pm3` isn't importable, or forced with `--driver pexpect`.
|
||||
- **Command tree from the client** — the menu is built from the checkout's `doc/commands.json` (the
|
||||
same set the client itself exposes), auto-located from the repo; override with `--commands`.
|
||||
|
||||
### Distributing a drop-in
|
||||
|
||||
`pm3py client --pack` spins off a **self-contained** copy for people who use the stock Iceman/RRG
|
||||
Proxmark3 repo but not pm3py:
|
||||
|
||||
```bash
|
||||
pm3py client --pack pm3cli.py # one readable file — needs `pip install prompt_toolkit pexpect`
|
||||
pm3py client --pack pm3cli.pyz # a zipapp with those deps bundled — runs on bare `python3`
|
||||
```
|
||||
|
||||
Drop the file into (or beside) a Proxmark3 checkout and run it (`python3 pm3cli.py`). It finds the
|
||||
checkout's `doc/commands.json` and client automatically — walking up from where it's dropped, or via
|
||||
`$PM3_REPO` — auto-detects `/dev/ttyACM*`, and gives the same console. The file is generated from
|
||||
`pm3py/cli/clientcli/` (the tested source of truth), so it never drifts.
|
||||
|
||||
## Firmware Compatibility
|
||||
|
||||
On connect, pm3py pings the device and fetches the firmware version. Results are in `pm3.firmware`:
|
||||
On connect, pm3py pings the device and fetches the firmware version. The result is on
|
||||
`pm3.firmware`:
|
||||
|
||||
```python
|
||||
pm3 = Proxmark3.sync()
|
||||
print(pm3.firmware.compatible) # True if NG protocol works
|
||||
print(pm3.firmware.version_string) # firmware version
|
||||
print(pm3.firmware.compatible) # True if the NG protocol handshake worked
|
||||
print(pm3.firmware.version_string) # firmware version string
|
||||
print(pm3.firmware.chip_id) # e.g. "0x270B0A40"
|
||||
print(pm3.firmware.warnings) # any issues detected
|
||||
print(pm3.firmware.warnings) # any issues detected during probe
|
||||
```
|
||||
|
||||
Core reader/sniff/HW commands work against **stock RfidResearchGroup/proxmark3**
|
||||
firmware. The **live streaming** features (streaming sniff and the response-table /
|
||||
trace card simulation) require our firmware fork, tracked as the `firmware/` git
|
||||
submodule (`proxmark3-pm3py`, rebased onto upstream RfidResearchGroup/proxmark3).
|
||||
Those add the `CMD_HF_SNIFF_STREAM` live-frame path and the BigBuf response-table +
|
||||
WTX relay used by `SimSession`. Everything else degrades gracefully on stock firmware.
|
||||
To put the fork on your device, use [`pm3flash`](#flashing-firmware-pm3flash) (below).
|
||||
|
||||
## Flashing firmware (`pm3flash`)
|
||||
|
||||
The live features (streaming sniff, response-table/trace card sim, `hw.led`) need the
|
||||
**firmware fork** (`proxmark3-pm3py`, the `firmware/` submodule). pm3py flashes it for you
|
||||
over USB — a pure-Python flasher, no C `pm3-flash`/DFU/JTAG. Each pm3py release is pinned to
|
||||
a firmware build, and `pm3.firmware.matches_pin` tells you whether your device runs it.
|
||||
|
||||
```bash
|
||||
pm3flash # detect the device; flash the pinned firmware if it differs
|
||||
pm3flash --image fullimage.elf # flash a specific prebuilt image
|
||||
pm3flash --build PM3GENERIC # build from the submodule (Easy), then flash
|
||||
pm3flash --force # flash even if the device already matches the pin
|
||||
```
|
||||
|
||||
`pm3flash` auto-detects the port, compares the device firmware to the pin, then (fullimage
|
||||
only) flashes and verifies. It's **bootrom-safe** — it never writes the bootloader, so a bad
|
||||
flash is recoverable, not a brick.
|
||||
|
||||
Image resolution order: `--image`, then `$PM3PY_FIRMWARE`, then a local build at
|
||||
`firmware/armsrc/obj/fullimage.elf`. Build one with:
|
||||
|
||||
```bash
|
||||
make -C firmware PLATFORM=PM3GENERIC armsrc/all # PM3 Easy / generic
|
||||
make -C firmware PLATFORM=PM3RDV4 armsrc/all # RDV4
|
||||
```
|
||||
|
||||
The firmware is an ARM **cross-compile** (needs `gcc-arm-none-eabi` + `make`; the FPGA
|
||||
bitstream is committed, so no FPGA toolchain), and the `fullimage.elf` it produces is the
|
||||
same regardless of build host. **Choose `PLATFORM` yourself** — pm3py can't infer it, because
|
||||
a device's `is_rdv4` flag reports the firmware it's running, not the physical board.
|
||||
|
||||
**On a Raspberry Pi:** `pip install` pm3py on the Pi (works on aarch64), then either
|
||||
cross-build on another Linux box and copy the image over —
|
||||
|
||||
```bash
|
||||
make -C firmware PLATFORM=PM3GENERIC armsrc/all # dev box
|
||||
scp firmware/armsrc/obj/fullimage.elf pi@rpi:~/
|
||||
pm3flash --image ~/fullimage.elf # on the Pi
|
||||
```
|
||||
|
||||
— or build on the Pi itself (with `gcc-arm-none-eabi` + `make`): `pm3flash --build PM3GENERIC`.
|
||||
|
||||
**Programmatic** (the CLI wraps `pm3.flasher`):
|
||||
|
||||
```python
|
||||
pm3 = Proxmark3.sync()
|
||||
pm3.firmware.matches_pin # False -> not on the pinned build
|
||||
pm3.firmware.expected_firmware # the pinned firmware id
|
||||
pm3.flasher.flash("firmware/armsrc/obj/fullimage.elf") # enter bootloader, write, reset, verify
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
pm3py/
|
||||
├── protocol.py # Wire protocol constants, CRC-16/A, command IDs, error codes
|
||||
├── transport.py # Frame encode/decode, async serial I/O, PM3Transport
|
||||
├── client.py # Proxmark3 class, sync proxy, firmware probe
|
||||
├── hw.py # hw.* commands
|
||||
├── lf.py # lf.* commands + T55xx
|
||||
├── hf.py # hf.* commands (tune, search, sniff)
|
||||
├── hf_14a.py # hf.14a.* (ISO 14443-A)
|
||||
├── hf_15.py # hf.15.* (ISO 15693)
|
||||
└── hf_mf.py # hf.mf.* (MIFARE Classic)
|
||||
├── core/ # wire protocol + device command modules
|
||||
│ ├── protocol.py # constants, CRC-16/A, Cmd enum, PM3Status
|
||||
│ ├── transport.py # NG/MIX frame encode/decode, PM3Transport (async serial)
|
||||
│ ├── client.py # Proxmark3, _SyncProxy, FirmwareInfo
|
||||
│ ├── hw.py # hw.* (ping, version, tune, LEDs, dbg, reset)
|
||||
│ ├── hf.py # hf.* core (tune, search, sniff, dropfield)
|
||||
│ ├── hf_iso14a.py # hf.iso14a.* (ISO 14443-A)
|
||||
│ ├── hf_iso15.py # hf.iso15.* (ISO 15693)
|
||||
│ ├── hf_mfc.py # hf.mf.* (MIFARE Classic)
|
||||
│ ├── hf_mfu.py # hf.mfu.* (MIFARE Ultralight / NTAG)
|
||||
│ └── lf.py # lf.* + lf.t55.* (T55xx) + LFSearchResult
|
||||
├── trace/ # firmware trace parsing + annotation (shared by sniff & sim)
|
||||
│ ├── trace.py # parse_tracelog
|
||||
│ ├── decode_iso14a.py / decode_iso15.py # per-protocol decoders
|
||||
│ ├── ndef.py # NDEF TLV/record decode
|
||||
│ └── format.py # ANSI color trace formatting
|
||||
├── cli/ # `pm3py` console-script: rawcli (raw-command TUI) + clientcli (`pm3py client`)
|
||||
├── sniff/ # SniffSession — protocol detection, live streaming lifecycle
|
||||
├── sim/ # card-sim infrastructure: SimSession, response-table compiler,
|
||||
│ # Medium/Reader models, MCU bridge, replay/relay/fuzzer
|
||||
└── transponders/ # pure-Python tag models
|
||||
├── hf/iso14443a/ # Tag14443A, MifareClassic, DESFire, NfcType2/4
|
||||
├── hf/iso15693/ # Tag15693, NfcType5, ICODE/SLIX2/DNA/NTAG5
|
||||
└── lf/ # EM4100, HID, T5577
|
||||
```
|
||||
|
||||
- **Transport layer** handles serial I/O, CRC, NG/MIX frame encoding/decoding
|
||||
- **Command modules** send structured commands and parse responses into dicts
|
||||
- **USB connections** skip CRC (matching the C client behavior)
|
||||
- **FPC UART connections** use CRC-16/A with byte-swapped wire format
|
||||
- **`core/`** is the wire stack: `transport.py` handles serial I/O and NG/MIX frame
|
||||
encode/decode; command modules send structured commands and parse responses into
|
||||
dicts. USB connections skip CRC (matching the C client); FPC UART uses CRC-16/A with
|
||||
byte-swapped wire encoding.
|
||||
- **`trace/`** parses the firmware trace log and annotates it (per-protocol decoders +
|
||||
NDEF), used by both sniff and sim.
|
||||
- **`sniff/`** orchestrates protocol detection and the live `CMD_HF_SNIFF_STREAM`
|
||||
streaming loop.
|
||||
- **`sim/`** + **`transponders/`** implement software-defined tags served from a
|
||||
firmware BigBuf response table, with a WTX relay for real-time Layer-4 crypto.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -182,4 +718,8 @@ cd pm3py
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
All tests use mock transports — no hardware required.
|
||||
All tests use mock transports — **no hardware required**.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
780
docs/NTAG5_SECURITY.md
Normal file
780
docs/NTAG5_SECURITY.md
Normal file
@@ -0,0 +1,780 @@
|
||||
# NTAG5 Link (NTP5332) Security Configuration Reference
|
||||
|
||||
This document captures key technical details for implementing ISO 29167-10 AES-128 authentication on NTAG5 Link chips.
|
||||
|
||||
## Configuration Memory Map
|
||||
|
||||
### Device Security Configuration (DEV_SEC_CONFIG)
|
||||
|
||||
**Address:** Block 0x3F (NFC) / 0x103F (I2C)
|
||||
|
||||
| Byte | Name | Description |
|
||||
| ---- | --------------- | --------------------------- |
|
||||
| 0 | DEV_SEC_CONFIG | Security mode and lock bits |
|
||||
| 1 | SRAM_CONF_PROT | SRAM and config protection |
|
||||
| 2 | PP_AREA_1 (LSB) | Protection pointer area 1 |
|
||||
| 3 | PP_AREA_1 (MSB) | Protection pointer area 1 |
|
||||
|
||||
**DEV_SEC_CONFIG Bit Definition:**
|
||||
|
||||
| Bits | Name | Value | Description |
|
||||
| ---- | ------------- | ----- | ------------------------ |
|
||||
| 7-5 | Security Lock | 010b | Locked (cannot modify) |
|
||||
| | | 101b | Writable (default) |
|
||||
| 4 | RFU | 00b | Reserved |
|
||||
| 3-0 | NFC_SEC_MODE | 0010b | AES mode (NTP5332 only) |
|
||||
| | | 0101b | Plain password (default) |
|
||||
|
||||
**Example Values:**
|
||||
- `0xA5` = Writable + Plain password mode (factory default)
|
||||
- `0xA2` = Writable + AES mode
|
||||
|
||||
### NFC Global Crypto Header (NFC_GCH)
|
||||
|
||||
**Address:** Block 0x0C (NFC) / 0x100C (I2C), **Byte 1** (other bytes are RFU)
|
||||
|
||||
**What it controls:** NFC_GCH defines the status/access enforcement for passwords (in password mode), keys, protection pointer & conditions, key headers, key privileges, crypto configuration header, and EAS/AFI protection.
|
||||
|
||||
**IMPORTANT:** NFC_GCH is **one-way programmable** (lower → higher values only) and **irreversible**.
|
||||
|
||||
**Allowed Values (AES Mode):** All other values are invalid.
|
||||
|
||||
| Value | Status | Description |
|
||||
| ----- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0x81 | Deactivated (default) | Protection pointer/conditions not activated; user memory R/W possible without prior mutual auth; PP/PPC can be modified via PROTECT PAGE; keys & privileges are readable/writable via READ/WRITE CONFIG according to each key's KH status; EAS/AFI protection not activated |
|
||||
| 0x87 | Deactivated & privileges locked | Same as 0x81, but Key Privileges are locked (cannot be modified). EAS/AFI still not protected |
|
||||
| 0xC1 | Access right activated | Protection pointer/conditions enabled; user-memory R/W protection enforced per configuration; keys/privileges still readable/writable according to KH status; EAS/AFI protection enabled |
|
||||
| 0xC7 | Access right activated & privileges locked | Same as 0xC1, but Key Privileges are locked |
|
||||
| 0xE7 | Activated (final) | Protection enabled; all Key Headers/Privileges/Keys are locked (cannot be modified); EAS/AFI protection enabled |
|
||||
|
||||
**Allowed Values (Plain Password Mode):**
|
||||
|
||||
| Value | Status | Description |
|
||||
| ----- | ------------------ | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0x81 | Writable (default) | Passwords readable/writable via READ/WRITE CONFIG |
|
||||
| 0xE7 | Locked | Passwords not readable/writable via READ/WRITE CONFIG (note: separate LOCK PASSWORD needed to lock passwords permanently) |
|
||||
|
||||
### NFC Crypto Configuration Header (NFC_CCH)
|
||||
|
||||
**Address:** Block 0x0D (NFC) / 0x100D (I2C), **Byte 1** (other bytes are RFU)
|
||||
|
||||
**What it controls:** NFC_CCH controls whether the NFC Authentication Limit can be changed freely or only after authentication. This is the "global knob" for Authentication Limit behavior (not general access-right enforcement).
|
||||
|
||||
**Allowed Values:** All other values are invalid.
|
||||
|
||||
| Value | Status | Description |
|
||||
| ----- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 0x81 | Unlocked (default) | Authentication limit can be modified freely |
|
||||
| 0xE7 | Locked | Authentication limit can only be modified after mutual auth with a key that has the Crypto Config privilege (bit 5 of NFC_KPx) |
|
||||
|
||||
### NFC Key Headers (NFC_KHx)
|
||||
|
||||
**Addresses:** Key headers live in blocks 0x10/0x12/0x14/0x16, specifically in **Byte 1** of each block (other bytes are RFU).
|
||||
|
||||
| Key | Block (NFC) | Block (I2C) | Byte Position |
|
||||
| ------- | ----------- | ----------- | ------------- |
|
||||
| NFC_KH0 | 0x10 | 0x1010 | Byte 1 |
|
||||
| NFC_KH1 | 0x12 | 0x1012 | Byte 1 |
|
||||
| NFC_KH2 | 0x14 | 0x1014 | Byte 1 |
|
||||
| NFC_KH3 | 0x16 | 0x1016 | Byte 1 |
|
||||
|
||||
**Key Header Values:**
|
||||
|
||||
| Value | Status | Description |
|
||||
| ----- | -------------------- | ------------------------------------------------------------- |
|
||||
| 0x81 | Not active (default) | Key can be read/written but CANNOT be used for authentication |
|
||||
| 0xE7 | Active and locked | Key is active and locked - can be used for authentication |
|
||||
| 0xFF | Disabled | Key slot is disabled (NXP recommends disabling unused keys) |
|
||||
|
||||
**IMPORTANT:**
|
||||
- Keys with header 0x81 cannot be used for authentication
|
||||
- The header must be set to 0xE7 to activate the key
|
||||
- Once 0xE7 is set, the key value cannot be modified
|
||||
- Key headers are **one-way** (lower→higher values only) and **irreversible**
|
||||
|
||||
### NFC Key Privileges (NFC_KPx)
|
||||
|
||||
**Addresses:** Key privileges live in blocks 0x11/0x13/0x15/0x17, specifically in **Byte 0** of each block (other bytes are RFU).
|
||||
|
||||
| Key | Block (NFC) | Block (I2C) | Byte Position |
|
||||
| ------- | ----------- | ----------- | ------------- |
|
||||
| NFC_KP0 | 0x11 | 0x1011 | Byte 0 |
|
||||
| NFC_KP1 | 0x13 | 0x1013 | Byte 0 |
|
||||
| NFC_KP2 | 0x15 | 0x1015 | Byte 0 |
|
||||
| NFC_KP3 | 0x17 | 0x1017 | Byte 0 |
|
||||
|
||||
**NFC_KPx Bit Meanings (AES mode):**
|
||||
|
||||
Each NFC_KPx is a 1-byte bitmask. Bit = 1 grants that capability after successful mutual authentication using KeyID = x.
|
||||
|
||||
| Bit | Name | Privilege Granted |
|
||||
| --- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 7 | Restricted AREA_1 Write | Write access to restricted user memory AREA_1 |
|
||||
| 6 | Restricted AREA_1 Read | Read access to restricted user memory AREA_1 |
|
||||
| 5 | Crypto Config | Preset of Authentication Limit |
|
||||
| 4 | EAS/AFI | Access to write-alike EAS/AFI commands: PROTECT EAS/AFI, SET EAS, RESET EAS, LOCK EAS, WRITE EAS ID, WRITE AFI, LOCK AFI |
|
||||
| 3 | Destroy | Access to DESTROY functionality |
|
||||
| 2 | Privacy | Enable/disable PRIVACY mode |
|
||||
| 1 | Write | Write access to the protected user memory area |
|
||||
| 0 | Read | Read access to the protected user memory area |
|
||||
|
||||
**Common Privilege Values:**
|
||||
- `0x03` = Read + Write access to protected memory
|
||||
- `0x07` = Read + Write + Privacy control
|
||||
- `0xFF` = All privileges enabled
|
||||
|
||||
### AES Key Storage
|
||||
|
||||
**Key Addresses (per NTP53x2 datasheet page 16):**
|
||||
| Key | Blocks (NFC) | Blocks (I2C) | Size |
|
||||
| ---- | ------------ | ------------- | ------------------- |
|
||||
| KEY0 | 0x20-0x23 | 0x1020-0x1023 | 16 bytes (4 blocks) |
|
||||
| KEY1 | 0x24-0x27 | 0x1024-0x1027 | 16 bytes |
|
||||
| KEY2 | 0x28-0x2B | 0x1028-0x102B | 16 bytes |
|
||||
| KEY3 | 0x2C-0x2F | 0x102C-0x102F | 16 bytes |
|
||||
|
||||
**NOTE:** Addresses 0x1E-0x1F are RFU (reserved), NOT key storage!
|
||||
|
||||
**Default Key Value:** Unknown - likely not all zeros. Factory default may vary.
|
||||
|
||||
**Key Write Requirements:**
|
||||
- Must enable AES mode (DEV_SEC_CONFIG = 0xA2) before writing keys
|
||||
- Keys can be written and read back while header is 0x81 (not active)
|
||||
- Once a key header is set to 0xE7, the key storage becomes unreadable
|
||||
- Always verify key values by reading back before activating headers
|
||||
|
||||
## Safe Programming Order
|
||||
|
||||
This order prevents locking yourself out when provisioning AES mode on an NTP5332.
|
||||
|
||||
### Step 1: Keep NFC_GCH at 0x81 (Deactivated)
|
||||
|
||||
While NFC_GCH = 0x81:
|
||||
- Protection settings aren't enforced yet
|
||||
- Protection pointer/conditions can be modified via PROTECT PAGE without prior mutual auth
|
||||
- Keys and privileges are freely readable/writable
|
||||
|
||||
**WARNING:** NFC_GCH only programs one-way (lower→higher) and is irreversible. Don't jump to C1/C7/E7 until you've tested.
|
||||
|
||||
### Step 2: Set Up Memory Protection Layout
|
||||
|
||||
Configure these while GCH is still 0x81:
|
||||
- Protection Pointer Address
|
||||
- Protection Pointer Condition
|
||||
- Any EAS/AFI protection conditions you plan to use
|
||||
|
||||
### Step 3: Write Key Privileges and Keys
|
||||
|
||||
While each NFC_KHx is still 0x81 (Not active):
|
||||
- Write NFC_KPx (Key Privileges) to define what each key can authorize
|
||||
- Write the 128-bit key values to storage blocks (0x20-0x2F)
|
||||
- Key and privileges are readable/writable via READ/WRITE CONFIG
|
||||
- Key is not active for authentication yet
|
||||
|
||||
**Key Storage Addresses (per datasheet page 16):**
|
||||
- KEY0: blocks 0x20-0x23
|
||||
- KEY1: blocks 0x24-0x27
|
||||
- KEY2: blocks 0x28-0x2B
|
||||
- KEY3: blocks 0x2C-0x2F
|
||||
|
||||
### Step 4: Activate/Lock Only the Keys You Need
|
||||
|
||||
For each key you intend to use:
|
||||
- Set NFC_KHx → 0xE7 (Active and locked)
|
||||
- Key becomes usable for authentication
|
||||
- Key material can no longer be read/written via READ/WRITE CONFIG
|
||||
|
||||
For unused keys (NXP recommendation):
|
||||
- Set NFC_KHx → 0xFF (Disabled)
|
||||
- Prevents unused key slots from being exploited
|
||||
|
||||
**WARNING:** Key headers are one-way (lower→higher) and irreversible.
|
||||
|
||||
### Step 5: Enable Enforcement
|
||||
|
||||
Set NFC_GCH → 0xC1 (Access right activated):
|
||||
- Read/write protection becomes enforced according to your settings
|
||||
- EAS/AFI protection becomes enforced
|
||||
- Keys can still be modified if their headers are still 0x81
|
||||
|
||||
### Step 6: Test Everything
|
||||
|
||||
Verify your protection configuration:
|
||||
- Try protected reads/writes WITHOUT mutual auth (should be denied)
|
||||
- Mutual-auth with each KeyID and confirm only the privileges in that key's KPx work
|
||||
- Test all access patterns you need
|
||||
|
||||
### Step 7: (Optional) Freeze Configuration
|
||||
|
||||
Only after thorough testing:
|
||||
- NFC_GCH → 0xC7: Locks privileges, keeps enforcement, keys still modifiable per their KH status
|
||||
- NFC_GCH → 0xE7: "Finalize" state - enforcement on, all key headers/privileges/keys locked forever
|
||||
|
||||
### Step 8: (Optional) Lock Authentication Limit
|
||||
|
||||
Only when you're sure about auth-limit behavior:
|
||||
- Set NFC_CCH → 0xE7 to lock the authentication-limit setting
|
||||
- After this, auth limit can only be modified with a key that has Crypto Config privilege (bit 5)
|
||||
|
||||
### Footgun to Avoid
|
||||
|
||||
**Authentication Limit:** If you enable the Authentication Limit feature (block 0x0E), hitting the terminal failure count causes **permanent, irreversible lockout** of that key slot. Set a reasonable limit or leave at 0 (unlimited) during development.
|
||||
|
||||
## Legacy Key Provisioning (Simple Version)
|
||||
|
||||
For simple use cases (single key, no protection pointer):
|
||||
|
||||
1. **Ensure AES Mode is Enabled**
|
||||
- Read DEV_SEC_CONFIG at 0x3F
|
||||
- If bits 3-0 != 0010b, write new value with AES mode enabled
|
||||
- Power cycle the chip (remove and replace on reader)
|
||||
|
||||
2. **Write Key Value**
|
||||
- Write 16 bytes to KEY0 blocks 0x20-0x23
|
||||
- Key can only be written when NFC_KH0 is 0x81
|
||||
|
||||
3. **Activate Key Header**
|
||||
- Write 0xE7 to block 0x10 byte 1
|
||||
- This is IRREVERSIBLE - key is locked after this
|
||||
|
||||
4. **(Optional) Activate NFC_GCH**
|
||||
- Write 0xC1 or 0xE7 to enable protection pointer enforcement
|
||||
- 0xE7 locks all crypto configuration permanently
|
||||
|
||||
## Authentication Protocol
|
||||
|
||||
### COMPLETE MAM AUTHENTICATION (VERIFIED WORKING 2026-02-13)
|
||||
|
||||
**NTAG5 Link uses UNDOCUMENTED AuthMethod values for MAM:**
|
||||
| Value | Method | Result |
|
||||
| ----- | --------------- | -------------------------------------------------- |
|
||||
| 0x00 | TAM1 (Tag Auth) | SUCCESS - 18 byte response |
|
||||
| 0x02 | **MAM1** | SUCCESS - 24 byte response |
|
||||
| 0x06 | **MAM2** | SUCCESS - empty response (authentication complete) |
|
||||
| 0x80 | MAM1 (per ISO) | Error 0x0F (NOT SUPPORTED) |
|
||||
| 0x90 | MAM2 (per ISO) | Error 0x0F (NOT SUPPORTED) |
|
||||
|
||||
**CRITICAL BYTE ORDERING RULES:**
|
||||
All data to/from the chip must be byte-reversed:
|
||||
- IChallenge sent to chip: **REVERSED**
|
||||
- TChallenge_high from response: **REVERSED**
|
||||
- IResponse sent to chip: **REVERSED**
|
||||
|
||||
### MAM1 (AuthMethod = 0x02)
|
||||
|
||||
**Command Format:**
|
||||
```
|
||||
FLAGS(0x22) | CMD(0x35) | UID(8) | CSI(0x00) | AuthMethod(0x02) | KeyID | IChallenge[::-1]
|
||||
```
|
||||
- IChallenge: 10 bytes, must be **REVERSED** before sending
|
||||
|
||||
**Response Structure (24 bytes):**
|
||||
| Offset | Length | Content |
|
||||
| ------ | ------ | ---------------------------------------------- |
|
||||
| 0 | 1 | Flags: 0x04 |
|
||||
| 1 | 1 | Header: 0xA7 |
|
||||
| 2-7 | 6 | TChallenge[79:32] (plaintext, needs reversal!) |
|
||||
| 8-23 | 16 | Encrypted block |
|
||||
|
||||
**Decryption Procedure:**
|
||||
1. Extract TChallenge_high_raw = bytes 2-7 (6 bytes)
|
||||
2. Extract encrypted block = bytes 8-23 (16 bytes)
|
||||
3. **REVERSE** encrypted block, then AES-ECB decrypt
|
||||
4. Parse decrypted: `C_MAM1(2) || TChallenge[31:0](4) || IChallenge(10)`
|
||||
5. **REVERSE** TChallenge_high_raw to get actual TChallenge_high
|
||||
6. TChallenge[79:0] = TChallenge_high + TChallenge_low
|
||||
|
||||
**Verified Constants:**
|
||||
- C_MAM1 = 0xDA83
|
||||
- IChallenge in decrypted = original IChallenge (chip de-reverses what it receives)
|
||||
|
||||
### MAM2 (AuthMethod = 0x06)
|
||||
|
||||
**Command Format (NO KeyID!):**
|
||||
```
|
||||
FLAGS(0x22) | CMD(0x35) | UID(8) | CSI(0x00) | AuthMethod(0x06) | IResponse[::-1]
|
||||
```
|
||||
- IResponse: 16 bytes, must be **REVERSED** before sending
|
||||
|
||||
**IResponse Computation:**
|
||||
```python
|
||||
# Build plaintext (16 bytes)
|
||||
C_MAM2_PURPOSE = bytes([0xDA, 0x80]) # C_MAM2[11:0] || Purpose[3:0]
|
||||
IChallenge_31_0 = original_ichallenge[6:10] # Last 4 bytes of ORIGINAL
|
||||
TChallenge_79_0 = tc_high_reversed + tc_low # From MAM1 response
|
||||
|
||||
plaintext = C_MAM2_PURPOSE + IChallenge_31_0 + TChallenge_79_0
|
||||
|
||||
# Compute IResponse
|
||||
iresponse_to_send = AES_DEC(key, plaintext)[::-1] # Decrypt then REVERSE
|
||||
```
|
||||
|
||||
**Expected Response:** Empty (just flags/header, no data) = SUCCESS
|
||||
|
||||
### Complete MAM Example (Python)
|
||||
|
||||
```python
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
def perform_mam(hcard, proto, uid_lsb, key, key_id, ichallenge):
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
|
||||
# MAM1: Send IChallenge REVERSED
|
||||
mam1_cmd = bytes([0x22, 0x35]) + uid_lsb + bytes([0x00, 0x02, key_id]) + ichallenge[::-1]
|
||||
mam1_resp = send_command(hcard, proto, mam1_cmd)
|
||||
|
||||
# Parse MAM1 response
|
||||
tc_high_raw = mam1_resp[2:8] # Needs reversal!
|
||||
encrypted = mam1_resp[8:24]
|
||||
|
||||
decrypted = cipher.decrypt(encrypted[::-1])
|
||||
assert decrypted[0:2] == bytes([0xDA, 0x83]) # C_MAM1
|
||||
|
||||
tc_high = tc_high_raw[::-1] # REVERSE!
|
||||
tc_low = decrypted[2:6]
|
||||
tc_full = tc_high + tc_low
|
||||
|
||||
# MAM2: Build IResponse
|
||||
C_MAM2_PURPOSE = bytes([0xDA, 0x80])
|
||||
ich_31_0 = ichallenge[6:10] # Original, not reversed
|
||||
plaintext = C_MAM2_PURPOSE + ich_31_0 + tc_full
|
||||
iresponse = cipher.decrypt(plaintext)[::-1] # Decrypt then REVERSE
|
||||
|
||||
# MAM2: No KeyID in command!
|
||||
mam2_cmd = bytes([0x22, 0x35]) + uid_lsb + bytes([0x00, 0x06]) + iresponse
|
||||
mam2_resp = send_command(hcard, proto, mam2_cmd)
|
||||
|
||||
return mam2_resp is empty or success
|
||||
```
|
||||
|
||||
**Verified Working (2026-02-13):** All 4 key slots (KEY0-KEY3) authenticate successfully using this protocol.
|
||||
|
||||
**Standard MAM1/MAM2 (0x80/0x90) fail** with Error 0x0F despite the chip's GET NXP SYSTEM INFORMATION reporting MUTUAL AUTH = 1. Use AuthMethod 0x02/0x06 instead.
|
||||
|
||||
---
|
||||
|
||||
### ISO 29167-10 Standard MAM (AuthMethod 0x80/0x90) - NOT SUPPORTED
|
||||
|
||||
**NOTE:** The standard ISO 29167-10 AuthMethod values (0x80 for MAM1, 0x90 for MAM2) are **NOT supported** by NTAG5 Link hardware, despite documentation suggesting otherwise.
|
||||
|
||||
Use the undocumented AuthMethod values instead:
|
||||
- **AuthMethod 0x02** for MAM1
|
||||
- **AuthMethod 0x06** for MAM2 (no KeyID in command)
|
||||
|
||||
See "Complete MAM Authentication" section above for the working protocol.
|
||||
|
||||
## TAM1 Authentication (Tag Authentication Method 1)
|
||||
|
||||
TAM1 is a one-way authentication where the reader verifies the tag's identity. It uses the CHALLENGE (0x39) and READBUFFER (0x3A) commands instead of the full mutual authentication.
|
||||
|
||||
### TAM1 Protocol Flow
|
||||
|
||||
1. **Reader sends CHALLENGE** - Contains KeyID and random IChallenge
|
||||
2. **Tag computes AES encryption** - No response sent
|
||||
3. **Reader sends READBUFFER** - Requests the crypto result
|
||||
4. **Tag returns TResponse** - 16-byte encrypted block
|
||||
5. **Reader decrypts and verifies** - Checks C_TAM1 constant and IChallenge
|
||||
|
||||
### TAM1 Message Format
|
||||
|
||||
**CHALLENGE Command (0x39):**
|
||||
```
|
||||
Flags | 0x39 | CSI | AuthMethod/RFU | KeyID | IChallenge (10 bytes)
|
||||
```
|
||||
- Flags: 0x02 (high data rate, NOT addressed)
|
||||
- CSI: 0x00 (AES-128 Crypto Suite Identifier)
|
||||
- AuthMethod/RFU: 0x00 (TAM1 = 00b, CustomData = 0, RFU = 00000b)
|
||||
- KeyID: 0x00-0x03 (KEY0-KEY3)
|
||||
- IChallenge: 10 bytes random challenge
|
||||
|
||||
**READBUFFER Command (0x3A):**
|
||||
```
|
||||
Flags | 0x3A
|
||||
```
|
||||
- Flags: 0x02 (high data rate, NOT addressed)
|
||||
|
||||
**TResponse Format (16 bytes):**
|
||||
```
|
||||
AES-ECB-ENC(Key, C_TAM1 || TRnd || IChallenge)
|
||||
```
|
||||
- C_TAM1: 0x96C5 (2 bytes) - constant per ISO 29167-10
|
||||
- TRnd: 4 bytes random from tag
|
||||
- IChallenge: 10 bytes echoed from CHALLENGE command
|
||||
|
||||
### TAM1 Verification
|
||||
|
||||
To verify TAM1:
|
||||
1. **Reverse the TResponse byte order** (ACR1552 returns data in LSB-first order)
|
||||
2. Decrypt TResponse with the expected key using AES-128-ECB
|
||||
3. Check bytes 0-1 equal 0x96C5 (C_TAM1 constant)
|
||||
4. **Reverse the echoed IChallenge** (bytes 6-15) before comparing
|
||||
5. Check reversed bytes 6-15 equal the IChallenge you sent
|
||||
6. If both match, the tag is authentic
|
||||
|
||||
**CRITICAL: ACR1552 Byte Ordering**
|
||||
|
||||
The ACR1552 returns TResponse in reversed byte order due to ISO 15693's LSB-first transmission.
|
||||
Both the entire TResponse and the echoed IChallenge within the decrypted data must be reversed.
|
||||
|
||||
```python
|
||||
# Example verification (Python)
|
||||
tresponse_raw = resp[1:17] # Skip flags byte
|
||||
tresponse = tresponse_raw[::-1] # Reverse entire TResponse
|
||||
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(tresponse)
|
||||
|
||||
c_tam1 = decrypted[0:2] # Should be 96C5
|
||||
trnd = decrypted[2:6] # 4 bytes random from tag
|
||||
echoed_raw = decrypted[6:16] # Echoed IChallenge (reversed)
|
||||
echoed = echoed_raw[::-1] # Reverse to get original order
|
||||
|
||||
if c_tam1 == bytes([0x96, 0xC5]) and echoed == ichallenge:
|
||||
# Authentication successful
|
||||
```
|
||||
|
||||
### ACR1552 Complete APDU Format for TAM1
|
||||
|
||||
The ACR1552 uses pseudo-APDUs with TLV wrapping for transparent mode communication.
|
||||
|
||||
**PCSC APDU Structure:**
|
||||
```
|
||||
FF C2 00 01 Lc [TLV Data] 00
|
||||
```
|
||||
- CLA: 0xFF (pseudo-APDU)
|
||||
- INS: 0xC2 (transparent exchange)
|
||||
- P1: 0x00
|
||||
- P2: 0x01 (exchange function)
|
||||
- Lc: Length of TLV data
|
||||
- Le: 0x00
|
||||
|
||||
**TLV Data Structure:**
|
||||
```
|
||||
5F46 04 [timeout_4bytes] Timeout in microseconds (big-endian)
|
||||
FF6E 03 03 01 0F FWTI (Frame Waiting Time Integer)
|
||||
95 xx [iso15693_command] ISO 15693 command payload
|
||||
```
|
||||
|
||||
**Response TLV Structure:**
|
||||
```
|
||||
C0 03 [status_3bytes] Error/status TLV (SW1 SW2 at bytes 1-2)
|
||||
92 01 xx Framing TLV
|
||||
96 02 xx xx Status TLV
|
||||
97 xx [iso15693_response] Response data TLV (includes flags byte)
|
||||
```
|
||||
- SW = 0x9000: Success
|
||||
- SW = 0x6401: Execution error (no response from ICC)
|
||||
|
||||
### Complete APDU Examples (Working TAM1 Flow)
|
||||
|
||||
**Example 1: GET SYSTEM INFO (required before CHALLENGE)**
|
||||
```
|
||||
TX: ffc20001115f4604000f4240ff6e0303010f9502022b00
|
||||
├─ FF C2 00 01 11 PCSC header (Lc=17)
|
||||
├─ 5F46 04 000F4240 Timeout = 1,000,000 µs (1 second)
|
||||
├─ FF6E 03 030100F FWTI
|
||||
├─ 95 02 022B ISO15693: Flags=02, Cmd=2B (GET SYSTEM INFO)
|
||||
└─ 00 Le
|
||||
|
||||
RX: c00300900092010096020000970f000f02e88c59580104e00000ff0301
|
||||
├─ C0 03 009000 Status OK (SW=9000)
|
||||
├─ 92 01 00 Framing OK
|
||||
├─ 96 02 0000 Status OK
|
||||
└─ 97 0F 00... Response: Flags=00 + System Info (14 bytes)
|
||||
```
|
||||
|
||||
**Example 2: CHALLENGE (KeyID=3, IChallenge=33ccc451ad4d9a6e9cd6)**
|
||||
```
|
||||
TX: ffc200011e5f4604000f4240ff6e0303010f950f023900000333ccc451ad4d9a6e9cd600
|
||||
├─ FF C2 00 01 1E PCSC header (Lc=30)
|
||||
├─ 5F46 04 000F4240 Timeout = 1 second
|
||||
├─ FF6E 03 03010F FWTI
|
||||
├─ 95 0F 02 39 00 00 03 33ccc451ad4d9a6e9cd6
|
||||
│ ├─ 02 Flags (high data rate)
|
||||
│ ├─ 39 CHALLENGE command
|
||||
│ ├─ 00 CSI (AES-128)
|
||||
│ ├─ 00 AuthMethod=TAM1
|
||||
│ ├─ 03 KeyID=3
|
||||
│ └─ 33ccc451ad4d9a6e9cd6 IChallenge (10 bytes)
|
||||
└─ 00 Le
|
||||
|
||||
RX: c003036401
|
||||
└─ C0 03 036401 Status: SW=6401 (no response from ICC)
|
||||
This is EXPECTED - CHALLENGE has no response
|
||||
```
|
||||
|
||||
**Example 3: READBUFFER**
|
||||
```
|
||||
TX: ffc20001115f4604000f4240ff6e0303010f9502023a00
|
||||
├─ FF C2 00 01 11 PCSC header (Lc=17)
|
||||
├─ 5F46 04 000F4240 Timeout = 1 second
|
||||
├─ FF6E 03 03010F FWTI
|
||||
├─ 95 02 02 3A ISO15693: Flags=02, Cmd=3A (READBUFFER)
|
||||
└─ 00 Le
|
||||
|
||||
RX: c00300900092010096020000971102238333363a3142b26328d1bf618362e4
|
||||
├─ C0 03 009000 Status OK
|
||||
├─ 92 01 00 Framing OK
|
||||
├─ 96 02 0000 Status OK
|
||||
└─ 97 11 02 238333363a3142b26328d1bf618362e4
|
||||
├─ 02 Flags (success, no error)
|
||||
└─ 238333...e4 TResponse (16 bytes)
|
||||
```
|
||||
|
||||
### Critical Timing Requirement
|
||||
|
||||
**IMPORTANT:** The chip requires time to compute the AES encryption after receiving CHALLENGE.
|
||||
|
||||
| Scenario | READBUFFER Result |
|
||||
| ----------------------------------------------------- | ------------------------- |
|
||||
| READBUFFER sent immediately after CHALLENGE | Often fails (no response) |
|
||||
| GET SYSTEM INFO sent between CHALLENGE and READBUFFER | Works (implicit delay) |
|
||||
| 1 second delay between CHALLENGE and READBUFFER | Works reliably |
|
||||
|
||||
**Recommended approach:** Either:
|
||||
1. Send any command (e.g., GET SYSTEM INFO) after CHALLENGE before READBUFFER, OR
|
||||
2. Add a minimum 50-100ms delay after CHALLENGE before sending READBUFFER
|
||||
|
||||
The crypto calculation typically takes ~4-8ms, but reader/driver overhead can cause timing issues if READBUFFER is sent too quickly.
|
||||
|
||||
### READBUFFER Failure Example (Insufficient Delay)
|
||||
|
||||
When CHALLENGE is sent immediately after connection with no prior commands and READBUFFER follows too quickly:
|
||||
|
||||
```
|
||||
TX: ffc200011e5f4604000f4240ff6e0303010f950f0239000003[IChallenge]00
|
||||
RX: c003036401 (SW=6401, expected - no response for CHALLENGE)
|
||||
|
||||
TX: ffc20001115f4604000f4240ff6e0303010f9502023a00
|
||||
RX: c003036401 (SW=6401, FAILURE - chip not ready)
|
||||
```
|
||||
|
||||
The second `c003036401` response indicates "no response from ICC" - the chip hasn't finished computing the crypto result yet. Adding a delay or sending an intermediate command resolves this.
|
||||
|
||||
### TAM1 vs State Machine
|
||||
|
||||
Per the datasheet, CHALLENGE can only be executed in READY state with non-addressed mode. However, testing shows:
|
||||
|
||||
- CHALLENGE works even after GET SYSTEM INFO (which puts chip in SELECTED state)
|
||||
- The critical factor is the **timing delay**, not the chip state
|
||||
- Sending GET SYSTEM INFO first provides the necessary delay for crypto completion
|
||||
|
||||
## Reader-Specific Notes
|
||||
|
||||
### ACS ACR1552
|
||||
|
||||
- Uses transparent mode with TLV-wrapped commands
|
||||
- Supports ISO15693 natively
|
||||
- Switch protocol command: `8F 02 02 03` (ISO15693 Layer 3)
|
||||
- Timeout TLV: `5F46` with 4-byte timeout in microseconds
|
||||
- Commands work correctly for basic operations
|
||||
- CHALLENGE returns SW=6401 (no response from ICC) - this is expected
|
||||
- READBUFFER returns SW=9000 with 17-byte response (flags + 16-byte TResponse)
|
||||
- Authentication returns error 0x0F when key is not properly configured
|
||||
|
||||
**CRITICAL: Byte Order Reversal Required**
|
||||
- TResponse data is returned in LSB-first (reversed) byte order
|
||||
- Must reverse entire TResponse before AES decryption
|
||||
- Echoed IChallenge in decrypted data is also reversed
|
||||
- See TAM1 Verification section for correct processing order
|
||||
- AUTHENTICATE (0x35) command returns "no ICC response" - use CHALLENGE/READBUFFER instead
|
||||
|
||||
### HID Omnikey 5022 CL
|
||||
|
||||
- Uses transceive APDU: `FF 68 0E 03`
|
||||
- 10-byte header: TxRxFlags | ValidBits | Timeout(4) | RFU(4)
|
||||
- TxRxFlags: 0x05 for ISO15693 (CRC TX + CRC RX enabled)
|
||||
- Response format: STS(1) | RxB(1) | Card_Response(n) | SW(2)
|
||||
- Direct transceive works WITHOUT entering transparent session first
|
||||
- ISO15693 transparent session may not work reliably - use direct transceive
|
||||
|
||||
**AUTHENTICATE (0x35) for TAM1 - CONFIRMED WORKING EXAMPLE:**
|
||||
|
||||
TAM1 command format (AuthMethod = 0x00):
|
||||
```
|
||||
Flags(22) | Cmd(35) | UID(8) | CSI(00) | AuthMethod(00) | KeyID | IChallenge(10)
|
||||
```
|
||||
|
||||
**Verified Example (2026-02-13):**
|
||||
|
||||
Input parameters:
|
||||
- Chip UID: E0040158F08BE802 (MSB) / 02E88BF0580104E0 (LSB)
|
||||
- KEY0: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
- IChallenge: 112233445566778899AA
|
||||
|
||||
Command sent to reader:
|
||||
```
|
||||
FF680E03210500004C4B4000000000223502E88BF0580104E0000000112233445566778899AA00
|
||||
```
|
||||
|
||||
| Bytes | Value | Meaning |
|
||||
| -------------------- | ---------- | -------------------- |
|
||||
| FF680E03 | Header | Omnikey transceive |
|
||||
| 21 | Lc=33 | Data length |
|
||||
| 05 | TxRxFlags | ISO15693 CRC |
|
||||
| 00 | ValidBits | Whole bytes |
|
||||
| 004C4B40 | Timeout | 5 seconds |
|
||||
| 00000000 | RFU | Reserved |
|
||||
| 22 | Flags | Addressed, high rate |
|
||||
| 35 | Cmd | AUTHENTICATE |
|
||||
| 02E88BF0580104E0 | UID | LSB order |
|
||||
| 00 | CSI | AES-128 |
|
||||
| 00 | AuthMethod | TAM1 |
|
||||
| 00 | KeyID | KEY0 |
|
||||
| 112233445566778899AA | IChallenge | 10 bytes |
|
||||
| 00 | Le | Response length |
|
||||
|
||||
Response received:
|
||||
```
|
||||
000006A7F2E114AFC63A0F6A7170A4F1692DD29F9000
|
||||
```
|
||||
|
||||
| Bytes | Value | Meaning |
|
||||
| -------------------------------- | --------- | ------------------ |
|
||||
| 0000 | STS+RxB | Transceive OK |
|
||||
| 06 | Flags | Success |
|
||||
| A7 | Header | Barker code (skip) |
|
||||
| F2E114AFC63A0F6A7170A4F1692DD29F | TResponse | 16 bytes encrypted |
|
||||
| 9000 | SW | Success |
|
||||
|
||||
**Decryption process:**
|
||||
|
||||
1. Extract TResponse: `F2E114AFC63A0F6A7170A4F1692DD29F`
|
||||
2. REVERSE bytes: `9FD22D69F1A470716A0F3AC6AF14E1F2`
|
||||
3. AES-ECB decrypt with KEY0: `96C51B6F50BBAA998877665544332211`
|
||||
4. Parse result:
|
||||
- C_TAM1: `96C5` (correct constant)
|
||||
- TRnd: `1B6F50BB` (tag random)
|
||||
- Echoed: `AA998877665544332211` (IChallenge reversed)
|
||||
|
||||
**VERIFICATION: SUCCESS** - C_TAM1=96C5, IChallenge matches (reversed)
|
||||
|
||||
```python
|
||||
# Omnikey 5022 TAM1 verification (AuthMethod=0x00)
|
||||
card_resp = response[2:-2] # Strip STS, RxB, and SW
|
||||
data_after_flags = card_resp[1:] # Skip flags byte (0x06)
|
||||
tresponse = data_after_flags[1:17] # Skip header byte (0xA7), take 16 bytes
|
||||
tresponse = tresponse[::-1] # Reverse byte order
|
||||
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(tresponse)
|
||||
|
||||
c_tam1 = decrypted[0:2] # Should be 96C5
|
||||
trnd = decrypted[2:6] # 4-byte tag random
|
||||
echoed = decrypted[6:16] # REVERSED - compare against ichallenge[::-1]
|
||||
|
||||
if c_tam1 == bytes([0x96, 0xC5]) and echoed == ichallenge[::-1]:
|
||||
# TAM1 authentication successful
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
### ISO15693 Error Codes (from flag byte with error bit set)
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ---------------------- |
|
||||
| 0x01 | Command not supported |
|
||||
| 0x02 | Command not recognized |
|
||||
| 0x03 | Option not supported |
|
||||
| 0x0F | Unknown error |
|
||||
| 0x10 | Block not available |
|
||||
| 0x11 | Block already locked |
|
||||
| 0x12 | Block locked |
|
||||
| 0x13 | Programming failed |
|
||||
| 0x14 | Locking failed |
|
||||
|
||||
**Error 0x0F during authentication typically means:**
|
||||
- Key header not activated (still 0x81 instead of 0xE7)
|
||||
- Wrong key slot used
|
||||
- Chip not in AES mode
|
||||
- Malformed MAM1 message
|
||||
|
||||
## READ CONFIG Command Requirements
|
||||
|
||||
**IMPORTANT:** READ CONFIG (0xC0) must use **unaddressed mode** (flags=0x02).
|
||||
|
||||
**Correct Format:**
|
||||
```
|
||||
Flags | Cmd | MfgCode | BlockAddr | NumBlocks
|
||||
0x02 | 0xC0 | 0x04 | addr | 0x00
|
||||
```
|
||||
|
||||
Example: `bytes([0x02, 0xC0, 0x04, 0x3F, 0x00])` to read DEV_SEC_CONFIG
|
||||
|
||||
**Wrong Format (does not work):**
|
||||
```
|
||||
Flags | Cmd | MfgCode | UID (8 bytes) | BlockAddr
|
||||
0x22 | 0xC0 | 0x04 | uid_lsb | addr
|
||||
```
|
||||
|
||||
Using addressed mode (flags=0x22 with UID) returns no response from the chip.
|
||||
|
||||
## Common Issues
|
||||
|
||||
1. **"Unknown error" (0x0F) on AUTHENTICATE**
|
||||
- Check DEV_SEC_CONFIG is in AES mode (bits 3-0 = 0010b)
|
||||
- Check Key Header is 0xE7 (active and locked)
|
||||
- Power cycle chip after mode changes
|
||||
|
||||
2. **Key Header reads as 0x00**
|
||||
- Incorrect address used - Key headers are at 0x10, 0x12, 0x14, 0x16
|
||||
- Block 0x0E is NFC_AUTH_LIMIT, not KEY0 header
|
||||
|
||||
3. **Cannot write key after activation**
|
||||
- Once key header is 0xE7, key is permanently locked
|
||||
- Must set key value BEFORE activating header
|
||||
|
||||
4. **Mode change doesn't take effect**
|
||||
- Configuration changes require power cycle (POR)
|
||||
- Remove card from reader field and place again
|
||||
|
||||
5. **Keys must be written in AES mode**
|
||||
- Enable AES mode (DEV_SEC_CONFIG = 0xA2) before writing key values
|
||||
- Power cycle after enabling AES mode, then write keys
|
||||
- Keys written while in password mode may not work correctly for authentication
|
||||
|
||||
6. **TAM1/MAM1 authentication fails despite correct key**
|
||||
- Key values verified by read-back but authentication still fails
|
||||
- This is a known issue under investigation with NXP (open support ticket)
|
||||
- Ensure chip is in AES mode before writing keys
|
||||
|
||||
## Troubleshooting Checklist
|
||||
|
||||
Before attempting authentication:
|
||||
|
||||
1. **Verify AES Mode:**
|
||||
```
|
||||
Read config block 0x3F
|
||||
DEV_SEC_CONFIG byte 0, bits 3-0 should be 0010b
|
||||
Example: 0xA2 = AES mode enabled, writable
|
||||
```
|
||||
|
||||
2. **Verify Key Header is Active:**
|
||||
```
|
||||
Read config block 0x10 (KEY0), 0x12 (KEY1), 0x14 (KEY2), or 0x16 (KEY3)
|
||||
Byte 1 should be 0xE7 for the key you want to use
|
||||
0x81 = not active (cannot authenticate)
|
||||
```
|
||||
|
||||
3. **Verify Key Value is Known:**
|
||||
```
|
||||
If key header was 0x81 before you configured it:
|
||||
- Was AES mode enabled BEFORE writing key values?
|
||||
- Did read-back verification match what you wrote?
|
||||
- After activating header to 0xE7, key blocks should return errors (protected)
|
||||
```
|
||||
|
||||
4. **Check Auth Failure Counter:**
|
||||
```
|
||||
Read config block 0x0E
|
||||
NFC_AUTH_LIMIT (bits 9-0) sets max failures before lockout
|
||||
If counter reaches limit, key slot is permanently blocked
|
||||
```
|
||||
|
||||
5. **Test with Correct Command Format:**
|
||||
```
|
||||
AUTHENTICATE: 0x22 0x35 <UID_LSB_8bytes> 0x00 0x80 <key_slot> <challenge_10bytes>
|
||||
- FLAGS = 0x22 (high data rate, addressed mode) - REQUIRED
|
||||
- CSI = 0x00 (AES-128)
|
||||
- MAM1_Header = 0x80
|
||||
```
|
||||
258
docs/TRANSPONDERS.md
Normal file
258
docs/TRANSPONDERS.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# pm3py Simulated Transponders — Reference & Comparison
|
||||
|
||||
Every transponder model that `pm3py` can simulate, organized by RF frequency and
|
||||
protocol, with feature-comparison tables. Import any model from `pm3py.sim`:
|
||||
|
||||
```python
|
||||
from pm3py.sim import NTAG216, ST25DV16K, OptigaAuthenticateNBT, SimSession
|
||||
```
|
||||
|
||||
Memory figures are **user memory** (excludes UID/lock/config/OTP pages) per the
|
||||
manufacturer datasheet. "Faithful" models reproduce the documented command set,
|
||||
UID prefix, and access rules; a ✓ in a feature column means the model implements
|
||||
that behaviour (not merely that the silicon has it).
|
||||
|
||||
Legend: **T2/T4/T5** = NFC Forum Tag type · **A/B/V** = ISO 14443-A / 14443-B /
|
||||
15693 (NFC-V) · **DI** = dual interface (RF + wired I²C/SPI) · **EH** = energy
|
||||
harvesting · **—** = not applicable / not present.
|
||||
|
||||
---
|
||||
|
||||
## 1. Master list — by frequency and protocol
|
||||
|
||||
| Model(s) | Vendor | Freq | Protocol | NFC type | Module |
|
||||
|---|---|---|---|---|---|
|
||||
| MIFARE Ultralight / C / EV1 (MF0UL11/21) | NXP | 13.56 MHz | ISO 14443-A | T2 | `iso14443a/nxp/ultralight` |
|
||||
| NTAG210/212/213/215/216 | NXP | 13.56 MHz | ISO 14443-A | T2 | `iso14443a/nxp/ntag21x` |
|
||||
| NTAG I²C plus (NT3H2111/2211) | NXP | 13.56 MHz | ISO 14443-A | T2 + I²C | `iso14443a/nxp/ntag_i2c` |
|
||||
| MIFARE Classic 1K/4K | NXP | 13.56 MHz | ISO 14443-A | — (Crypto1) | `iso14443a/nxp/mifare_classic` |
|
||||
| MIFARE DESFire EV1/2/3 | NXP | 13.56 MHz | ISO 14443-A | T4 | `iso14443a/nxp/desfire` |
|
||||
| ST25TN512/01K | ST | 13.56 MHz | ISO 14443-A | T2 | `iso14443a/st/st25tn` |
|
||||
| ST25TA512B/02KB/16K/64K | ST | 13.56 MHz | ISO 14443-A | T4 | `iso14443a/st/st25ta` |
|
||||
| OPTIGA Authenticate NBT | Infineon | 13.56 MHz | ISO 14443-A | T4 + I²C | `iso14443a/infineon/optiga_nbt` |
|
||||
| ST25TB512-AC/AT, 02K, 04K | ST | 13.56 MHz | ISO 14443-B (SRIX) | — | `iso14443b/st/st25tb` |
|
||||
| RF430CL330H | TI | 13.56 MHz | ISO 14443-B | T4 + I²C | `iso14443b/ti/rf430cl330h` |
|
||||
| ICODE SLIX / SLIX2 / 3 / DNA | NXP | 13.56 MHz | ISO 15693 | T5 | `iso15693/nxp/icode_*` |
|
||||
| NTAG 5 Switch / Link / Boost | NXP | 13.56 MHz | ISO 15693 | T5 (+I²C on Link/Boost) | `iso15693/nxp/ntag5_*` |
|
||||
| ST25TV512C/02KC | ST | 13.56 MHz | ISO 15693 | T5 | `iso15693/st/st25tv` |
|
||||
| ST25DV04K/16K/64K | ST | 13.56 MHz | ISO 15693 | T5 + I²C | `iso15693/st/st25dv` |
|
||||
| my-d vicinity SRF55V02P/10P | Infineon | 13.56 MHz | ISO 15693 | T5 | `iso15693/infineon/myd_vicinity` |
|
||||
| Tag-it HF-I Plus | TI | 13.56 MHz | ISO 15693 | T5 | `iso15693/ti/tagit` |
|
||||
| RF430FRL152/153/154H | TI | 13.56 MHz | ISO 15693 + sensor | T5 + I²C/SPI | `iso15693/ti/rf430frl` |
|
||||
| EM4100 | EM | 125 kHz | LF ASK | — | `lf/em/em4100` |
|
||||
| HID Prox | HID | 125 kHz | LF FSK (Wiegand) | — | `lf/hid/hid` |
|
||||
| T5577 | Atmel | 125 kHz | LF (multi) | — | `lf/atmel/t5577` |
|
||||
|
||||
Generic bases you can subclass: `NfcType2Tag` / `NfcType4Tag` (14443-A),
|
||||
`NfcType5Tag`/`Tag15693` (15693), `Tag14443B` (SRIX) / `Tag14443B_4` (standard
|
||||
Type B), `TagLF`.
|
||||
|
||||
---
|
||||
|
||||
## 2. HF · ISO 14443-A · NFC Forum Type 2 (Ultralight / NTAG)
|
||||
|
||||
| Model | User mem | Pages | GET_VERSION | FAST_READ | PWD_AUTH | Counter | Signature | Extra |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| MIFARE Ultralight (MF0ICU1) | 48 B | 16 | — | — | — | — | — | OTP, COMPAT_WRITE |
|
||||
| MIFARE Ultralight C (MF0ICU2) | 144 B | 48 | — | — | — | 16-bit | — | **3DES AUTHENTICATE** |
|
||||
| Ultralight EV1 MF0UL11 | 48 B | 20 | ✓ | ✓ | 32-bit | 3 × 24-bit | ✓ | INCR_CNT, tearing |
|
||||
| Ultralight EV1 MF0UL21 | 128 B | 41 | ✓ | ✓ | 32-bit | 3 × 24-bit | ✓ | INCR_CNT, tearing |
|
||||
| NTAG210 | 48 B | 20 | ✓ | ✓ | 32-bit | — | ✓ | — |
|
||||
| NTAG212 | 128 B | 41 | ✓ | ✓ | 32-bit | — | ✓ | — |
|
||||
| NTAG213 | 144 B | 45 | ✓ | ✓ | 32-bit | 1 × 24-bit NFC | ✓ | mirror |
|
||||
| NTAG215 | 504 B | 135 | ✓ | ✓ | 32-bit | 1 × 24-bit NFC | ✓ | mirror |
|
||||
| NTAG216 | 888 B | 231 | ✓ | ✓ | 32-bit | 1 × 24-bit NFC | ✓ | mirror |
|
||||
| NTAG I²C plus 1K (NT3H2111) | 888 B | — | ✓ | ✓ | 32-bit | — | ✓ | **SECTOR_SELECT**, 64 B SRAM, DI |
|
||||
| NTAG I²C plus 2K (NT3H2211) | 1912 B | — | ✓ | ✓ | 32-bit | — | ✓ | 2 sectors, FAST_WRITE, DI |
|
||||
| ST25TN512 | 64 B | 64 | — | — | — | — | TruST25 | Kill password |
|
||||
| ST25TN01K | 160 B | 64 | — | — | — | — | TruST25 | Kill password |
|
||||
|
||||
All: ATQA `0x0044`, SAK `0x00`, 7-byte UID. NXP UID prefix `04…`; ST prefix `02…`.
|
||||
NFC counter on NTAG213/215/216 auto-increments once per power cycle when enabled
|
||||
(`ACCESS.NFC_CNT_EN`). Ultralight EV1 counters are read/increment (one-way) with
|
||||
`CHECK_TEARING_EVENT`.
|
||||
|
||||
---
|
||||
|
||||
## 3. HF · ISO 14443-A · NFC Forum Type 4 (ISO-DEP / APDU)
|
||||
|
||||
| Model | NDEF | Extra files | Password | Crypto auth | Counter | Extra |
|
||||
|---|---|---|---|---|---|---|
|
||||
| ST25TA512B | 64 B | System | 128-bit R/W | — | — | — |
|
||||
| ST25TA02KB | 256 B | System | 128-bit R/W | — | 20-bit event | TruST25, GPO (-D/-P) |
|
||||
| ST25TA16K | 2 KB | System | 128-bit R/W | — | — | — |
|
||||
| ST25TA64K | 8 KB | System | 128-bit R/W | — | — | *constants extrapolated* |
|
||||
| OPTIGA Authenticate NBT | 4 KB | 4×1 KB proprietary + FAP | 32-bit (per-file, FAP) | **ECDSA P-256** + AES-CMAC COTT | — | DI (I²C), life-cycle |
|
||||
|
||||
All ISO-DEP with per-model ATS. ST25TA UID prefix `02…`; OPTIGA prefix `05…`,
|
||||
ATS historical bytes = `"NBT2000"`. OPTIGA read/write access is gated by the
|
||||
File Access Policy (per file, per interface); AUTHENTICATE TAG (INS `0x88`)
|
||||
signs a challenge with the device's NIST P-256 key.
|
||||
|
||||
*MIFARE DESFire (EV1/2/3) is also Type 4 but uses the proprietary DESFire
|
||||
command set + DES/3DES/AES auth — see `iso14443a/nxp/desfire`.*
|
||||
|
||||
---
|
||||
|
||||
## 4. HF · ISO 14443-B
|
||||
|
||||
| Model | Anticollision | Blocks × size | User | Counters | OTP area | Extra |
|
||||
|---|---|---|---|---|---|---|
|
||||
| ST25TB512-AC | SRIX slot-marker | 16 × 4 B | 36 B | 2 × count-down | blocks 0-4 | resettable OTP |
|
||||
| ST25TB512-AT | SRIX slot-marker | 16 × 4 B | 36 B | 2 × count-down | — | plain EEPROM 0-4 |
|
||||
| ST25TB02K | SRIX slot-marker | 64 × 4 B | 228 B | 2 × count-down | blocks 0-4 | reload counter |
|
||||
| ST25TB04K | SRIX slot-marker | 128 × 4 B | 484 B | 2 × count-down | blocks 0-4 | reload counter |
|
||||
| RF430CL330H | **standard REQB/ATTRIB** | 3 KB SRAM | 3 KB NDEF | — | — | NFC **Type 4**, DI (I²C/SPI) |
|
||||
|
||||
ST25TB use the simplified SRIX/SRI anticollision (Initiate/Pcall16/Slot_marker
|
||||
/Select-by-Chip_ID), 64-bit UID prefix `D0 02 …`, decrement-only counters at
|
||||
blocks 5/6. RF430CL330H uses full ISO 14443-3 Type B (REQB→ATQB→ATTRIB) and then
|
||||
Type 4 NDEF APDUs; its NDEF SRAM is volatile (host-filled over I²C/SPI).
|
||||
|
||||
---
|
||||
|
||||
## 5. HF · ISO 15693 (NFC-V / NFC Forum Type 5)
|
||||
|
||||
| Model | User mem | Blocks | UID prefix | Password | Privacy | Signature | Extra |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| ICODE SLIX | 112 B | 28 × 4 B | `E0 04 01` | EAS/AFI | — | ✓ | EAS |
|
||||
| ICODE SLIX2 | 320 B | 80 × 4 B | `E0 04 02` | 4 × 32-bit | privacy | ✓ | DESTROY, protect-page |
|
||||
| ICODE 3 | 304 B | 76 × 4 B | `E0 04 01` | 4 × 32-bit | privacy | ✓ | — |
|
||||
| ICODE DNA | 256 B | 64 × 4 B | `E0 04 01` | 4 × 32-bit | privacy | ✓ | **AES-128** mutual auth |
|
||||
| NTAG 5 Switch (NTP5210) | 512 B | 128 × 4 B | `E0 04 01` | password | privacy | ✓ | PWM/GPIO, EH, ED — **no I²C**, no AES |
|
||||
| NTAG 5 Link (NTP53x2) | 2 KB | 512 × 4 B | `E0 04 01` | password | privacy | ✓ | **DI (I²C)**, 256 B SRAM, EH, ED, AES (NTP5332) |
|
||||
| NTAG 5 Boost (NTA5332) | 2 KB | 512 × 4 B | `E0 04 01` | password | privacy | ✓ | **DI (I²C)**, SRAM, EH, AES always, **ALM**³ |
|
||||
| ST25TV512C | 64 B | 16 × 4 B | `E0 02 08` | 64/32-bit areas | Kill/Silent/Discreet | TruST25 | tamper (-T) |
|
||||
| ST25TV02KC | 320 B | 80 × 4 B | `E0 02 08` | 64/32-bit areas | Kill/Silent/Discreet | TruST25 | tamper (-T) |
|
||||
| ST25DV04K | 512 B | 128 × 4 B | `E0 02 24` | 3 × 64-bit | — | — | **EH**, mailbox/FTM, DI |
|
||||
| ST25DV16K | 2 KB | 512 × 4 B | `E0 02 26` | 3 × 64-bit | — | — | **EH**, 4 areas, GPO, DI |
|
||||
| ST25DV64K | 8 KB | 2048 × 4 B | `E0 02 26` | 3 × 64-bit | — | — | **EH**, mailbox, DI |
|
||||
| my-d vicinity SRF55V02P | 224 B | 56 × 4 B | `E0 05 40` | — | — | — | value counters, page mode¹ |
|
||||
| my-d vicinity SRF55V10P | 992 B | 248 × 4 B | `E0 05 00` | — | — | — | value counters, page mode¹ |
|
||||
| Tag-it HF-I Plus | 256 B | 64 × 4 B | `E0 07 …` | — | — | — | Write_2/Lock_2 (0xA2/0xA3) |
|
||||
| RF430FRL152/3/4H | 256 B² | 64 × 4 B² | `E0 07 A2` | — | — | — | **ADC + temp sensor**, DI |
|
||||
|
||||
¹ my-d vicinity has **no GET SYSTEM INFO** (0x2B) — identify via the Chip-ID UID
|
||||
byte. ² RF430FRL block map + sensor commands are defined by ROM firmware
|
||||
(SLAU603, not modelled); the model provides identity + standard 15693 I/O.
|
||||
³ **ALM** = Active Load Modulation, unique to the NTAG 5 Boost (NTA5332) among
|
||||
the NTAG 5 family — a physical RF feature (no command-layer effect; at the APDU
|
||||
layer Boost is a Link NTP5332 with AES permanently enabled).
|
||||
|
||||
---
|
||||
|
||||
## 6. LF · 125 kHz
|
||||
|
||||
| Model | Data | Encoding | Writable | Notes |
|
||||
|---|---|---|---|---|
|
||||
| EM4100 | 40-bit ID | ASK / Manchester | read-only | classic access-control tag |
|
||||
| HID Prox | 26/35/… bit Wiegand | FSK | read-only | facility + card number |
|
||||
| T5577 | 4×32-bit blocks + config | ASK/FSK/PSK (multi) | ✓ | emulates EM4100/HID/etc. via config |
|
||||
|
||||
---
|
||||
|
||||
## 7. Specialized feature comparison
|
||||
|
||||
### 7.1 Energy harvesting (EH output for external circuits)
|
||||
|
||||
| Model | Protocol | EH mechanism |
|
||||
|---|---|---|
|
||||
| ST25DV04K/16K/64K | 15693 | V_EH analog output pin; `EH_CTRL_Dyn` register (EH_EN/EH_ON) |
|
||||
| NTAG I²C plus | 14443-A | field-powered I²C host supply (V_OUT) |
|
||||
| **NTAG 5 — all variants (Switch/Link/Boost)** | 15693 | energy-harvesting output + event-detection (ED) pin, shared via the NTAG 5 platform |
|
||||
| RF430FRL15xH | 15693 | passive (field-powered) or battery-assisted operation |
|
||||
|
||||
### 7.2 Dual interface — RF + wired host (I²C / SPI bridge)
|
||||
|
||||
| Model | RF | Wired | Shared-memory / bridge feature |
|
||||
|---|---|---|---|
|
||||
| NTAG I²C plus (NT3H2111/2211) | 14443-A T2 | I²C | 64-byte SRAM, pass-through, SECTOR_SELECT |
|
||||
| ST25DV04K/16K/64K | 15693 T5 | I²C | 256-byte mailbox (Fast Transfer Mode) |
|
||||
| OPTIGA Authenticate NBT | 14443-A T4 | I²C (GP T=1′) | NFC↔I²C pass-through, IRQ |
|
||||
| RF430CL330H | 14443-B T4 | I²C / SPI | 3 KB NDEF SRAM, host writes NDEF |
|
||||
| NTAG 5 Link (NTP53x2) | 15693 T5 | I²C | 256 B SRAM, arbiter, pass-through/mirror/PHDC |
|
||||
| NTAG 5 Boost (NTA5332) | 15693 T5 | I²C | = Link NTP5332 + AES always on + ALM |
|
||||
| RF430FRL15xH | 15693 | I²C / SPI | MSP430 host access to FRAM |
|
||||
|
||||
*(NTAG 5 **Switch** (NTP5210) is single-interface — RF + PWM/GPIO only, no I²C.)*
|
||||
|
||||
### 7.3 Cryptographic authentication
|
||||
|
||||
| Model | Protocol | Scheme |
|
||||
|---|---|---|
|
||||
| MIFARE Classic 1K/4K | 14443-A | Crypto1 (48-bit, per-sector keys A/B) |
|
||||
| MIFARE Ultralight C | 14443-A | 2-key **3DES** challenge-response (AUTHENTICATE 0x1A) |
|
||||
| MIFARE DESFire | 14443-A T4 | DES / 3DES / **AES-128** |
|
||||
| ICODE DNA | 15693 | **AES-128** tag + mutual auth |
|
||||
| NTAG 5 Link (NTP5332) / Boost (NTA5332) | 15693 | **AES-128** TAM/MAM (Switch has none; Link NTP5312 is password-only) |
|
||||
| OPTIGA Authenticate NBT | 14443-A T4 | **ECDSA P-256** (offline) + AES-128-CMAC COTT (online) |
|
||||
|
||||
### 7.4 Password protection (symmetric secret / verify)
|
||||
|
||||
| Model | Protocol | Password |
|
||||
|---|---|---|
|
||||
| NTAG21x, Ultralight EV1 | 14443-A | 32-bit PWD_AUTH (+16-bit PACK), AUTH0 page gate |
|
||||
| ST25TA family | 14443-A T4 | 128-bit read + write passwords (VERIFY) |
|
||||
| OPTIGA NBT | 14443-A T4 | 32-bit per-file passwords via File Access Policy |
|
||||
| ICODE SLIX2 / 3 / DNA | 15693 | 4 × 32-bit (read/write/privacy/destroy) |
|
||||
| ST25TV | 15693 | 64-bit (single-area) or 2 × 32-bit (dual-area) + config pwd |
|
||||
| ST25DV | 15693 | 3 × 64-bit RF passwords + area protection |
|
||||
| ST25TN | 14443-A | 32-bit Kill password only |
|
||||
|
||||
### 7.5 Counters
|
||||
|
||||
| Model | Counter(s) | Type |
|
||||
|---|---|---|
|
||||
| NTAG213/215/216 | 1 × 24-bit | NFC read counter (auto-increment) |
|
||||
| Ultralight EV1 | 3 × 24-bit | one-way, increment + tearing check |
|
||||
| Ultralight C | 1 × 16-bit | one-way |
|
||||
| ST25TA02KB | 20-bit | event counter (anti-tearing) |
|
||||
| my-d vicinity | per-page | value counters (decrement-only, 0…65535) |
|
||||
| ST25TB (all) | 2 × 32-bit | count-down (decrement-only) |
|
||||
|
||||
### 7.6 Tamper detection, privacy & kill
|
||||
|
||||
| Model | Feature |
|
||||
|---|---|
|
||||
| ST25TV02KC-T | tamper loop (TD0/TD1), seal/unseal/reseal event memorization |
|
||||
| ST25TV | Kill (permanent), Silent + Discreet (reversible), NUID masking |
|
||||
| ST25TN | Kill (permanent RF mute) |
|
||||
| ICODE SLIX2 / 3 / DNA | privacy mode, DESTROY (permanent) |
|
||||
| ST25DV | RF-disable / RF-sleep dynamic registers |
|
||||
|
||||
### 7.7 Originality / authenticity signatures
|
||||
|
||||
| Signature scheme | Models |
|
||||
|---|---|
|
||||
| NXP originality (32-byte ECC, READ_SIG) | NTAG21x, Ultralight EV1, NTAG I²C, ICODE SLIX/SLIX2/3/DNA, NTAG5 |
|
||||
| ST TruST25 digital signature | ST25TN, ST25TA (02KB), ST25TV |
|
||||
| Certificate-based (X.509 + ECDSA) | OPTIGA Authenticate NBT |
|
||||
|
||||
### 7.8 Sensors
|
||||
|
||||
| Model | Sensor |
|
||||
|---|---|
|
||||
| RF430FRL152/153/154H | 14-bit sigma-delta ADC + internal temperature sensor (over 15693, via ROM firmware) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Implant presets
|
||||
|
||||
Pre-configured instances for the common dual/single-frequency implants
|
||||
(`from pm3py.sim import xEM, xNT, xM1, FlexDF, NExT`):
|
||||
|
||||
| Preset | Backing model | Notes |
|
||||
|---|---|---|
|
||||
| `xEM` | T5577 (EM4100 mode) | LF |
|
||||
| `xNT` | NTAG216 | HF, NDEF |
|
||||
| `xM1` | MIFARE Classic 1K | HF |
|
||||
| `FlexDF` | DESFire EV2 | HF |
|
||||
| `NExT` | T5577 + NTAG216 | dual-frequency (LF + HF) |
|
||||
| `MagicMifareClassicTag` | gen1a / gen2 Classic | writable block 0 |
|
||||
|
||||
---
|
||||
|
||||
*Generated from the `pm3py.sim` model set. Memory/feature values follow the
|
||||
manufacturer datasheets; where a datasheet was not supplied (ST25TA64K,
|
||||
RF430FRL firmware command set) the model is marked accordingly in its module
|
||||
docstring.*
|
||||
134
docs/plans/2026-03-17-trace-formatter-design.md
Normal file
134
docs/plans/2026-03-17-trace-formatter-design.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Trace Formatter Design
|
||||
|
||||
## Problem
|
||||
|
||||
The sim session trace output is a raw `print()` with no color, no command decoding, no wrapping, and no leading newline (so it sometimes renders on the REPL prompt). The PM3 C client has rich colored, decoded trace output — we want that for our Python sim traces.
|
||||
|
||||
## Design
|
||||
|
||||
### New module: `pm3py/sim/trace_fmt.py`
|
||||
|
||||
A `TraceFormatter` class that owns all rendering logic. `_trace_reader` in `sim_session.py` delegates to it instead of calling `print()` directly.
|
||||
|
||||
### Modes
|
||||
|
||||
| Mode | Tag | Color |
|
||||
|------|-----|-------|
|
||||
| Sim (we are the tag) | `[Sim]` | Magenta (`\033[35m`) |
|
||||
| Reader (we are the reader) | `[Rdr]` | Cyan (`\033[36m`) |
|
||||
| Sniff (passive observer) | `[Snf]` | Default/white |
|
||||
|
||||
### Direction colors
|
||||
|
||||
| Direction | Color |
|
||||
|-----------|-------|
|
||||
| Reader -> Tag (command) | Cyan (`\033[36m`) |
|
||||
| Tag -> Reader (response) | Yellow (`\033[33m`) |
|
||||
|
||||
### Additional colors
|
||||
|
||||
| Element | Color |
|
||||
|---------|-------|
|
||||
| Command annotation | Green (`\033[32m`) |
|
||||
| Hex bytes | Dim (`\033[2m`) |
|
||||
|
||||
Colors suppressed when `sys.stdout.isatty()` is False.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
\n[Sim] Reader -> Tag: 26 01 00 INVENTORY
|
||||
^^^^ ^^^^^^^^^^^^^^ ^^^^^^^^ ^^^^^^^^^
|
||||
mode prefix(colored) hex(dim) annotation(green)
|
||||
```
|
||||
|
||||
Prefix column = fixed width (e.g., 24 chars). This is the indent for continuation lines.
|
||||
|
||||
Hex bytes are space-separated (`de ad be ef`, not `deadbeef`).
|
||||
|
||||
### Wrapping
|
||||
|
||||
When hex + annotation fit on one line: annotation appears inline after hex.
|
||||
|
||||
When hex overflows terminal width: hex wraps at prefix column, annotation goes on its own line below (also at prefix column).
|
||||
|
||||
```
|
||||
\n[Sim] Tag -> Reader: 00 de ad be ef 00 00 00 00 00 00 00 00 00 00
|
||||
00 00 00 00 00 00 00 00 00 00 00 00 00 00 fe a1
|
||||
READ MULTIPLE BLOCK [0x00, 13 blocks] OK
|
||||
```
|
||||
|
||||
### Terminal width handling
|
||||
|
||||
- Cache `os.get_terminal_size()` on init
|
||||
- Register `signal.SIGWINCH` handler (Unix) to update cached width
|
||||
- Fallback: re-read every 10 lines if signal registration fails
|
||||
- Default 80 columns if no TTY
|
||||
|
||||
### ISO 15693 command decode table
|
||||
|
||||
Extracted from command byte (byte index 1 of frame):
|
||||
|
||||
| Cmd | Name | Extra annotation |
|
||||
|-----|------|-----------------|
|
||||
| 0x01 | INVENTORY | mask_len if present |
|
||||
| 0x02 | STAY QUIET | -- |
|
||||
| 0x20 | READ SINGLE BLOCK | block number |
|
||||
| 0x21 | WRITE SINGLE BLOCK | block number + data length |
|
||||
| 0x23 | READ MULTIPLE BLOCK | start block, count |
|
||||
| 0x26 | RESET TO READY | -- |
|
||||
| 0x2B | GET SYSTEM INFO | -- |
|
||||
| 0x2C | GET MULTIPLE BLOCK SECURITY | -- |
|
||||
| 0xA1 | NXP READ CONFIG | reg index |
|
||||
| 0xA2 | NXP WRITE CONFIG | reg index, value |
|
||||
| 0xB2 | NXP GET RANDOM | -- |
|
||||
| 0xB3 | NXP SET PASSWORD | pwd_id |
|
||||
|
||||
Response decoding: flags byte 0x00 = OK, 0x01 = ERROR + error code. Inventory responses show UID. Read responses show data.
|
||||
|
||||
### ISO 14443-A command decode table
|
||||
|
||||
| Byte(s) | Name |
|
||||
|---------|------|
|
||||
| 0x26 | REQA |
|
||||
| 0x52 | WUPA |
|
||||
| 0x50 00 | HLTA |
|
||||
| 0x93 20 | ANTICOL CL1 |
|
||||
| 0x93 70 | SELECT CL1 |
|
||||
| 0x95 20/70 | ANTICOL/SELECT CL2 |
|
||||
| 0x97 20/70 | ANTICOL/SELECT CL3 |
|
||||
| 0xE0 | RATS |
|
||||
| 0x02/0x03 | I-BLOCK |
|
||||
| 0xA2/0xA3 | R-ACK |
|
||||
| 0xB2/0xB3 | R-NAK |
|
||||
| 0xC2/0xF2 | S(DESELECT)/S(WTX) |
|
||||
|
||||
Response decoding: ATQA as `ATQA xx xx`, SAK as `SAK xx`, ATS as `ATS [len]`.
|
||||
|
||||
### API
|
||||
|
||||
```python
|
||||
class TraceFormatter:
|
||||
def __init__(self, mode: str = "sim"):
|
||||
"""mode: "sim", "reader", or "sniff" """
|
||||
|
||||
def format(self, direction: int, payload: bytes) -> str:
|
||||
"""Returns fully formatted, colored, wrapped string with leading \\n."""
|
||||
|
||||
def print(self, direction: int, payload: bytes) -> None:
|
||||
"""format() + print to stdout."""
|
||||
```
|
||||
|
||||
Decoder functions: `decode_15693(direction, payload) -> str | None` and `decode_14443a(direction, payload) -> str | None`.
|
||||
|
||||
### Integration
|
||||
|
||||
- `sim_session.py`: instantiate `TraceFormatter("sim")` in `start_15693()`, call `self._formatter.print()` in `_trace_reader()`
|
||||
- 14443-A trace path: decode table built now, wired up when firmware trace path lands
|
||||
- No changes to `replay.py` or `InteractiveReader.dump_trace()`
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Replay/recorder trace formatting
|
||||
- InteractiveReader dump formatting
|
||||
- Protocol decode beyond 15693 + 14443-A
|
||||
998
docs/plans/2026-03-17-trace-formatter-plan.md
Normal file
998
docs/plans/2026-03-17-trace-formatter-plan.md
Normal file
@@ -0,0 +1,998 @@
|
||||
# Trace Formatter Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace raw `print()` trace output with colored, decoded, column-wrapped trace rendering for sim sessions.
|
||||
|
||||
**Architecture:** New `trace_fmt.py` module with `TraceFormatter` class + protocol decode functions. `sim_session.py` delegates to it. Terminal width detected dynamically with SIGWINCH support.
|
||||
|
||||
**Tech Stack:** Python stdlib only (os, signal, sys, struct, shutil). No external dependencies.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: ISO 15693 request decoder
|
||||
|
||||
**Files:**
|
||||
- Create: `pm3py/sim/trace_fmt.py`
|
||||
- Test: `tests/test_sim_trace_fmt.py`
|
||||
|
||||
**Step 1: Write failing tests for 15693 request decoding**
|
||||
|
||||
```python
|
||||
"""Tests for pm3py.sim.trace_fmt — trace formatting and protocol decoding."""
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.trace_fmt import decode_15693
|
||||
|
||||
|
||||
class TestDecode15693Request:
|
||||
"""Decode reader->tag (direction=0) 15693 commands."""
|
||||
|
||||
def test_inventory(self):
|
||||
# flags=0x26, cmd=0x01, mask_len=0
|
||||
payload = bytes([0x26, 0x01, 0x00])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "INVENTORY"
|
||||
|
||||
def test_inventory_with_mask(self):
|
||||
payload = bytes([0x26, 0x01, 0x08, 0xAB])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "INVENTORY mask=8"
|
||||
|
||||
def test_stay_quiet(self):
|
||||
payload = bytes([0x22, 0x02]) + b"\x01" * 8
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "STAY QUIET"
|
||||
|
||||
def test_read_single_block_addressed(self):
|
||||
# flags=0x22 (addressed), cmd=0x20, uid(8), block=0x03
|
||||
payload = bytes([0x22, 0x20]) + b"\x01" * 8 + bytes([0x03])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "READ SINGLE BLOCK #3"
|
||||
|
||||
def test_read_single_block_unaddressed(self):
|
||||
# flags=0x02 (no address flag), cmd=0x20, block=0x00
|
||||
payload = bytes([0x02, 0x20, 0x00])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "READ SINGLE BLOCK #0"
|
||||
|
||||
def test_write_single_block(self):
|
||||
payload = bytes([0x22, 0x21]) + b"\x01" * 8 + bytes([0x05]) + b"\xDE\xAD\xBE\xEF"
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "WRITE SINGLE BLOCK #5 [4B]"
|
||||
|
||||
def test_read_multiple_block(self):
|
||||
# flags=0x22, cmd=0x23, uid(8), start=0x00, count=0x0D
|
||||
payload = bytes([0x22, 0x23]) + b"\x01" * 8 + bytes([0x00, 0x0D])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "READ MULTIPLE BLOCK #0+13"
|
||||
|
||||
def test_reset_to_ready(self):
|
||||
payload = bytes([0x22, 0x26]) + b"\x01" * 8
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "RESET TO READY"
|
||||
|
||||
def test_get_system_info(self):
|
||||
payload = bytes([0x22, 0x2B]) + b"\x01" * 8
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "GET SYSTEM INFO"
|
||||
|
||||
def test_get_multiple_block_security(self):
|
||||
payload = bytes([0x22, 0x2C]) + b"\x01" * 8 + bytes([0x00, 0x3F])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "GET MULTIPLE BLOCK SECURITY #0+63"
|
||||
|
||||
def test_nxp_read_config(self):
|
||||
# flags=0x22, cmd=0xA1, mfg=0x04, reg=0x02
|
||||
payload = bytes([0x22, 0xA1, 0x04, 0x02])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "NXP READ CONFIG reg=2"
|
||||
|
||||
def test_nxp_write_config(self):
|
||||
payload = bytes([0x22, 0xA2, 0x04, 0x03, 0xFF])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "NXP WRITE CONFIG reg=3 val=0xFF"
|
||||
|
||||
def test_nxp_get_random(self):
|
||||
payload = bytes([0x22, 0xB2, 0x04])
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "NXP GET RANDOM"
|
||||
|
||||
def test_nxp_set_password(self):
|
||||
payload = bytes([0x22, 0xB3, 0x04, 0x01]) + b"\x00" * 4
|
||||
result = decode_15693(0, payload)
|
||||
assert result == "NXP SET PASSWORD id=1"
|
||||
|
||||
def test_unknown_command(self):
|
||||
payload = bytes([0x22, 0xFF])
|
||||
result = decode_15693(0, payload)
|
||||
assert result is None
|
||||
|
||||
def test_too_short(self):
|
||||
result = decode_15693(0, bytes([0x26]))
|
||||
assert result is None
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestDecode15693Request -v`
|
||||
Expected: FAIL — `ModuleNotFoundError: No module named 'pm3py.sim.trace_fmt'`
|
||||
|
||||
**Step 3: Implement 15693 request decoder**
|
||||
|
||||
Create `pm3py/sim/trace_fmt.py`:
|
||||
|
||||
```python
|
||||
"""Trace formatter — colored, decoded, column-wrapped trace output."""
|
||||
from __future__ import annotations
|
||||
|
||||
# ---- ISO 15693 flags ----
|
||||
_15693_FLAG_INVENTORY = 0x04
|
||||
_15693_FLAG_ADDRESS = 0x20
|
||||
|
||||
# ---- ISO 15693 command names ----
|
||||
_15693_CMDS = {
|
||||
0x01: "INVENTORY",
|
||||
0x02: "STAY QUIET",
|
||||
0x20: "READ SINGLE BLOCK",
|
||||
0x21: "WRITE SINGLE BLOCK",
|
||||
0x23: "READ MULTIPLE BLOCK",
|
||||
0x26: "RESET TO READY",
|
||||
0x2B: "GET SYSTEM INFO",
|
||||
0x2C: "GET MULTIPLE BLOCK SECURITY",
|
||||
}
|
||||
|
||||
# NXP custom commands (manufacturer code 0x04)
|
||||
_15693_NXP_CMDS = {
|
||||
0xA1: "NXP READ CONFIG",
|
||||
0xA2: "NXP WRITE CONFIG",
|
||||
0xB2: "NXP GET RANDOM",
|
||||
0xB3: "NXP SET PASSWORD",
|
||||
}
|
||||
|
||||
|
||||
def _15693_block_offset(flags: int) -> int:
|
||||
"""Return the byte offset of the block number field after flags+cmd."""
|
||||
is_inventory = bool(flags & _15693_FLAG_INVENTORY)
|
||||
if not is_inventory and (flags & _15693_FLAG_ADDRESS):
|
||||
return 10 # flags(1) + cmd(1) + uid(8)
|
||||
return 2 # flags(1) + cmd(1)
|
||||
|
||||
|
||||
def decode_15693(direction: int, payload: bytes) -> str | None:
|
||||
"""Decode an ISO 15693 frame into a human-readable annotation.
|
||||
|
||||
Args:
|
||||
direction: 0 = reader->tag, 1 = tag->reader
|
||||
payload: raw frame bytes
|
||||
|
||||
Returns:
|
||||
Annotation string or None if unrecognized.
|
||||
"""
|
||||
if direction == 0:
|
||||
return _decode_15693_request(payload)
|
||||
else:
|
||||
return _decode_15693_response(payload)
|
||||
|
||||
|
||||
def _decode_15693_request(payload: bytes) -> str | None:
|
||||
if len(payload) < 2:
|
||||
return None
|
||||
|
||||
flags = payload[0]
|
||||
cmd = payload[1]
|
||||
|
||||
# Standard commands
|
||||
name = _15693_CMDS.get(cmd)
|
||||
if name is not None:
|
||||
return _annotate_15693_request(name, cmd, flags, payload)
|
||||
|
||||
# NXP custom commands (check manufacturer code)
|
||||
name = _15693_NXP_CMDS.get(cmd)
|
||||
if name is not None:
|
||||
return _annotate_nxp_request(name, cmd, payload)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _annotate_15693_request(name: str, cmd: int, flags: int, payload: bytes) -> str:
|
||||
blk_off = _15693_block_offset(flags)
|
||||
|
||||
if cmd == 0x01: # INVENTORY
|
||||
if len(payload) > 2 and payload[2] > 0:
|
||||
return f"{name} mask={payload[2]}"
|
||||
return name
|
||||
|
||||
if cmd in (0x20, 0x21): # READ/WRITE SINGLE BLOCK
|
||||
if len(payload) > blk_off:
|
||||
block = payload[blk_off]
|
||||
if cmd == 0x21:
|
||||
data_len = len(payload) - blk_off - 1
|
||||
return f"{name} #{block} [{data_len}B]"
|
||||
return f"{name} #{block}"
|
||||
return name
|
||||
|
||||
if cmd in (0x23, 0x2C): # READ MULTIPLE / GET MULTIPLE BLOCK SECURITY
|
||||
if len(payload) > blk_off + 1:
|
||||
start = payload[blk_off]
|
||||
count = payload[blk_off + 1]
|
||||
return f"{name} #{start}+{count}"
|
||||
return name
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def _annotate_nxp_request(name: str, cmd: int, payload: bytes) -> str:
|
||||
if cmd == 0xA1 and len(payload) >= 4: # READ CONFIG
|
||||
return f"{name} reg={payload[3]}"
|
||||
if cmd == 0xA2 and len(payload) >= 5: # WRITE CONFIG
|
||||
return f"{name} reg={payload[3]} val=0x{payload[4]:02X}"
|
||||
if cmd == 0xB3 and len(payload) >= 4: # SET PASSWORD
|
||||
return f"{name} id={payload[3]}"
|
||||
return name
|
||||
|
||||
|
||||
def _decode_15693_response(payload: bytes) -> str | None:
|
||||
# Placeholder — implemented in Task 2
|
||||
return None
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestDecode15693Request -v`
|
||||
Expected: all PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/work/pm3py/.worktrees/sim-framework
|
||||
git add pm3py/sim/trace_fmt.py tests/test_sim_trace_fmt.py
|
||||
git commit --no-gpg-sign -m "feat(trace): add ISO 15693 request decoder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: ISO 15693 response decoder
|
||||
|
||||
**Files:**
|
||||
- Modify: `pm3py/sim/trace_fmt.py` — replace `_decode_15693_response` placeholder
|
||||
- Test: `tests/test_sim_trace_fmt.py`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
Append to `tests/test_sim_trace_fmt.py`:
|
||||
|
||||
```python
|
||||
class TestDecode15693Response:
|
||||
"""Decode tag->reader (direction=1) 15693 responses."""
|
||||
|
||||
def test_ok_empty(self):
|
||||
# Just flags=0x00, no data (e.g., write ack)
|
||||
result = decode_15693(1, bytes([0x00]))
|
||||
assert result == "OK"
|
||||
|
||||
def test_inventory_response(self):
|
||||
# flags=0x00, dsfid=0x00, uid (8 bytes LSB-first)
|
||||
uid_lsb = bytes([0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x04, 0xE0])
|
||||
payload = bytes([0x00, 0x00]) + uid_lsb
|
||||
result = decode_15693(1, payload)
|
||||
assert result == "OK INVENTORY UID=E004010101010101"
|
||||
|
||||
def test_error_response(self):
|
||||
payload = bytes([0x01, 0x0F])
|
||||
result = decode_15693(1, payload)
|
||||
assert result == "ERROR 0x0F"
|
||||
|
||||
def test_error_block_not_available(self):
|
||||
payload = bytes([0x01, 0x10])
|
||||
result = decode_15693(1, payload)
|
||||
assert result == "ERROR 0x10 block not available"
|
||||
|
||||
def test_data_response(self):
|
||||
# flags=0x00 + 4 data bytes (read single block response)
|
||||
payload = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF])
|
||||
result = decode_15693(1, payload)
|
||||
assert result == "OK [4B]"
|
||||
|
||||
def test_too_short(self):
|
||||
result = decode_15693(1, b"")
|
||||
assert result is None
|
||||
```
|
||||
|
||||
**Step 2: Run to verify failure**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestDecode15693Response -v`
|
||||
Expected: FAIL — responses decode as `None`
|
||||
|
||||
**Step 3: Implement response decoder**
|
||||
|
||||
Replace `_decode_15693_response` in `trace_fmt.py`:
|
||||
|
||||
```python
|
||||
_15693_ERRORS = {
|
||||
0x01: "not supported",
|
||||
0x02: "not recognized",
|
||||
0x0F: "unknown error",
|
||||
0x10: "block not available",
|
||||
0x11: "block already locked",
|
||||
0x12: "block locked",
|
||||
0x13: "block not written",
|
||||
0x14: "block not locked",
|
||||
}
|
||||
|
||||
|
||||
def _decode_15693_response(payload: bytes) -> str | None:
|
||||
if len(payload) < 1:
|
||||
return None
|
||||
|
||||
flags = payload[0]
|
||||
|
||||
if flags & 0x01: # Error
|
||||
if len(payload) >= 2:
|
||||
code = payload[1]
|
||||
desc = _15693_ERRORS.get(code, "")
|
||||
if desc:
|
||||
return f"ERROR 0x{code:02X} {desc}"
|
||||
return f"ERROR 0x{code:02X}"
|
||||
return "ERROR"
|
||||
|
||||
# Success — try to identify the response type
|
||||
data = payload[1:]
|
||||
if len(data) == 0:
|
||||
return "OK"
|
||||
|
||||
# Inventory response: dsfid(1) + uid(8) = 9 bytes
|
||||
if len(data) == 9:
|
||||
uid_msb = bytes(reversed(data[1:9]))
|
||||
return f"OK INVENTORY UID={uid_msb.hex().upper()}"
|
||||
|
||||
# Generic data response
|
||||
return f"OK [{len(data)}B]"
|
||||
```
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestDecode15693Response -v`
|
||||
Expected: all PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/work/pm3py/.worktrees/sim-framework
|
||||
git add pm3py/sim/trace_fmt.py tests/test_sim_trace_fmt.py
|
||||
git commit --no-gpg-sign -m "feat(trace): add ISO 15693 response decoder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: ISO 14443-A decoder
|
||||
|
||||
**Files:**
|
||||
- Modify: `pm3py/sim/trace_fmt.py`
|
||||
- Test: `tests/test_sim_trace_fmt.py`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
Append to test file:
|
||||
|
||||
```python
|
||||
from pm3py.sim.trace_fmt import decode_14443a
|
||||
|
||||
|
||||
class TestDecode14443aRequest:
|
||||
"""Decode reader->tag 14443-A commands."""
|
||||
|
||||
def test_reqa(self):
|
||||
assert decode_14443a(0, bytes([0x26])) == "REQA"
|
||||
|
||||
def test_wupa(self):
|
||||
assert decode_14443a(0, bytes([0x52])) == "WUPA"
|
||||
|
||||
def test_hlta(self):
|
||||
assert decode_14443a(0, bytes([0x50, 0x00])) == "HLTA"
|
||||
|
||||
def test_anticol_cl1(self):
|
||||
assert decode_14443a(0, bytes([0x93, 0x20])) == "ANTICOL CL1"
|
||||
|
||||
def test_select_cl1(self):
|
||||
payload = bytes([0x93, 0x70]) + b"\x01\x02\x03\x04\x04"
|
||||
assert decode_14443a(0, payload) == "SELECT CL1"
|
||||
|
||||
def test_anticol_cl2(self):
|
||||
assert decode_14443a(0, bytes([0x95, 0x20])) == "ANTICOL CL2"
|
||||
|
||||
def test_select_cl2(self):
|
||||
payload = bytes([0x95, 0x70]) + b"\x01\x02\x03\x04\x04"
|
||||
assert decode_14443a(0, payload) == "SELECT CL2"
|
||||
|
||||
def test_anticol_cl3(self):
|
||||
assert decode_14443a(0, bytes([0x97, 0x20])) == "ANTICOL CL3"
|
||||
|
||||
def test_select_cl3(self):
|
||||
payload = bytes([0x97, 0x70]) + b"\x01\x02\x03\x04\x04"
|
||||
assert decode_14443a(0, payload) == "SELECT CL3"
|
||||
|
||||
def test_rats(self):
|
||||
assert decode_14443a(0, bytes([0xE0, 0x50])) == "RATS"
|
||||
|
||||
def test_iblock_even(self):
|
||||
assert decode_14443a(0, bytes([0x02, 0x00, 0xA4])) == "I-BLOCK(0)"
|
||||
|
||||
def test_iblock_odd(self):
|
||||
assert decode_14443a(0, bytes([0x03, 0x00, 0xA4])) == "I-BLOCK(1)"
|
||||
|
||||
def test_rack(self):
|
||||
assert decode_14443a(0, bytes([0xA2])) == "R-ACK(0)"
|
||||
assert decode_14443a(0, bytes([0xA3])) == "R-ACK(1)"
|
||||
|
||||
def test_rnak(self):
|
||||
assert decode_14443a(0, bytes([0xB2])) == "R-NAK(0)"
|
||||
assert decode_14443a(0, bytes([0xB3])) == "R-NAK(1)"
|
||||
|
||||
def test_deselect(self):
|
||||
assert decode_14443a(0, bytes([0xC2])) == "S(DESELECT)"
|
||||
|
||||
def test_wtx(self):
|
||||
assert decode_14443a(0, bytes([0xF2, 0x01])) == "S(WTX)"
|
||||
|
||||
def test_unknown(self):
|
||||
assert decode_14443a(0, bytes([0xFF])) is None
|
||||
|
||||
def test_empty(self):
|
||||
assert decode_14443a(0, b"") is None
|
||||
|
||||
|
||||
class TestDecode14443aResponse:
|
||||
"""Decode tag->reader 14443-A responses."""
|
||||
|
||||
def test_atqa(self):
|
||||
# 2-byte response to REQA/WUPA
|
||||
assert decode_14443a(1, bytes([0x04, 0x00])) == "ATQA 04 00"
|
||||
|
||||
def test_sak(self):
|
||||
# 1-byte response to SELECT
|
||||
assert decode_14443a(1, bytes([0x20])) == "SAK 20"
|
||||
|
||||
def test_ats(self):
|
||||
# ATS: first byte is length
|
||||
ats = bytes([0x05, 0x78, 0x80, 0x70, 0x02])
|
||||
assert decode_14443a(1, ats) == "ATS [5]"
|
||||
|
||||
def test_iblock_response(self):
|
||||
assert decode_14443a(1, bytes([0x02, 0x90, 0x00])) == "I-BLOCK(0)"
|
||||
|
||||
def test_empty(self):
|
||||
assert decode_14443a(1, b"") is None
|
||||
```
|
||||
|
||||
**Step 2: Run to verify failure**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestDecode14443aRequest tests/test_sim_trace_fmt.py::TestDecode14443aResponse -v`
|
||||
Expected: FAIL — `ImportError: cannot import name 'decode_14443a'`
|
||||
|
||||
**Step 3: Implement 14443-A decoder**
|
||||
|
||||
Add to `trace_fmt.py`:
|
||||
|
||||
```python
|
||||
# ---- ISO 14443-A constants ----
|
||||
_14A_CL_MAP = {0x93: "CL1", 0x95: "CL2", 0x97: "CL3"}
|
||||
|
||||
|
||||
def decode_14443a(direction: int, payload: bytes) -> str | None:
|
||||
"""Decode an ISO 14443-A frame into a human-readable annotation."""
|
||||
if not payload:
|
||||
return None
|
||||
if direction == 0:
|
||||
return _decode_14443a_request(payload)
|
||||
else:
|
||||
return _decode_14443a_response(payload)
|
||||
|
||||
|
||||
def _decode_14443a_request(payload: bytes) -> str | None:
|
||||
b0 = payload[0]
|
||||
|
||||
# Short frames (single byte)
|
||||
if b0 == 0x26:
|
||||
return "REQA"
|
||||
if b0 == 0x52:
|
||||
return "WUPA"
|
||||
|
||||
# HLTA
|
||||
if b0 == 0x50 and len(payload) >= 2:
|
||||
return "HLTA"
|
||||
|
||||
# Anticollision / Select
|
||||
if b0 in _14A_CL_MAP and len(payload) >= 2:
|
||||
cl = _14A_CL_MAP[b0]
|
||||
nvb = payload[1]
|
||||
if nvb == 0x20:
|
||||
return f"ANTICOL {cl}"
|
||||
if nvb == 0x70:
|
||||
return f"SELECT {cl}"
|
||||
return f"ANTICOL {cl} nvb={nvb:02X}"
|
||||
|
||||
# RATS
|
||||
if b0 == 0xE0:
|
||||
return "RATS"
|
||||
|
||||
# ISO-DEP I-block
|
||||
if b0 & 0xE2 == 0x02:
|
||||
bn = b0 & 0x01
|
||||
return f"I-BLOCK({bn})"
|
||||
|
||||
# R-ACK
|
||||
if b0 & 0xF6 == 0xA2:
|
||||
bn = b0 & 0x01
|
||||
return f"R-ACK({bn})"
|
||||
|
||||
# R-NAK
|
||||
if b0 & 0xF6 == 0xB2:
|
||||
bn = b0 & 0x01
|
||||
return f"R-NAK({bn})"
|
||||
|
||||
# S(DESELECT)
|
||||
if b0 == 0xC2:
|
||||
return "S(DESELECT)"
|
||||
|
||||
# S(WTX)
|
||||
if b0 == 0xF2:
|
||||
return "S(WTX)"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _decode_14443a_response(payload: bytes) -> str | None:
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
b0 = payload[0]
|
||||
|
||||
# I-block response
|
||||
if b0 & 0xE2 == 0x02:
|
||||
bn = b0 & 0x01
|
||||
return f"I-BLOCK({bn})"
|
||||
|
||||
# ATQA (2 bytes)
|
||||
if len(payload) == 2 and b0 & 0xF0 == 0x00:
|
||||
return f"ATQA {payload[0]:02X} {payload[1]:02X}"
|
||||
|
||||
# SAK (1 byte)
|
||||
if len(payload) == 1:
|
||||
return f"SAK {b0:02X}"
|
||||
|
||||
# ATS (first byte = length, length >= 2)
|
||||
if len(payload) >= 2 and payload[0] == len(payload):
|
||||
return f"ATS [{len(payload)}]"
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py -v`
|
||||
Expected: all PASS (15693 + 14443-A)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/work/pm3py/.worktrees/sim-framework
|
||||
git add pm3py/sim/trace_fmt.py tests/test_sim_trace_fmt.py
|
||||
git commit --no-gpg-sign -m "feat(trace): add ISO 14443-A decoder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: TraceFormatter — color, wrapping, terminal width
|
||||
|
||||
**Files:**
|
||||
- Modify: `pm3py/sim/trace_fmt.py`
|
||||
- Test: `tests/test_sim_trace_fmt.py`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
Append to test file:
|
||||
|
||||
```python
|
||||
from pm3py.sim.trace_fmt import TraceFormatter
|
||||
|
||||
|
||||
class TestTraceFormatterBasic:
|
||||
"""Test formatting output (colors stripped for assertion)."""
|
||||
|
||||
def _strip_ansi(self, s: str) -> str:
|
||||
import re
|
||||
return re.sub(r'\033\[[0-9;]*m', '', s)
|
||||
|
||||
def test_starts_with_newline(self):
|
||||
fmt = TraceFormatter(mode="sim")
|
||||
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
|
||||
assert result.startswith("\n")
|
||||
|
||||
def test_sim_mode_tag(self):
|
||||
fmt = TraceFormatter(mode="sim")
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
assert "[Sim]" in result
|
||||
|
||||
def test_reader_mode_tag(self):
|
||||
fmt = TraceFormatter(mode="reader")
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
assert "[Rdr]" in result
|
||||
|
||||
def test_sniff_mode_tag(self):
|
||||
fmt = TraceFormatter(mode="sniff")
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
assert "[Snf]" in result
|
||||
|
||||
def test_reader_to_tag_arrow(self):
|
||||
fmt = TraceFormatter(mode="sim")
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
assert "Reader \u2192 Tag:" in result
|
||||
|
||||
def test_tag_to_reader_arrow(self):
|
||||
fmt = TraceFormatter(mode="sim")
|
||||
result = self._strip_ansi(fmt.format(1, bytes([0x00])))
|
||||
assert "Tag \u2192 Reader:" in result
|
||||
|
||||
def test_hex_space_separated(self):
|
||||
fmt = TraceFormatter(mode="sim")
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x22, 0x20, 0x03])))
|
||||
assert "22 20 03" in result
|
||||
|
||||
def test_annotation_present(self):
|
||||
fmt = TraceFormatter(mode="sim")
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
assert "INVENTORY" in result
|
||||
|
||||
def test_no_annotation_for_unknown(self):
|
||||
fmt = TraceFormatter(mode="sim")
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x22, 0xFF])))
|
||||
assert "22 FF" in result or "22 ff" in result.lower()
|
||||
|
||||
|
||||
class TestTraceFormatterWrapping:
|
||||
"""Test column-aligned wrapping for long payloads."""
|
||||
|
||||
def _strip_ansi(self, s: str) -> str:
|
||||
import re
|
||||
return re.sub(r'\033\[[0-9;]*m', '', s)
|
||||
|
||||
def test_short_payload_inline_annotation(self):
|
||||
"""Short payload: annotation on same line as hex."""
|
||||
fmt = TraceFormatter(mode="sim", width=80)
|
||||
result = self._strip_ansi(fmt.format(0, bytes([0x26, 0x01, 0x00])))
|
||||
lines = result.strip().split("\n")
|
||||
assert len(lines) == 1
|
||||
assert "INVENTORY" in lines[0]
|
||||
assert "26 01 00" in lines[0]
|
||||
|
||||
def test_long_payload_wraps(self):
|
||||
"""Long payload wraps at prefix column."""
|
||||
fmt = TraceFormatter(mode="sim", width=60)
|
||||
long_payload = bytes([0x00]) + bytes(40)
|
||||
result = self._strip_ansi(fmt.format(1, long_payload))
|
||||
lines = result.strip().split("\n")
|
||||
assert len(lines) > 1
|
||||
# Continuation lines should be indented to prefix column
|
||||
first_hex_col = lines[0].index("00")
|
||||
for line in lines[1:]:
|
||||
if line.strip():
|
||||
leading = len(line) - len(line.lstrip())
|
||||
assert leading >= first_hex_col - 1
|
||||
|
||||
def test_wrapped_annotation_on_own_line(self):
|
||||
"""When hex wraps, annotation goes on its own line."""
|
||||
fmt = TraceFormatter(mode="sim", width=50)
|
||||
# Inventory response with UID — long enough to wrap at width=50
|
||||
uid_lsb = bytes([0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x04, 0xE0])
|
||||
payload = bytes([0x00, 0x00]) + uid_lsb
|
||||
result = self._strip_ansi(fmt.format(1, payload))
|
||||
lines = result.strip().split("\n")
|
||||
# Annotation should be on a separate line
|
||||
annotation_lines = [l for l in lines if "INVENTORY" in l]
|
||||
hex_lines = [l for l in lines if "00 01" in l.lower() or "04 e0" in l.lower()]
|
||||
if len(hex_lines) > 1:
|
||||
# Wrapped — annotation must be on its own line
|
||||
assert len(annotation_lines) == 1
|
||||
assert annotation_lines[0] not in hex_lines
|
||||
|
||||
|
||||
class TestTraceFormatterNoColor:
|
||||
"""Colors suppressed when is_tty=False."""
|
||||
|
||||
def test_no_ansi_when_not_tty(self):
|
||||
fmt = TraceFormatter(mode="sim", is_tty=False)
|
||||
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
|
||||
assert "\033[" not in result
|
||||
```
|
||||
|
||||
**Step 2: Run to verify failure**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestTraceFormatterBasic tests/test_sim_trace_fmt.py::TestTraceFormatterWrapping tests/test_sim_trace_fmt.py::TestTraceFormatterNoColor -v`
|
||||
Expected: FAIL — `ImportError: cannot import name 'TraceFormatter'`
|
||||
|
||||
**Step 3: Implement TraceFormatter**
|
||||
|
||||
Add to `trace_fmt.py`:
|
||||
|
||||
```python
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
|
||||
# ---- ANSI colors ----
|
||||
_C_CYAN = "\033[36m"
|
||||
_C_YELLOW = "\033[33m"
|
||||
_C_MAGENTA = "\033[35m"
|
||||
_C_GREEN = "\033[32m"
|
||||
_C_DIM = "\033[2m"
|
||||
_C_RESET = "\033[0m"
|
||||
|
||||
_MODE_TAGS = {
|
||||
"sim": ("[Sim]", _C_MAGENTA),
|
||||
"reader": ("[Rdr]", _C_CYAN),
|
||||
"sniff": ("[Snf]", ""),
|
||||
}
|
||||
|
||||
|
||||
class TraceFormatter:
|
||||
"""Colored, decoded, column-wrapped trace output.
|
||||
|
||||
Args:
|
||||
mode: "sim", "reader", or "sniff"
|
||||
protocol: "15693" or "14443a" (selects decoder)
|
||||
width: override terminal width (None = auto-detect)
|
||||
is_tty: override TTY detection (None = auto-detect)
|
||||
"""
|
||||
|
||||
def __init__(self, mode: str = "sim", protocol: str = "15693",
|
||||
width: int | None = None, is_tty: bool | None = None):
|
||||
self._mode = mode
|
||||
self._protocol = protocol
|
||||
self._width = width or self._detect_width()
|
||||
self._is_tty = is_tty if is_tty is not None else sys.stdout.isatty()
|
||||
self._line_count = 0
|
||||
|
||||
# Try to register SIGWINCH for dynamic resize
|
||||
if width is None:
|
||||
try:
|
||||
signal.signal(signal.SIGWINCH, self._on_resize)
|
||||
except (OSError, ValueError):
|
||||
pass # not main thread or not Unix
|
||||
|
||||
def _detect_width(self) -> int:
|
||||
try:
|
||||
return os.get_terminal_size().columns
|
||||
except (OSError, ValueError):
|
||||
return 80
|
||||
|
||||
def _on_resize(self, signum, frame):
|
||||
self._width = self._detect_width()
|
||||
|
||||
def _color(self, code: str, text: str) -> str:
|
||||
if not self._is_tty or not code:
|
||||
return text
|
||||
return f"{code}{text}{_C_RESET}"
|
||||
|
||||
def format(self, direction: int, payload: bytes) -> str:
|
||||
"""Format a trace line with colors, decoding, and wrapping."""
|
||||
# Re-check width periodically if no SIGWINCH
|
||||
self._line_count += 1
|
||||
if self._line_count % 10 == 0:
|
||||
try:
|
||||
self._width = self._detect_width()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Mode tag
|
||||
mode_tag, mode_color = _MODE_TAGS.get(self._mode, ("[???]", ""))
|
||||
|
||||
# Direction
|
||||
if direction == 0:
|
||||
arrow = "Reader \u2192 Tag:"
|
||||
dir_color = _C_CYAN
|
||||
else:
|
||||
arrow = "Tag \u2192 Reader:"
|
||||
dir_color = _C_YELLOW
|
||||
|
||||
# Build prefix (uncolored for width calc)
|
||||
prefix_plain = f"{mode_tag} {arrow} "
|
||||
prefix_len = len(prefix_plain)
|
||||
|
||||
# Colored prefix
|
||||
prefix_colored = self._color(mode_color, mode_tag) + " " + self._color(dir_color, arrow) + " "
|
||||
|
||||
# Hex bytes (space-separated, uppercase)
|
||||
hex_str = " ".join(f"{b:02X}" for b in payload)
|
||||
|
||||
# Decode annotation
|
||||
if self._protocol == "15693":
|
||||
annotation = decode_15693(direction, payload)
|
||||
elif self._protocol == "14443a":
|
||||
annotation = decode_14443a(direction, payload)
|
||||
else:
|
||||
annotation = None
|
||||
|
||||
# Layout: determine if everything fits on one line
|
||||
avail = self._width - prefix_len
|
||||
if annotation:
|
||||
one_line = f"{hex_str} {annotation}"
|
||||
else:
|
||||
one_line = hex_str
|
||||
|
||||
if len(one_line) <= avail:
|
||||
# Single line
|
||||
hex_colored = self._color(_C_DIM, hex_str)
|
||||
if annotation:
|
||||
ann_colored = self._color(_C_GREEN, annotation)
|
||||
line = f"{prefix_colored}{hex_colored} {ann_colored}"
|
||||
else:
|
||||
line = f"{prefix_colored}{hex_colored}"
|
||||
return f"\n{line}"
|
||||
|
||||
# Multi-line: wrap hex, annotation on its own line
|
||||
pad = " " * prefix_len
|
||||
hex_lines = self._wrap_hex(hex_str, avail)
|
||||
parts = [f"{prefix_colored}{self._color(_C_DIM, hex_lines[0])}"]
|
||||
for hl in hex_lines[1:]:
|
||||
parts.append(f"{pad}{self._color(_C_DIM, hl)}")
|
||||
if annotation:
|
||||
parts.append(f"{pad}{self._color(_C_GREEN, annotation)}")
|
||||
|
||||
return "\n" + "\n".join(parts)
|
||||
|
||||
def _wrap_hex(self, hex_str: str, avail: int) -> list[str]:
|
||||
"""Wrap space-separated hex string into lines of at most `avail` chars."""
|
||||
tokens = hex_str.split(" ")
|
||||
lines = []
|
||||
current = ""
|
||||
for tok in tokens:
|
||||
candidate = f"{current} {tok}" if current else tok
|
||||
if len(candidate) <= avail:
|
||||
current = candidate
|
||||
else:
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = tok
|
||||
if current:
|
||||
lines.append(current)
|
||||
return lines or [""]
|
||||
|
||||
def print(self, direction: int, payload: bytes) -> None:
|
||||
"""Format and print a trace line to stdout."""
|
||||
sys.stdout.write(self.format(direction, payload))
|
||||
sys.stdout.flush()
|
||||
```
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py -v`
|
||||
Expected: all PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/work/pm3py/.worktrees/sim-framework
|
||||
git add pm3py/sim/trace_fmt.py tests/test_sim_trace_fmt.py
|
||||
git commit --no-gpg-sign -m "feat(trace): add TraceFormatter with color, wrapping, terminal resize"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Wire TraceFormatter into SimSession
|
||||
|
||||
**Files:**
|
||||
- Modify: `pm3py/sim/sim_session.py:1-2,126-175,177-224`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
Append to `tests/test_sim_trace_fmt.py`:
|
||||
|
||||
```python
|
||||
from unittest.mock import MagicMock, patch
|
||||
import struct
|
||||
|
||||
from pm3py.sim.sim_session import SimSession, CMD_HF_ISO15693_SIM_TRACE
|
||||
from pm3py.transport import encode_ng_frame, RESP_PREAMBLE_MAGIC, RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE
|
||||
|
||||
|
||||
class TestSimSessionTrace:
|
||||
"""Verify SimSession uses TraceFormatter instead of raw print."""
|
||||
|
||||
def _make_trace_frame(self, direction: int, payload: bytes) -> bytes:
|
||||
"""Build a fake firmware trace response frame."""
|
||||
from pm3py.transport import RESP_POSTAMBLE_MAGIC
|
||||
data = bytes([direction]) + payload
|
||||
length = len(data) | 0x8000 # NG bit set
|
||||
header = struct.pack("<IHHI",
|
||||
RESP_PREAMBLE_MAGIC,
|
||||
length,
|
||||
0, # status
|
||||
CMD_HF_ISO15693_SIM_TRACE)
|
||||
postamble = struct.pack("<H", RESP_POSTAMBLE_MAGIC)
|
||||
return header + data + postamble
|
||||
|
||||
@patch("pm3py.sim.sim_session.TraceFormatter")
|
||||
def test_trace_reader_uses_formatter(self, MockFormatter):
|
||||
"""_trace_reader should call formatter.print() not raw print()."""
|
||||
mock_fmt = MockFormatter.return_value
|
||||
session = SimSession()
|
||||
session._formatter = mock_fmt
|
||||
session._active = True
|
||||
|
||||
# Build a trace frame for: reader->tag inventory
|
||||
frame = self._make_trace_frame(0, bytes([0x26, 0x01, 0x00]))
|
||||
|
||||
# Mock serial that returns frame then stops
|
||||
mock_serial = MagicMock()
|
||||
mock_serial.in_waiting = len(frame)
|
||||
call_count = 0
|
||||
|
||||
def read_side_effect(n):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return frame
|
||||
session._active = False
|
||||
return b""
|
||||
|
||||
mock_serial.read.side_effect = read_side_effect
|
||||
|
||||
session._trace_reader(mock_serial)
|
||||
mock_fmt.print.assert_called_once_with(0, bytes([0x26, 0x01, 0x00]))
|
||||
```
|
||||
|
||||
**Step 2: Run to verify failure**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py::TestSimSessionTrace -v`
|
||||
Expected: FAIL — `ImportError` or `AttributeError` (no `TraceFormatter` import in sim_session)
|
||||
|
||||
**Step 3: Modify sim_session.py**
|
||||
|
||||
Add import at top of `sim_session.py`:
|
||||
|
||||
```python
|
||||
from .trace_fmt import TraceFormatter
|
||||
```
|
||||
|
||||
In `start_15693()`, replace the trace thread creation (around line 167-171) to instantiate the formatter:
|
||||
|
||||
```python
|
||||
if trace:
|
||||
self._formatter = TraceFormatter(mode="sim", protocol="15693")
|
||||
self._trace_thread = threading.Thread(
|
||||
target=self._trace_reader, args=(ser,), daemon=True
|
||||
)
|
||||
self._trace_thread.start()
|
||||
```
|
||||
|
||||
In `_trace_reader()`, replace line 216-217:
|
||||
|
||||
```python
|
||||
arrow = "Reader → Tag" if direction == 0 else "Tag → Reader"
|
||||
print(f"[Trace] {arrow}: {payload.hex()}")
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```python
|
||||
self._formatter.print(direction, payload)
|
||||
```
|
||||
|
||||
**Step 4: Run all tests**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_trace_fmt.py -v`
|
||||
Expected: all PASS
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/ -v`
|
||||
Expected: all PASS (no regressions)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/work/pm3py/.worktrees/sim-framework
|
||||
git add pm3py/sim/sim_session.py pm3py/sim/trace_fmt.py tests/test_sim_trace_fmt.py
|
||||
git commit --no-gpg-sign -m "feat(trace): wire TraceFormatter into SimSession"
|
||||
```
|
||||
677
docs/plans/2026-03-18-ndef-trace-decode.md
Normal file
677
docs/plans/2026-03-18-ndef-trace-decode.md
Normal file
@@ -0,0 +1,677 @@
|
||||
# NDEF Trace Decode Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Detect and decode NDEF content in ISO 15693 trace output — both sim trace and sniff — showing human-readable NDEF records in the annotation column, with overflow on the next line.
|
||||
|
||||
**Architecture:** Add an `decode_ndef()` function that parses NDEF TLV + records from block data. Wire it into `decode_15693_response()` so READ SINGLE BLOCK and READ MULTIPLE BLOCKS responses that contain NDEF data show decoded content. CC (block 0) gets its own annotation. Overflow annotations wrap to a continuation line at the annotation column.
|
||||
|
||||
**Tech Stack:** Python, pytest, existing hf_15.py decoder + sim trace_fmt.py
|
||||
|
||||
**Working directory:** `/home/work/pm3py/.worktrees/sim-framework/`
|
||||
|
||||
**Test command:** `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py tests/test_sim_trace_fmt.py -v`
|
||||
|
||||
---
|
||||
|
||||
### Context for implementer
|
||||
|
||||
The trace formatter produces lines like:
|
||||
|
||||
```
|
||||
[Sim] Tag → Reader: 00 E1 40 0D 01 C1 C0 OK [4B]
|
||||
[Sim] Tag → Reader: 00 03 0B D1 01 07 54 02 65 6E 74 65 73 74 FE 00 ... FD 02 OK [60B]
|
||||
```
|
||||
|
||||
The response decoder (`decode_15693_response`) currently sees all success responses as either inventory (9B data), empty, or generic `OK [NB]`. We want to detect and decode:
|
||||
|
||||
1. **CC block** (block 0): `E1 40 XX YY` → `CC v1.0 MLEN=XX MBREAD=Y`
|
||||
2. **NDEF TLV**: `03 LL <message> FE` → decode the NDEF records inside
|
||||
3. **NDEF records**: Text → `NDEF Text "en" "hello"`, URI → `NDEF URI https://example.com`, Media → `NDEF MIME text/plain [5B]`
|
||||
|
||||
When the annotation is longer than the available space, it wraps to a continuation line right-justified at the annotation column (same pattern the hex wrapping already uses).
|
||||
|
||||
**Tricky parts:**
|
||||
|
||||
- The decoder only sees the response payload (after flags byte), with no context about WHICH command produced it. It must detect CC/NDEF purely from the data pattern.
|
||||
- READ MULTIPLE BLOCKS responses can contain block 0 (CC) followed by NDEF data. The CC is in the first 4 bytes, NDEF TLV starts at byte 4.
|
||||
- READ SINGLE BLOCK #0 response is just 4 bytes of CC data.
|
||||
- We need both `hf_15.py` (sniff decoder) and `sim/trace_fmt.py` (sim decoder) to share the NDEF decode logic. Put the shared code in `hf_15.py` since that's where the 15693 decoders live; the sim trace_fmt calls into it via `decode_15693`.
|
||||
|
||||
**NFC Forum Type 5 CC format (4 bytes):**
|
||||
- Byte 0: 0xE1 = NDEF magic
|
||||
- Byte 1: version (upper nibble = major, lower = minor) + access bits
|
||||
- Byte 2: MLEN (data area size / 8)
|
||||
- Byte 3: feature flags (bit 0 = MBREAD)
|
||||
|
||||
**NDEF TLV format:**
|
||||
- Type 0x03 = NDEF message, 0xFE = terminator, 0x00 = NULL (skip)
|
||||
- Length: 1 byte if < 0xFF, else 0xFF + 2 bytes big-endian
|
||||
- Value: raw NDEF message bytes
|
||||
|
||||
**NDEF record format:**
|
||||
- Byte 0: flags (MB=0x80, ME=0x40, CF=0x20, SR=0x10, IL=0x08, TNF=0x07)
|
||||
- Byte 1: TYPE_LENGTH
|
||||
- If SR: byte 2 = PAYLOAD_LENGTH (1 byte), else bytes 2-5 = PAYLOAD_LENGTH (4 bytes BE)
|
||||
- If IL: next byte = ID_LENGTH
|
||||
- TYPE (TYPE_LENGTH bytes)
|
||||
- ID (ID_LENGTH bytes, if IL set)
|
||||
- PAYLOAD (PAYLOAD_LENGTH bytes)
|
||||
|
||||
**Well-known RTD types:**
|
||||
- "T" (0x54): Text record — payload[0] = lang_len, payload[1:1+lang_len] = lang, rest = text
|
||||
- "U" (0x55): URI record — payload[0] = prefix code, rest = URI suffix
|
||||
|
||||
---
|
||||
|
||||
### Task 1: NDEF TLV + record decoder
|
||||
|
||||
**Files:**
|
||||
- Modify: `pm3py/hf_15.py`
|
||||
- Modify: `tests/test_hf_15.py`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
Append to `tests/test_hf_15.py`:
|
||||
|
||||
```python
|
||||
from pm3py.hf_15 import decode_ndef_annotation
|
||||
|
||||
# ---- NDEF decode ----
|
||||
|
||||
class TestDecodeNdefAnnotation:
|
||||
def test_cc_block(self):
|
||||
"""4-byte CC recognized and decoded."""
|
||||
data = bytes([0xE1, 0x40, 0x0D, 0x01])
|
||||
ann = decode_ndef_annotation(data)
|
||||
assert ann is not None
|
||||
assert "CC" in ann
|
||||
assert "v1.0" in ann
|
||||
assert "MBREAD" in ann
|
||||
|
||||
def test_cc_no_mbread(self):
|
||||
data = bytes([0xE1, 0x40, 0x10, 0x00])
|
||||
ann = decode_ndef_annotation(data)
|
||||
assert "CC" in ann
|
||||
assert "MBREAD" not in ann
|
||||
|
||||
def test_ndef_text_record(self):
|
||||
"""NDEF TLV with Text record."""
|
||||
# TLV: 03 0B <ndef> FE
|
||||
# Record: D1 01 07 54 02 65 6E 74 65 73 74
|
||||
# MB+ME+SR, TNF=well-known, type="T", lang="en", text="test"
|
||||
msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E,
|
||||
0x74, 0x65, 0x73, 0x74])
|
||||
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
|
||||
ann = decode_ndef_annotation(tlv)
|
||||
assert ann is not None
|
||||
assert '"test"' in ann
|
||||
|
||||
def test_ndef_uri_record(self):
|
||||
"""NDEF TLV with URI record."""
|
||||
# URI: https://example.com → prefix 0x04 + "example.com"
|
||||
uri_payload = bytes([0x04]) + b"example.com"
|
||||
rec = bytes([0xD1, 0x01, len(uri_payload), 0x55]) + uri_payload
|
||||
tlv = bytes([0x03, len(rec)]) + rec + bytes([0xFE])
|
||||
ann = decode_ndef_annotation(tlv)
|
||||
assert "https://example.com" in ann
|
||||
|
||||
def test_ndef_mime_record(self):
|
||||
"""NDEF TLV with MIME record."""
|
||||
mime_type = b"text/plain"
|
||||
payload = b"hello"
|
||||
rec = bytes([0xD2, len(mime_type), len(payload)]) + mime_type + payload
|
||||
tlv = bytes([0x03, len(rec)]) + rec + bytes([0xFE])
|
||||
ann = decode_ndef_annotation(tlv)
|
||||
assert "text/plain" in ann
|
||||
|
||||
def test_no_ndef_magic(self):
|
||||
"""Random data without NDEF TLV returns None."""
|
||||
ann = decode_ndef_annotation(bytes([0x00, 0x00, 0x00, 0x00]))
|
||||
assert ann is None
|
||||
|
||||
def test_cc_plus_ndef(self):
|
||||
"""Data starting with CC followed by NDEF TLV (READ MULTIPLE from block 0)."""
|
||||
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
|
||||
msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E,
|
||||
0x74, 0x65, 0x73, 0x74])
|
||||
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
|
||||
data = cc + tlv + bytes(50) # pad like real response
|
||||
ann = decode_ndef_annotation(data)
|
||||
assert "CC" in ann
|
||||
assert '"test"' in ann
|
||||
|
||||
def test_empty_ndef(self):
|
||||
"""NDEF TLV with zero length."""
|
||||
tlv = bytes([0x03, 0x00, 0xFE])
|
||||
ann = decode_ndef_annotation(tlv)
|
||||
assert ann is not None
|
||||
assert "empty" in ann.lower() or "0B" in ann or "NDEF" in ann
|
||||
|
||||
def test_long_text_truncated(self):
|
||||
"""Long text is truncated in annotation."""
|
||||
long_text = "A" * 200
|
||||
msg = ndef_text(long_text)
|
||||
tlv = bytes([0x03, 0xFF, (len(msg) >> 8) & 0xFF, len(msg) & 0xFF]) + msg + bytes([0xFE])
|
||||
ann = decode_ndef_annotation(tlv)
|
||||
assert ann is not None
|
||||
assert "..." in ann # truncated
|
||||
```
|
||||
|
||||
**Step 2: Run tests to see them fail**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestDecodeNdefAnnotation -v`
|
||||
Expected: FAIL — `ImportError: cannot import name 'decode_ndef_annotation'`
|
||||
|
||||
**Step 3: Implement decode_ndef_annotation**
|
||||
|
||||
Add to `pm3py/hf_15.py`, after the existing `URI_PREFIXES` dict (copy it from type5.py or import it), before the `_15693_CMDS` dict:
|
||||
|
||||
```python
|
||||
# ---- NDEF decode (for trace annotation) ----
|
||||
|
||||
# NFC Type 5 CC magic
|
||||
_NDEF_MAGIC = 0xE1
|
||||
|
||||
# URI prefix codes (NFC Forum RTD URI)
|
||||
_URI_PREFIXES = {
|
||||
0x00: "",
|
||||
0x01: "http://www.",
|
||||
0x02: "https://www.",
|
||||
0x03: "http://",
|
||||
0x04: "https://",
|
||||
0x05: "tel:",
|
||||
0x06: "mailto:",
|
||||
}
|
||||
|
||||
# TNF values
|
||||
_TNF_EMPTY = 0x00
|
||||
_TNF_WELL_KNOWN = 0x01
|
||||
_TNF_MEDIA = 0x02
|
||||
_TNF_URI = 0x03
|
||||
_TNF_EXTERNAL = 0x04
|
||||
|
||||
_MAX_ANN_TEXT = 40 # truncate decoded text in annotations
|
||||
|
||||
|
||||
def decode_ndef_annotation(data: bytes) -> str | None:
|
||||
"""Decode NDEF content from block data for trace annotation.
|
||||
|
||||
Handles:
|
||||
- CC block (4 bytes starting with 0xE1)
|
||||
- NDEF TLV (starting with 0x03)
|
||||
- CC + NDEF TLV (READ MULTIPLE from block 0)
|
||||
|
||||
Returns annotation string or None if not NDEF.
|
||||
"""
|
||||
if len(data) < 4:
|
||||
return None
|
||||
|
||||
parts = []
|
||||
offset = 0
|
||||
|
||||
# Check for CC at start
|
||||
if data[0] == _NDEF_MAGIC:
|
||||
ver_major = (data[1] >> 6) & 0x03
|
||||
ver_minor = (data[1] >> 4) & 0x03
|
||||
mlen = data[2]
|
||||
features = data[3]
|
||||
cc_str = f"CC v{ver_major}.{ver_minor} MLEN={mlen}"
|
||||
if features & 0x01:
|
||||
cc_str += " MBREAD"
|
||||
parts.append(cc_str)
|
||||
offset = 4
|
||||
if offset >= len(data):
|
||||
return cc_str
|
||||
|
||||
# Scan for NDEF TLV
|
||||
while offset < len(data):
|
||||
tlv_type = data[offset]
|
||||
offset += 1
|
||||
|
||||
if tlv_type == 0x00: # NULL TLV — skip
|
||||
continue
|
||||
if tlv_type == 0xFE: # Terminator
|
||||
break
|
||||
if tlv_type != 0x03: # Not NDEF — stop
|
||||
break
|
||||
|
||||
# Parse TLV length
|
||||
if offset >= len(data):
|
||||
break
|
||||
tlv_len = data[offset]
|
||||
offset += 1
|
||||
if tlv_len == 0xFF:
|
||||
if offset + 2 > len(data):
|
||||
break
|
||||
tlv_len = (data[offset] << 8) | data[offset + 1]
|
||||
offset += 2
|
||||
|
||||
if tlv_len == 0:
|
||||
parts.append("NDEF empty")
|
||||
continue
|
||||
|
||||
# Parse NDEF message (one or more records)
|
||||
msg_end = min(offset + tlv_len, len(data))
|
||||
records = _parse_ndef_records(data[offset:msg_end])
|
||||
for rec in records:
|
||||
parts.append(rec)
|
||||
offset = msg_end
|
||||
|
||||
return " | ".join(parts) if parts else None
|
||||
|
||||
|
||||
def _parse_ndef_records(msg: bytes) -> list[str]:
|
||||
"""Parse NDEF records from raw message bytes. Returns list of annotation strings."""
|
||||
records = []
|
||||
offset = 0
|
||||
|
||||
while offset < len(msg):
|
||||
if offset + 3 > len(msg):
|
||||
break
|
||||
|
||||
flags = msg[offset]
|
||||
tnf = flags & 0x07
|
||||
sr = bool(flags & 0x10)
|
||||
il = bool(flags & 0x08)
|
||||
type_len = msg[offset + 1]
|
||||
offset += 2
|
||||
|
||||
# Payload length
|
||||
if sr:
|
||||
if offset >= len(msg):
|
||||
break
|
||||
payload_len = msg[offset]
|
||||
offset += 1
|
||||
else:
|
||||
if offset + 4 > len(msg):
|
||||
break
|
||||
payload_len = int.from_bytes(msg[offset:offset + 4], "big")
|
||||
offset += 4
|
||||
|
||||
# ID length
|
||||
id_len = 0
|
||||
if il:
|
||||
if offset >= len(msg):
|
||||
break
|
||||
id_len = msg[offset]
|
||||
offset += 1
|
||||
|
||||
# Type
|
||||
if offset + type_len > len(msg):
|
||||
break
|
||||
rec_type = msg[offset:offset + type_len]
|
||||
offset += type_len
|
||||
|
||||
# ID (skip)
|
||||
offset += id_len
|
||||
|
||||
# Payload
|
||||
if offset + payload_len > len(msg):
|
||||
payload = msg[offset:]
|
||||
else:
|
||||
payload = msg[offset:offset + payload_len]
|
||||
offset += payload_len
|
||||
|
||||
# Decode based on TNF + type
|
||||
ann = _decode_ndef_record(tnf, rec_type, payload)
|
||||
records.append(ann)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _decode_ndef_record(tnf: int, rec_type: bytes, payload: bytes) -> str:
|
||||
"""Decode a single NDEF record into an annotation string."""
|
||||
if tnf == _TNF_WELL_KNOWN:
|
||||
if rec_type == b"T" and len(payload) >= 1:
|
||||
lang_len = payload[0] & 0x3F
|
||||
lang = payload[1:1 + lang_len].decode("ascii", errors="replace")
|
||||
text = payload[1 + lang_len:].decode("utf-8", errors="replace")
|
||||
if len(text) > _MAX_ANN_TEXT:
|
||||
text = text[:_MAX_ANN_TEXT] + "..."
|
||||
return f'NDEF Text "{lang}" "{text}"'
|
||||
if rec_type == b"U" and len(payload) >= 1:
|
||||
prefix = _URI_PREFIXES.get(payload[0], "")
|
||||
suffix = payload[1:].decode("utf-8", errors="replace")
|
||||
uri = prefix + suffix
|
||||
if len(uri) > _MAX_ANN_TEXT:
|
||||
uri = uri[:_MAX_ANN_TEXT] + "..."
|
||||
return f"NDEF URI {uri}"
|
||||
return f"NDEF WK type={rec_type!r} [{len(payload)}B]"
|
||||
|
||||
if tnf == _TNF_MEDIA:
|
||||
mime = rec_type.decode("ascii", errors="replace")
|
||||
if len(mime) > 30:
|
||||
mime = mime[:30] + "..."
|
||||
return f"NDEF MIME {mime} [{len(payload)}B]"
|
||||
|
||||
if tnf == _TNF_EXTERNAL:
|
||||
ext_type = rec_type.decode("ascii", errors="replace")
|
||||
return f"NDEF EXT {ext_type} [{len(payload)}B]"
|
||||
|
||||
if tnf == _TNF_EMPTY:
|
||||
return "NDEF empty"
|
||||
|
||||
return f"NDEF TNF={tnf} [{len(payload)}B]"
|
||||
```
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestDecodeNdefAnnotation -v`
|
||||
Expected: All pass
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/work/pm3py/.worktrees/sim-framework
|
||||
git add pm3py/hf_15.py tests/test_hf_15.py
|
||||
git commit --no-gpg-sign -m "feat: NDEF TLV + record decoder for trace annotations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Wire NDEF decode into response annotations
|
||||
|
||||
**Files:**
|
||||
- Modify: `pm3py/hf_15.py`
|
||||
- Modify: `tests/test_hf_15.py`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
Append to `tests/test_hf_15.py`:
|
||||
|
||||
```python
|
||||
class TestResponseNdefDecode:
|
||||
def test_read_single_block_0_cc(self):
|
||||
"""READ SINGLE BLOCK #0 response shows CC annotation."""
|
||||
# Response: flags(1) + block_data(4)
|
||||
data = bytes([0x00, 0xE1, 0x40, 0x0D, 0x01])
|
||||
ann = decode_15693_response(data)
|
||||
assert "CC" in ann
|
||||
assert "MBREAD" in ann
|
||||
|
||||
def test_read_multiple_blocks_ndef(self):
|
||||
"""READ MULTIPLE BLOCKS response with NDEF shows decoded text."""
|
||||
# Response: flags(1) + blocks data
|
||||
msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E,
|
||||
0x74, 0x65, 0x73, 0x74])
|
||||
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
|
||||
data = bytes([0x00]) + tlv + bytes(50)
|
||||
ann = decode_15693_response(data)
|
||||
assert '"test"' in ann
|
||||
|
||||
def test_read_multiple_with_cc_and_ndef(self):
|
||||
"""READ MULTIPLE from block 0: CC + NDEF both decoded."""
|
||||
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
|
||||
msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E,
|
||||
0x74, 0x65, 0x73, 0x74])
|
||||
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
|
||||
data = bytes([0x00]) + cc + tlv + bytes(40)
|
||||
ann = decode_15693_response(data)
|
||||
assert "CC" in ann
|
||||
assert '"test"' in ann
|
||||
|
||||
def test_generic_data_no_ndef(self):
|
||||
"""Non-NDEF data still shows OK [NB]."""
|
||||
data = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF])
|
||||
ann = decode_15693_response(data)
|
||||
assert "OK [4B]" in ann
|
||||
|
||||
def test_inventory_not_affected(self):
|
||||
"""Inventory response still decoded as inventory, not NDEF."""
|
||||
data = bytes([0x00, 0x00]) + bytes(8)
|
||||
ann = decode_15693_response(data)
|
||||
assert "INVENTORY" in ann
|
||||
```
|
||||
|
||||
**Step 2: Run tests to see them fail**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestResponseNdefDecode -v`
|
||||
Expected: FAIL — `"CC"` not in `"OK [4B]"`
|
||||
|
||||
**Step 3: Update decode_15693_response**
|
||||
|
||||
Replace the generic response handler section in `decode_15693_response` (the part after error check, starting at `data = payload[1:]`):
|
||||
|
||||
```python
|
||||
def decode_15693_response(payload: bytes) -> str | None:
|
||||
"""Decode an ISO 15693 response frame into annotation."""
|
||||
if len(payload) < 1:
|
||||
return None
|
||||
|
||||
flags = payload[0]
|
||||
|
||||
if flags & 0x01: # Error
|
||||
if len(payload) >= 2:
|
||||
code = payload[1]
|
||||
desc = _15693_ERRORS.get(code, "")
|
||||
return f"ERROR 0x{code:02X} {desc}" if desc else f"ERROR 0x{code:02X}"
|
||||
return "ERROR"
|
||||
|
||||
data = payload[1:]
|
||||
if len(data) == 0:
|
||||
return "OK"
|
||||
|
||||
# Inventory response: dsfid(1) + uid(8) = 9 bytes
|
||||
if len(data) == 9:
|
||||
uid_msb = bytes(reversed(data[1:9]))
|
||||
return f"OK INVENTORY UID={uid_msb.hex().upper()}"
|
||||
|
||||
# Try NDEF decode (CC block, NDEF TLV, or both)
|
||||
ndef_ann = decode_ndef_annotation(data)
|
||||
if ndef_ann:
|
||||
return f"OK {ndef_ann}"
|
||||
|
||||
return f"OK [{len(data)}B]"
|
||||
```
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py -v`
|
||||
Expected: All pass (including existing tests — verify `test_decode_response_ok_data` still passes since it uses non-NDEF data)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/work/pm3py/.worktrees/sim-framework
|
||||
git add pm3py/hf_15.py tests/test_hf_15.py
|
||||
git commit --no-gpg-sign -m "feat: wire NDEF decode into ISO 15693 response annotations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Annotation overflow wrapping
|
||||
|
||||
**Files:**
|
||||
- Modify: `pm3py/hf_15.py` (sniff formatter)
|
||||
- Modify: `pm3py/sim/trace_fmt.py` (sim formatter)
|
||||
- Modify: `tests/test_hf_15.py`
|
||||
- Modify: `tests/test_sim_trace_fmt.py`
|
||||
|
||||
The current formatters already wrap hex to multiple lines when it's too wide. But annotations are either right-justified on the last hex line (short) or on their own line (wrapped hex). NDEF annotations can be long (e.g. `OK CC v1.0 MLEN=13 MBREAD | NDEF Text "en" "test"`). When the annotation overflows the available width, it should wrap to the next line at the annotation column.
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
In `tests/test_hf_15.py`, append:
|
||||
|
||||
```python
|
||||
class TestSniffLineAnnotationOverflow:
|
||||
def test_long_annotation_wraps(self):
|
||||
"""Long NDEF annotation wraps to continuation line."""
|
||||
# Build a response with CC + NDEF text that produces a long annotation
|
||||
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
|
||||
long_text = "A" * 30
|
||||
from pm3py.sim.type5 import ndef_text
|
||||
msg = ndef_text(long_text)
|
||||
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
|
||||
payload = bytes([0x00]) + cc + tlv
|
||||
# Add CRC
|
||||
payload += bytes([0xAA, 0xBB])
|
||||
|
||||
entry = {"is_response": True, "data": payload,
|
||||
"timestamp": 0, "duration": 0, "parity": b""}
|
||||
line = format_sniff_line(entry, width=80, is_tty=False)
|
||||
lines = line.split("\n")
|
||||
# Annotation should be on a separate continuation line
|
||||
ann_lines = [l for l in lines if "NDEF" in l]
|
||||
assert len(ann_lines) >= 1
|
||||
|
||||
def test_short_annotation_no_wrap(self):
|
||||
"""Short annotation stays on same line as hex."""
|
||||
data = bytes([0x00, 0xE1, 0x40, 0x0D, 0x01, 0xAA, 0xBB])
|
||||
entry = {"is_response": True, "data": data,
|
||||
"timestamp": 0, "duration": 0, "parity": b""}
|
||||
line = format_sniff_line(entry, width=120, is_tty=False)
|
||||
assert "CC" in line
|
||||
# Should be single-line (plus possible hex wrap)
|
||||
non_empty = [l for l in line.split("\n") if l.strip()]
|
||||
assert len(non_empty) <= 2 # hex + annotation on same or next line
|
||||
```
|
||||
|
||||
In `tests/test_sim_trace_fmt.py`, append to `TestTraceFormatterWrapping`:
|
||||
|
||||
```python
|
||||
def test_long_annotation_wraps_to_next_line(self):
|
||||
"""Long NDEF annotation wraps to continuation line."""
|
||||
# Build response with CC + NDEF that gives a long annotation
|
||||
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
|
||||
from pm3py.sim.type5 import ndef_text
|
||||
msg = ndef_text("A" * 30)
|
||||
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
|
||||
payload = bytes([0x00]) + cc + tlv
|
||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=80)
|
||||
result = self._strip_ansi(fmt.format(1, payload))
|
||||
lines = result.strip().split("\n")
|
||||
ann_lines = [l for l in lines if "NDEF" in l]
|
||||
assert len(ann_lines) >= 1
|
||||
```
|
||||
|
||||
**Step 2: Run tests to see them fail**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestSniffLineAnnotationOverflow tests/test_sim_trace_fmt.py::TestTraceFormatterWrapping::test_long_annotation_wraps_to_next_line -v`
|
||||
Expected: Might pass or fail depending on current wrapping behavior — the annotation might already go on its own line if hex is wrapped. Run to check.
|
||||
|
||||
**Step 3: Update annotation wrapping in both formatters**
|
||||
|
||||
In `format_sniff_line()` in `hf_15.py`, replace the annotation handling in the multi-line section (the `if annotation:` block near the end):
|
||||
|
||||
```python
|
||||
if annotation:
|
||||
# Wrap annotation if it exceeds available width
|
||||
ann_lines = _wrap_annotation(annotation, avail)
|
||||
for al in ann_lines:
|
||||
ann_pad = max(avail - len(al), 0)
|
||||
parts.append(f"{pad}{' ' * ann_pad}{_color(ann_color, al, is_tty)}")
|
||||
```
|
||||
|
||||
And add the helper (shared between both formatters):
|
||||
|
||||
```python
|
||||
def _wrap_annotation(annotation: str, avail: int) -> list[str]:
|
||||
"""Wrap annotation text to fit within avail columns.
|
||||
|
||||
Splits on ' | ' boundaries first, then by word if still too long.
|
||||
"""
|
||||
if len(annotation) <= avail:
|
||||
return [annotation]
|
||||
|
||||
# Try splitting on pipe separator (NDEF uses " | " between parts)
|
||||
if " | " in annotation:
|
||||
parts = annotation.split(" | ")
|
||||
lines = []
|
||||
current = parts[0]
|
||||
for part in parts[1:]:
|
||||
candidate = f"{current} | {part}"
|
||||
if len(candidate) <= avail:
|
||||
current = candidate
|
||||
else:
|
||||
lines.append(current)
|
||||
current = part
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
# Fall back to truncation
|
||||
return [annotation[:avail - 3] + "..."]
|
||||
```
|
||||
|
||||
In `trace_fmt.py`, apply the same pattern to the `TraceFormatter.format()` method's multi-line annotation section. Import `_wrap_annotation` from `hf_15` or duplicate the logic inline:
|
||||
|
||||
In the multi-line section of `TraceFormatter.format()`, replace:
|
||||
|
||||
```python
|
||||
if annotation:
|
||||
ann_pad = avail - len(annotation)
|
||||
ann_pad = max(ann_pad, 0)
|
||||
parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, annotation)}")
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```python
|
||||
if annotation:
|
||||
ann_lines = self._wrap_annotation(annotation, avail)
|
||||
for al in ann_lines:
|
||||
ann_pad = max(avail - len(al), 0)
|
||||
parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, al)}")
|
||||
```
|
||||
|
||||
And add to `TraceFormatter`:
|
||||
|
||||
```python
|
||||
def _wrap_annotation(self, annotation: str, avail: int) -> list[str]:
|
||||
"""Wrap annotation to fit within avail columns."""
|
||||
if len(annotation) <= avail:
|
||||
return [annotation]
|
||||
if " | " in annotation:
|
||||
parts = annotation.split(" | ")
|
||||
lines = []
|
||||
current = parts[0]
|
||||
for part in parts[1:]:
|
||||
candidate = f"{current} | {part}"
|
||||
if len(candidate) <= avail:
|
||||
current = candidate
|
||||
else:
|
||||
lines.append(current)
|
||||
current = part
|
||||
lines.append(current)
|
||||
return lines
|
||||
return [annotation[:avail - 3] + "..."]
|
||||
```
|
||||
|
||||
**Step 4: Run all tests**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py tests/test_sim_trace_fmt.py -v`
|
||||
Expected: All pass
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/work/pm3py/.worktrees/sim-framework
|
||||
git add pm3py/hf_15.py pm3py/sim/trace_fmt.py tests/test_hf_15.py tests/test_sim_trace_fmt.py
|
||||
git commit --no-gpg-sign -m "feat: annotation overflow wrapping for NDEF decode in trace output"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Copy hf_15.py to main branch + final test
|
||||
|
||||
**Files:**
|
||||
- Copy: `pm3py/hf_15.py` → `/home/work/pm3py/pm3py/hf_15.py`
|
||||
- Copy: `tests/test_hf_15.py` → `/home/work/pm3py/tests/test_hf_15.py`
|
||||
|
||||
**Step 1: Copy files**
|
||||
|
||||
```bash
|
||||
cp /home/work/pm3py/.worktrees/sim-framework/pm3py/hf_15.py /home/work/pm3py/pm3py/hf_15.py
|
||||
cp /home/work/pm3py/.worktrees/sim-framework/tests/test_hf_15.py /home/work/pm3py/tests/test_hf_15.py
|
||||
```
|
||||
|
||||
**Step 2: Run main branch tests**
|
||||
|
||||
Run: `cd /home/work/pm3py && python -m pytest tests/ -v`
|
||||
Expected: All pass
|
||||
|
||||
**Step 3: Run worktree full test suite**
|
||||
|
||||
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/ -v`
|
||||
Expected: All pass
|
||||
333
docs/plans/2026-03-19-live-sniff-design.md
Normal file
333
docs/plans/2026-03-19-live-sniff-design.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Live Sniff Streaming — Design
|
||||
|
||||
## Overview
|
||||
|
||||
Rewrite sniff workflow from batch (sniff → button stop → download → decode) to live
|
||||
streaming with button-toggle pause/resume. User types `session.start_15693()` in the
|
||||
REPL, sees trace output in real-time, toggles sniffing with the PM3 button, and
|
||||
accumulates entries across pause/resume cycles.
|
||||
|
||||
Three changes:
|
||||
1. **Auto-load** — `.pythonstartup.py` creates a `session` object in sniff mode
|
||||
2. **Live streaming** — firmware flushes trace entries over USB on RF idle
|
||||
3. **Button-toggle** — firmware pause/resume within a persistent sniff session
|
||||
|
||||
## Protocol
|
||||
|
||||
### New command IDs (`pm3_cmd.h`)
|
||||
|
||||
| Command | Value | Direction | Purpose |
|
||||
|---------|-------|-----------|---------|
|
||||
| `CMD_HF_SNIFF_STREAM` | 0x0337 | FW→Host | Fire-and-forget trace entry (like `SIM_TRACE`) |
|
||||
| `CMD_HF_SNIFF_STATUS` | 0x0338 | FW→Host | State change: started / paused / resumed / stopped |
|
||||
|
||||
### Sniff command payload
|
||||
|
||||
Sent with existing `CMD_HF_ISO15693_SNIFF` (and future `CMD_HF_ISO14443A_SNIFF`).
|
||||
Legacy behavior preserved when `flags=0`.
|
||||
|
||||
```c
|
||||
struct sniff_params_t {
|
||||
uint16_t idle_timeout_ms; // flush threshold (default 400ms for 15693)
|
||||
uint8_t flags; // bit 0: live streaming (1=stream, 0=legacy BigBuf)
|
||||
// bit 1: button-toggle (1=pause/resume, 0=exit on press)
|
||||
} PACKED;
|
||||
```
|
||||
|
||||
### Stream trace payload (`CMD_HF_SNIFF_STREAM`)
|
||||
|
||||
```c
|
||||
struct sniff_trace_t {
|
||||
uint8_t protocol; // 0=15693, 1=14443A
|
||||
uint8_t direction; // 0=reader->tag, 1=tag->reader
|
||||
uint16_t duration; // frame duration ticks
|
||||
uint32_t timestamp; // us since sniff start
|
||||
uint8_t data[]; // frame bytes (variable length)
|
||||
} PACKED;
|
||||
```
|
||||
|
||||
### Status payload (`CMD_HF_SNIFF_STATUS`)
|
||||
|
||||
```c
|
||||
struct sniff_status_t {
|
||||
uint8_t state; // 0=started, 1=paused, 2=resumed, 3=stopped
|
||||
uint16_t count; // total frames captured so far
|
||||
} PACKED;
|
||||
```
|
||||
|
||||
## Firmware changes
|
||||
|
||||
### `iso15693.c` — `SniffIso15693()`
|
||||
|
||||
Current function signature gains parameters from the new payload. When
|
||||
`flags & SNIFF_FLAG_STREAMING` is unset, existing behavior is unchanged.
|
||||
|
||||
#### Streaming + button-toggle flow
|
||||
|
||||
```
|
||||
SniffIso15693(idle_timeout_ms, flags):
|
||||
if (!(flags & SNIFF_FLAG_STREAMING)):
|
||||
// existing behavior, unchanged
|
||||
return
|
||||
|
||||
// --- streaming mode ---
|
||||
ring_buf[TRACE_RING_SIZE] // 8-entry ring (same pattern as sim)
|
||||
trace_rd = trace_wr = 0
|
||||
state = SNIFF_ACTIVE
|
||||
frame_count = 0
|
||||
reply_ng(CMD_HF_SNIFF_STATUS, {state=0, count=0}) // "started"
|
||||
|
||||
while (state != SNIFF_STOPPED):
|
||||
|
||||
// --- button toggle ---
|
||||
if (BUTTON_PRESS()):
|
||||
debounce(200ms)
|
||||
if (state == SNIFF_ACTIVE):
|
||||
TRACE_FLUSH()
|
||||
state = SNIFF_PAUSED
|
||||
reply_ng(CMD_HF_SNIFF_STATUS, {state=1, count=frame_count})
|
||||
LED_C_OFF()
|
||||
else:
|
||||
state = SNIFF_ACTIVE
|
||||
reply_ng(CMD_HF_SNIFF_STATUS, {state=2, count=frame_count})
|
||||
LED_C_ON()
|
||||
|
||||
// --- paused: spin ---
|
||||
if (state == SNIFF_PAUSED):
|
||||
WDT_HIT()
|
||||
// check for BREAK_LOOP (below)
|
||||
goto check_break
|
||||
|
||||
// --- existing DMA decode logic ---
|
||||
// on decoded frame:
|
||||
TRACE_PUSH(direction, data, len, timestamp, duration)
|
||||
frame_count++
|
||||
last_activity = GetTickCount()
|
||||
|
||||
// --- idle flush ---
|
||||
if (trace_wr != trace_rd &&
|
||||
(GetTickCount() - last_activity) > idle_timeout_ms):
|
||||
TRACE_FLUSH()
|
||||
|
||||
check_break:
|
||||
// --- Python stop ---
|
||||
if (data_available()):
|
||||
receive_ng(&rx)
|
||||
if (rx.cmd == CMD_BREAK_LOOP):
|
||||
TRACE_FLUSH()
|
||||
state = SNIFF_STOPPED
|
||||
|
||||
reply_ng(CMD_HF_SNIFF_STATUS, {state=3, count=frame_count})
|
||||
```
|
||||
|
||||
#### TRACE_PUSH / TRACE_FLUSH macros
|
||||
|
||||
Reuse the sim pattern. Each `TRACE_FLUSH` entry sent as:
|
||||
|
||||
```c
|
||||
reply_ng(CMD_HF_SNIFF_STREAM, PM3_SUCCESS, &sniff_trace_t, sizeof(hdr) + data_len);
|
||||
```
|
||||
|
||||
Payload is `sniff_trace_t` with protocol=0 (15693), direction, duration, timestamp,
|
||||
and the raw frame bytes.
|
||||
|
||||
#### LED feedback
|
||||
|
||||
| State | LED |
|
||||
|-------|-----|
|
||||
| Sniffing active | Green ON (Easy=A, RDV4=B) |
|
||||
| Frame captured | Orange blink (Easy=C, RDV4=A) |
|
||||
| Paused | Green OFF |
|
||||
|
||||
#### Ring buffer sizing
|
||||
|
||||
8 entries. At 15693 speeds (~10-20 frames/sec during active reader polling),
|
||||
8 entries buffer ~400-800ms of traffic. Flush on idle empties before overflow.
|
||||
|
||||
### `pm3_cmd.h` — New definitions
|
||||
|
||||
```c
|
||||
#define CMD_HF_SNIFF_STREAM 0x0337
|
||||
#define CMD_HF_SNIFF_STATUS 0x0338
|
||||
|
||||
#define SNIFF_FLAG_STREAMING 0x01
|
||||
#define SNIFF_FLAG_BUTTON_TOGGLE 0x02
|
||||
|
||||
#define SNIFF_STATE_STARTED 0
|
||||
#define SNIFF_STATE_PAUSED 1
|
||||
#define SNIFF_STATE_RESUMED 2
|
||||
#define SNIFF_STATE_STOPPED 3
|
||||
```
|
||||
|
||||
## Python changes
|
||||
|
||||
### `sniff/session.py` — `SniffSession` rewrite
|
||||
|
||||
Persistent session with background reader thread, mirroring `SimSession`.
|
||||
|
||||
```python
|
||||
class SniffSession:
|
||||
def __init__(self, ser):
|
||||
self._ser = ser # raw pyserial (like SimSession)
|
||||
self._active = False
|
||||
self._entries = [] # accumulated parsed trace entries
|
||||
self._reader_thread = None
|
||||
self._formatter = TraceFormatter()
|
||||
self._state = "idle" # idle / sniffing / paused / stopped
|
||||
|
||||
@classmethod
|
||||
def open(cls, port=None, baudrate=115200):
|
||||
"""Auto-detect PM3 port and open serial connection."""
|
||||
if port is None:
|
||||
port = find_pm3_port()
|
||||
ser = serial.Serial(port, baudrate, timeout=0.1)
|
||||
return cls(ser)
|
||||
|
||||
def start_15693(self, live=True, idle_timeout_ms=400):
|
||||
"""Start 15693 sniff session.
|
||||
|
||||
live=True: streaming mode with button-toggle pause/resume (default)
|
||||
live=False: legacy BigBuf mode, button exits
|
||||
"""
|
||||
self._protocol = 0
|
||||
flags = 0
|
||||
if live:
|
||||
flags |= SNIFF_FLAG_STREAMING | SNIFF_FLAG_BUTTON_TOGGLE
|
||||
payload = struct.pack("<HB", idle_timeout_ms, flags)
|
||||
self._send_ng(Cmd.HF_ISO15693_SNIFF, payload)
|
||||
self._active = True
|
||||
|
||||
if live:
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._stream_reader, daemon=True)
|
||||
self._reader_thread.start()
|
||||
else:
|
||||
# Legacy: block, download, decode (existing behavior)
|
||||
self._legacy_sniff()
|
||||
|
||||
def _stream_reader(self):
|
||||
"""Background thread: read and print trace frames live."""
|
||||
buf = bytearray()
|
||||
while self._active:
|
||||
chunk = self._ser.read(self._ser.in_waiting or 1)
|
||||
buf.extend(chunk)
|
||||
while self._try_decode_frame(buf):
|
||||
pass
|
||||
|
||||
def _try_decode_frame(self, buf):
|
||||
"""Decode one frame from buffer. Returns True if frame consumed."""
|
||||
# Scan for response magic 0x62334d50
|
||||
# CMD_HF_SNIFF_STREAM → parse, append to self._entries, print live
|
||||
# CMD_HF_SNIFF_STATUS → update self._state, print status line
|
||||
# CMD_HF_ISO15693_SNIFF (final) → self._active = False
|
||||
...
|
||||
|
||||
def stop(self):
|
||||
"""End sniff session from Python (sends BREAK_LOOP)."""
|
||||
self._send_ng(Cmd.BREAK_LOOP, b"")
|
||||
if self._reader_thread:
|
||||
self._reader_thread.join(timeout=2.0)
|
||||
self._active = False
|
||||
self._state = "stopped"
|
||||
|
||||
def close(self):
|
||||
"""Stop and close serial port."""
|
||||
if self._active:
|
||||
self.stop()
|
||||
self._ser.close()
|
||||
|
||||
@property
|
||||
def entries(self):
|
||||
"""All captured trace entries across pause/resume cycles."""
|
||||
return list(self._entries)
|
||||
|
||||
def clear(self):
|
||||
"""Clear accumulated entries."""
|
||||
self._entries.clear()
|
||||
```
|
||||
|
||||
#### Status output
|
||||
|
||||
```
|
||||
[Snf] >> Started
|
||||
[Snf] Reader -> Tag: 26 01 00 INVENTORY
|
||||
[Snf] Tag -> Reader: 00 E0 04 ... INVENTORY RESPONSE
|
||||
[Snf] || Paused (12 frames)
|
||||
[Snf] >> Resumed (12 frames)
|
||||
[Snf] [] Stopped (28 frames)
|
||||
```
|
||||
|
||||
### `.pythonstartup.py` — Auto-load session
|
||||
|
||||
In `sniff` mode (and `all` mode), auto-create session:
|
||||
|
||||
```python
|
||||
if _MODE in ("sniff", "all"):
|
||||
from pm3py.sniff.session import SniffSession
|
||||
# ... other sniff imports ...
|
||||
|
||||
try:
|
||||
session = SniffSession.open()
|
||||
print("Sniff session ready -- session.start_15693()")
|
||||
except Exception as e:
|
||||
print(f"Sniff session: {e}")
|
||||
```
|
||||
|
||||
No `pm3` client created in sniff mode — `session` owns the serial port exclusively,
|
||||
same pattern as sim mode.
|
||||
|
||||
## Protocol-agnostic design
|
||||
|
||||
The `sniff_trace_t.protocol` field and `sniff_params_t` payload structure are shared
|
||||
across protocols. Only 15693 is implemented now. To add 14443-A later:
|
||||
|
||||
1. Parse `sniff_params_t` in `SniffIso14443a()`
|
||||
2. Use same `CMD_HF_SNIFF_STREAM` / `CMD_HF_SNIFF_STATUS` commands
|
||||
3. Set `protocol=1` in trace entries
|
||||
4. Python `_stream_reader` dispatches to `decode_14a()` based on protocol field
|
||||
5. Add `session.start_14a(live=True, idle_timeout_ms=100)` — 14443-A is faster,
|
||||
shorter idle timeout appropriate
|
||||
|
||||
## Idle timeout rationale
|
||||
|
||||
15693 default: **400ms**. A typical inventory+read cycle completes in ~20-30ms. Reader
|
||||
poll interval is 50-100ms. 400ms = 4 missed polls = reader is definitely gone. Short
|
||||
enough that trace appears almost instantly after removing the device.
|
||||
|
||||
Configurable via `idle_timeout_ms` parameter for different reader behaviors.
|
||||
|
||||
## Legacy compatibility
|
||||
|
||||
`live=False` preserves existing behavior exactly:
|
||||
- `flags=0` in payload → firmware takes legacy path (unchanged code)
|
||||
- Python blocks, waits for button exit, downloads BigBuf, decodes batch
|
||||
- No background thread, no streaming
|
||||
|
||||
## REPL workflow
|
||||
|
||||
```
|
||||
$ source activate sniff
|
||||
$ python
|
||||
Sniff session ready -- session.start_15693()
|
||||
|
||||
>>> session.start_15693()
|
||||
[Snf] >> Started
|
||||
# hold phone to antenna...
|
||||
[Snf] Reader -> Tag: 26 01 00 INVENTORY
|
||||
[Snf] Tag -> Reader: 00 E0 04 ... INVENTORY RESPONSE
|
||||
[Snf] Reader -> Tag: 22 20 04 ... READ SINGLE BLOCK blk=0
|
||||
[Snf] Tag -> Reader: 00 E1 10 06 01 ... READ SINGLE BLOCK RESPONSE
|
||||
# pull phone away, 400ms later trace flushes
|
||||
# press button to pause
|
||||
[Snf] || Paused (12 frames)
|
||||
# examine data in REPL
|
||||
>>> session.entries[-1]
|
||||
{'timestamp': 42318, 'direction': 1, 'data': b'\x00\xe1\x10...', ...}
|
||||
# press button to resume for more samples
|
||||
[Snf] >> Resumed (12 frames)
|
||||
# done
|
||||
>>> session.stop()
|
||||
[Snf] [] Stopped (28 frames)
|
||||
>>> len(session.entries)
|
||||
28
|
||||
```
|
||||
1108
docs/plans/2026-03-19-live-sniff-plan.md
Normal file
1108
docs/plans/2026-03-19-live-sniff-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
131
docs/plans/2026-07-03-firmware-upstream-rebase-plan.md
Normal file
131
docs/plans/2026-07-03-firmware-upstream-rebase-plan.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Firmware Upstream Rebase Plan (2026-07-03)
|
||||
|
||||
Rebase the `firmware/` submodule (fork `dangerous-tac0s/proxmark3-pm3py`)
|
||||
onto current RRG `upstream/master` **without losing our sim / sniff /
|
||||
inventory patch**. Backed by a non-destructive dry-run (worktree + merge-tree).
|
||||
|
||||
## Current state (measured)
|
||||
|
||||
- **merge-base:** `c7086c227` (2026-02-20) — where we branched.
|
||||
- **our patch:** 33 commits on top of merge-base (main == `35db99fa5`).
|
||||
- **upstream ahead:** 1049 commits (tip `87388b389`, 2026-07-03) — ~4.5 months.
|
||||
- Firmware is on `main`; ALM branch already scrapped.
|
||||
|
||||
## Dry-run findings — the conflict surface is small and concentrated
|
||||
|
||||
A **squash-then-rebase** (all 33 commits applied as one combined diff, per
|
||||
`git merge-tree`) conflicts in **exactly 3 files**; everything else
|
||||
auto-merges or is untouched:
|
||||
|
||||
| File | Verdict | ours vs upstream (lines) |
|
||||
|------|---------|--------------------------|
|
||||
| `armsrc/iso15693.c` | ⚠️ real work — 13 hunks | +599/−146 vs +523/−214 |
|
||||
| `client/src/cmdhf15.c` | ⚠️ moderate | +372/−7 vs +544/−45 |
|
||||
| `include/pm3_cmd.h` | ✅ trivial (textual re-append) | +21/−0 vs +94/−113 |
|
||||
| `appmain.c`, `iclass.c`, `iso15693.h`, `iso15.h`, `Makefile` | ✅ auto-merge | — |
|
||||
| `sim_table.c/h`, `sim_crypto.c/h`, `Standalone/hf_15sniff.c`, `Standalone/hf_unisniff.c` | ✅ upstream never touched | — |
|
||||
|
||||
A **sequential** rebase (replay 33 commits) is worse: it conflicts on
|
||||
commit #1 and, because **24 of the 33 commits touch `iso15693.c`**, it
|
||||
re-conflicts that file many times. → **Squash first, resolve once.**
|
||||
|
||||
### CMD-ID collision check — CLEAR ✅
|
||||
|
||||
All six IDs we added are free in upstream (upstream's `0x033x` block only
|
||||
reaches `CSETUID_V2 0x0333`; `0x095x` is empty):
|
||||
|
||||
```
|
||||
0x0336 CMD_HF_ISO15693_SIM_TRACE 0x0950 CMD_SIM_TABLE_UPLOAD
|
||||
0x0337 CMD_HF_SNIFF_STREAM 0x0951 CMD_SIM_TABLE_CLEAR
|
||||
0x0338 CMD_HF_SNIFF_STATUS 0x0952 CMD_SIM_TABLE_UPDATE
|
||||
```
|
||||
|
||||
So `pm3_cmd.h` is a pure textual re-append (drop our `#define`s at a clean
|
||||
spot in the churned region), **not** a semantic collision.
|
||||
|
||||
### `iso15693.c` hunk map (13 hunks, 2 functions)
|
||||
|
||||
- **15693 sniff loop (~L1598–1853):** our streaming-sniff feature (ring
|
||||
buffer, button-toggle, idle flush) vs upstream sniff edits. Biggest hunk
|
||||
~53 lines (our idle/flush block) — mostly *keep-ours-add-both*.
|
||||
- **SimTag / sim loop (~L2475–3372):** our sim handler (table lookup,
|
||||
access control, random UID, periodic field report) + the `CheckCrc15`
|
||||
4-byte-frame fix vs upstream. ~10 hunks: three large (span 36–43, our
|
||||
inserted blocks → take-ours), the rest small (≤16) needing genuine
|
||||
line-level reconciliation — notably **our `CheckCrc15` vs upstream's
|
||||
`CalculateCrc15`** at ~L2591 (keep ours; it fixes 4-byte frames).
|
||||
|
||||
Effort estimate: **~half a day** of focused merge work, dominated by
|
||||
`iso15693.c`; `cmdhf15.c` moderate; `pm3_cmd.h` ~10 min.
|
||||
|
||||
## Strategy: squash-to-one → rebase (single 3-file resolution) → validate → re-split
|
||||
|
||||
Resolve conflicts exactly once (matching the merge-tree preview), validate
|
||||
the build, then restore clean per-file commits for cheap future rebases
|
||||
(the design-doc "atomic single-file commit" model).
|
||||
|
||||
### Procedure (all in an isolated worktree — real checkout never moves)
|
||||
|
||||
```bash
|
||||
FW=/home/work/pm3py/firmware
|
||||
WT=<scratchpad>/fw-rebase
|
||||
git -C $FW fetch upstream master
|
||||
git -C $FW worktree add -b rebase-wip "$WT" 35db99fa5
|
||||
|
||||
cd "$WT"
|
||||
# 1. collapse 33 commits into one diff on our base (no upstream yet → no conflicts)
|
||||
MB=$(git merge-base HEAD upstream/master)
|
||||
git reset --soft "$MB"
|
||||
git commit -m "pm3py sim+sniff+inventory patch (squashed for rebase)"
|
||||
|
||||
# 2. rebase the single commit → conflicts in the 3 known files, resolved once
|
||||
git rebase upstream/master # resolve iso15693.c, cmdhf15.c, pm3_cmd.h
|
||||
# ... resolve, git add, git rebase --continue
|
||||
|
||||
# 3. re-split into logical per-file commits (soft reset + staged adds)
|
||||
git reset --soft upstream/master
|
||||
# commit A: sim_table.c/h + sim_crypto.c/h (new modules)
|
||||
# commit B: pm3_cmd.h + iso15.h + iso15693.h (IDs + struct fields)
|
||||
# commit C: appmain.c (dispatch)
|
||||
# commit D: iso15693.c (sim + sniff + inventory)
|
||||
# commit E: cmdhf15.c (client reader/inventory)
|
||||
# commit F: Makefile + Standalone/* (build glue)
|
||||
```
|
||||
|
||||
### Validation (all local per build-workflow — ARM builds here, not the Mac)
|
||||
|
||||
1. `make -C "$WT" clean && make -C "$WT" PLATFORM=PM3GENERIC` — ARM compiles.
|
||||
2. `make -C "$WT" client` — validates `cmdhf15.c`.
|
||||
3. `python -m pytest tests/` in pm3py — sim tests are transport-mocked, so
|
||||
they stay green; confirms no Python-side regression.
|
||||
4. **Hardware smoke test (user, has PM3):** flash, then `hf 15` scan +
|
||||
15693 sim + a phone read (the known-good sim path). This is the only
|
||||
check the dry-run can't cover — upstream may have altered 15693 wire
|
||||
behavior around our insertions.
|
||||
|
||||
### Landing
|
||||
|
||||
- Show resolved `iso15693.c` diff for review **before** anything touches
|
||||
`main`.
|
||||
- Fast-forward `firmware` main to `rebase-wip`, bump the submodule pointer
|
||||
in a pm3py commit, force-push the fork (`--force-with-lease`).
|
||||
- Remove the worktree (`git worktree remove`, no `--force`).
|
||||
|
||||
## Risks & rollback
|
||||
|
||||
- **Semantic drift in `iso15693.c`:** upstream may have changed shared
|
||||
helpers our sim path calls. Compile catches signature breaks; behavior
|
||||
needs the hardware smoke test.
|
||||
- **Rollback:** old main is preserved. Recovery ref for pre-rebase tip:
|
||||
`35db99fa5`. Nothing is force-pushed until build + review pass.
|
||||
- **Safety Net:** `branch -D`, `rm -rf`, `git clean -f`, `worktree remove
|
||||
--force` are blocked — use `-d`/plain `rm`/`worktree remove` (no force),
|
||||
or hand force-ops to the user.
|
||||
|
||||
## Go-forward (close the gap that let us drift 1049 commits)
|
||||
|
||||
From the earlier maintenance discussion, still unbuilt:
|
||||
- `pm3py/firmware_compat.py` — pin `UPSTREAM_TESTED_COMMIT` = `87388b389`.
|
||||
- A **real** rebase-check CI (the existing `firmware/.github/workflows/
|
||||
rebase.yml` is upstream's Changelog Reminder, not ours).
|
||||
```
|
||||
117
docs/plans/2026-07-05-14a-custom-commands-design.md
Normal file
117
docs/plans/2026-07-05-14a-custom-commands-design.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Custom ISO14443-A command handling for the card sim — Design
|
||||
|
||||
**Status:** Design approved, unimplemented · **Date:** 2026-07-05 · **Plan:** [2026-07-05-14a-custom-commands-plan.md](2026-07-05-14a-custom-commands-plan.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The 14a card sim serves the *standard* command set from firmware-native handlers
|
||||
backed by emulator RAM (EML). It has **no mechanism for custom/vendored commands**:
|
||||
|
||||
- **14a-3 (Layer 3):** proprietary commands common in the parts we model — most
|
||||
importantly **NTAG I2C** `SECTOR_SELECT` (0xC2) + cross-sector `READ`/`FAST_READ`,
|
||||
plus vendor reads (ST25TN system block / product ID). Today an unknown L3 command
|
||||
falls through `SimulateIso14443aTagEx`'s final `else` and gets **no response**.
|
||||
- **14a-4 (Layer 4 / ISO-DEP):** vendor APDUs and crypto (DESFire, JCOP, EMV, custom
|
||||
applets). Today RATS→ATS is answered, but any I-block APDU gets a **blanket fake
|
||||
`90 00`** (`iso14443a.c:2529-2545`) with no parsing — wrong for everything real.
|
||||
|
||||
This contrasts with the **15693 sim**, which already serves custom commands from a
|
||||
Python-compiled response table in BigBuf (`sim_table`) consulted in its `default:`
|
||||
case (`iso15693.c:~3081`). That mechanism is proven; 14a simply never got it wired in.
|
||||
|
||||
## Constraints
|
||||
|
||||
1. **86µs FDT at Layer 3.** A tag answer must start ~86µs after the reader command
|
||||
ends. This rules out a live USB round-trip for L3 — no reader-command → host →
|
||||
response is possible inside the window.
|
||||
2. **Reuse over reinvention.** The firmware is a fork maintained with atomic
|
||||
single-file commits for easy rebase against upstream PM3. The 15693 `sim_table`
|
||||
server, the 120-byte `TableEntry` wire format, the `TableCompiler`, and the
|
||||
`0x0950-0x0952` upload command IDs already exist and are fork-local.
|
||||
3. **Additive, non-invasive.** The native-EML sim is correct and battle-tested for
|
||||
standard commands. Custom-command support must *layer on top* of it, not replace it.
|
||||
|
||||
## Decision
|
||||
|
||||
**A table-first L3 + WTX-L4 hybrid, layered on the native-EML sim.** Five mechanisms,
|
||||
each the cheapest *correct* one for its command class, all tracing through the existing
|
||||
`EmLogTrace` chokepoint:
|
||||
|
||||
| # | Layer | Mechanism | Handles | Host round-trip? |
|
||||
|---|-------|-----------|---------|------------------|
|
||||
| A | L3 | **Native-EML** (unchanged) | Standard: anticoll, READ, FAST_READ, WRITE, GET_VERSION, READ_SIG, counters, RATS | No |
|
||||
| B | L3 | **Native-EML + sector offset** (graft) | NTAG I2C cross-sector READ/FAST_READ | No |
|
||||
| C | L3 | **Static `sim_table`** (new hook) | Custom-command *state machine* (SECTOR_SELECT), static vendor reads | No (pre-uploaded) |
|
||||
| D | L3 | **Native firmware auth** (existing branches) | UL-C 3DES, UL-AES, MFC Crypto1 | No |
|
||||
| E | L4 | **Static `sim_table`** (same hook) | Deterministic ISO-DEP APDUs: Type-4 NDEF SELECT/ReadBinary, canned GET/PUT DATA | No (pre-uploaded) |
|
||||
| F | L4 | **WTX relay** (new) | Dynamic ISO-DEP crypto: DESFire/JCOP/EMV, unpredictable reader nonce | Yes (S(WTX) buys seconds) |
|
||||
|
||||
The whole thing is **three additive firmware hooks** in `SimulateIso14443aTagEx`,
|
||||
copied in spirit from the 15693 sim:
|
||||
|
||||
1. **Host-poll** at loop top — `data_available()`/`receive_ng()` handling
|
||||
`CMD_SIM_TABLE_UPLOAD/UPDATE/CLEAR`, EML load, relay responses (mirrors
|
||||
`iso15693.c:2472-2508`). Prerequisite: without it the table is empty forever.
|
||||
2. **Table-check** at the top of the final `else` (`:2493`) — normalize, `sim_table_lookup`,
|
||||
`sim_table_execute`; placed *before* the ST25TA block and the fake-`90 00` switch so
|
||||
the table overrides those fakes. On an L4 I-block miss with ISO-DEP active, falls into
|
||||
the WTX relay.
|
||||
3. **Sector-offset graft** on native `READ`/`FAST_READ` — add `sim_active_sector()*0x400`
|
||||
to the `emlGet` source offset.
|
||||
|
||||
## Why not the alternatives
|
||||
|
||||
- **Relay-first at L3 (rejected).** Serving custom L3 commands by relaying to Python on
|
||||
a reader *retry* was scored `correctness 2 / fdt_safety 2` and rejected: the 86µs FDT
|
||||
is unreachable over USB, and a reader that gives up drops to REQA/WUPA + anticollision,
|
||||
losing the very state (selected sector) the relay was trying to serve. Retry-relay
|
||||
survives only as a debug affordance.
|
||||
- **Per-page table for cross-sector reads (rejected).** Representing NTAG I2C sectors as
|
||||
hundreds of per-page `sim_table` entries blows the linear-scan lookup toward the FDT and
|
||||
the entry/response-size ceilings. Instead, **reads stay native** and become
|
||||
sector-aware via a one-line offset — O(1), wire-speed, no entry explosion.
|
||||
|
||||
## The SECTOR_SELECT problem → group state + a firmware projection
|
||||
|
||||
NTAG I2C `SECTOR_SELECT` is the hard case that shaped the design. It is:
|
||||
|
||||
- **Two-phase:** `C2 FF` (packet 1) → 4-bit ACK, then a *bare* sector byte `s 00 00 00`
|
||||
(packet 2, no opcode) → **silent success** (the tag transmits nothing for >1ms).
|
||||
- **Stateful and persistent:** it changes the addressing of *all subsequent* READs until
|
||||
the next SECTOR_SELECT or a re-selection.
|
||||
|
||||
This is handled by the `sim_table` **group state machine** plus a read-only firmware
|
||||
projection:
|
||||
|
||||
- Phase 1 (`C2 FF`) activates a transient `GRP_SEL_PENDING` group and 4-bit-ACKs.
|
||||
- Phase 2 (`s 00 00 00`) matches **only inside `GRP_SEL_PENDING`** (so a stray `00`/`01`
|
||||
frame can't false-flip the sector), sets `GRP_SECTOR_s`, clears the pending group, and
|
||||
responds with `response_len == 0` = the passive-ACK silence.
|
||||
- An invalid-sector catch-all in `GRP_SEL_PENDING` returns a 4-bit NAK.
|
||||
- `sim_active_sector()` derives the active sector **read-only** from the group register,
|
||||
so the group register stays the single source of truth (no drift between routing and
|
||||
read data), and native READ/FAST_READ offset into the right sector.
|
||||
|
||||
This is the "graft" that resolves both judges' top risks: it keeps the table tiny
|
||||
(~6 entries, not 256) so FDT is trivially safe, and it makes native-firing-first
|
||||
*correct* (reads are sector-aware) instead of a bug that serves flat sector-0 data.
|
||||
|
||||
## Crypto taxonomy correction
|
||||
|
||||
The judges flagged a miscategorization worth stating plainly: **Ultralight-C 3DES
|
||||
(0x1A/0xAF) and UL-AES are Layer-3 framed** (no ISO-DEP). They are servable by *neither*
|
||||
the table (unpredictable reader `RndB`) nor the L4 WTX relay (86µs FDT). They use the
|
||||
**existing native firmware auth branches** (`iso14443a.c:2242/2273/2324/2355`) with the
|
||||
model key loaded into EML — a "native-auth" track (mechanism D), first cut with the
|
||||
firmware's fixed nonce (a documented fidelity gap vs the model's random `RndB`). Only
|
||||
genuinely dynamic *ISO-DEP* crypto (DESFire/JCOP AES) goes to the L4 relay.
|
||||
|
||||
## Provenance
|
||||
|
||||
This design was produced by a multi-agent workflow: 6 parallel readers mapped the 14a
|
||||
sim dispatch, the proven 15693 `sim_table` + retry-relay template, the tag-model custom
|
||||
commands, the `TableCompiler`, the WTX-relay design, and the L3 timing/retry reality;
|
||||
3 competing designs (static-table-first, relay-first, hybrid) were adversarially judged;
|
||||
and the result was synthesized. Judge outcome: static-table-first and hybrid both
|
||||
"adopt-with-changes" (reuse 5/5); relay-first "reject" (FDT-unsafe). See the plan doc for
|
||||
the sequenced, implementation-grade steps.
|
||||
248
docs/plans/2026-07-05-14a-custom-commands-plan.md
Normal file
248
docs/plans/2026-07-05-14a-custom-commands-plan.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Custom ISO14443-A command handling — Implementation Plan
|
||||
|
||||
**Status:** Planned, unimplemented · **Date:** 2026-07-05 · **Design:** [2026-07-05-14a-custom-commands-design.md](2026-07-05-14a-custom-commands-design.md)
|
||||
|
||||
Serve custom/vendored ISO14443-A commands in the card sim, layered on the native-EML
|
||||
14a sim. Two independently shippable tracks: **L3** (NTAG I2C SECTOR_SELECT + cross-sector
|
||||
reads, static vendor reads, native auth) and **L4** (static ISO-DEP APDUs + WTX relay for
|
||||
dynamic crypto). Reuses the proven 15693 `sim_table` server, the 120-byte `TableEntry`
|
||||
format, and the `TableCompiler`. All firmware line numbers are anchors against the current
|
||||
`firmware/armsrc/iso14443a.c`; each firmware change lands as an atomic single-file commit
|
||||
anchored to **stable symbols** (`ISO14443A_CMD_RATS`, the final `else`,
|
||||
`EmSendPrecompiledCmd`), not line numbers.
|
||||
|
||||
---
|
||||
|
||||
## L3 track — mechanisms
|
||||
|
||||
- **(A) Native-EML (unchanged):** REQA/WUPA/anticoll/SELECT/PPS/GET_VERSION/READ_SIG/
|
||||
READ/FAST_READ/WRITE/COMPAT_WRITE/READ_CNT/INCR_CNT/HALT/RATS. Byte-correct within
|
||||
86µs, zero new code. WRITE mutates the EM BigBuf so later reads reflect writes natively.
|
||||
- **(B) Native-EML + sector offset (graft):** cross-sector READ/FAST_READ.
|
||||
`sim_active_sector()` maps the `sim_table` active-group bits → `{0,1,3}`; native READ
|
||||
(`:2033-2058`) and FAST_READ (`:2081-2093`) add `sim_active_sector()*0x400` to the
|
||||
`emlGet` source offset. Python pre-zeroes invalid pages in the uploaded multi-sector EML
|
||||
image so native 00-fill falls out for free. Reads stay O(1)-native, sector-correct.
|
||||
- **(C) Static `sim_table` (new `:2493` hook):** the SECTOR_SELECT *state machine* +
|
||||
genuinely-custom static reads only (see worked example). No per-page entries.
|
||||
- **(D) Native firmware auth (existing branches):** UL-C 3DES (`:2242`), UL-AES
|
||||
(`:2273/2324/2355`), MFC Crypto1 — model key in EML, firmware computes challenge/response,
|
||||
first cut fixed-nonce. Not table, not relay.
|
||||
|
||||
**L3 miss policy:** reader-retry relay is debug-only (86µs FDT is unreachable over USB).
|
||||
Everything reachable at L3 is covered by A–D.
|
||||
|
||||
## L4 track — mechanisms
|
||||
|
||||
Gated by a new `iso_dep_active` flag (set at RATS→ATS `:2234`; cleared on HALT / S(DESELECT)).
|
||||
|
||||
- **(E) Static `sim_table`** (same `:2493` hook): deterministic ISO-DEP APDUs — Type-4
|
||||
NDEF SELECT-App(AID) / SELECT-File(CC,NDEF) / ReadBinary, canned GET/PUT DATA, fixed
|
||||
`90 00`. Firmware masks the PCB (`(pcb&0xE2)==0x02` = I-block), computes
|
||||
`off = 1 + CID + NAD`, strips the trailing CRC, `MATCH_PREFIX` on the bare APDU; on hit
|
||||
re-wraps (`response[0]=pcb`, CID re-inserted at `response[1]` when `pcb&0x08`, payload at
|
||||
`response+off`, `AddCrc14A`). **Overrides the bogus blanket `90 00`** at `:2529-2545` —
|
||||
which is exactly why the hook sits at `:2493` (before the switch), not `:2576`.
|
||||
- **(F) WTX relay** (dynamic crypto): on an I-block table miss with `iso_dep_active`,
|
||||
firmware sends a tag-side **S(WTX)** to buy time (FWT×59 ≈ 4.8s), relays the normalized
|
||||
APDU to Python, spin-waits for the computed response, re-wraps and sends. The Python
|
||||
transponder model owns per-session nonce/IV/auth state.
|
||||
|
||||
**L4 chaining** (gating dependency for DESFire/NDEF coverage): tag block-number toggle
|
||||
(PCB bit0 per I-block), reader→tag reassembly (PCB bit4 → R(ACK) `0xA2|bn`), tag→reader
|
||||
FSD split into chained I-blocks. `DYNAMIC_RESPONSE_BUFFER_SIZE` bumped `64→256` first.
|
||||
v1 gated to single-frame + `0xAF` continuation until chaining lands.
|
||||
|
||||
---
|
||||
|
||||
## Firmware steps
|
||||
|
||||
| ID | File | Change |
|
||||
|----|------|--------|
|
||||
| **FW-1** | `armsrc/sim_table.h` + `.c` | Generalize `sim_table_execute` from `(iso15_tag_t *tag)` to `(uint8_t *eml_data, uint16_t eml_size, uint32_t *auth_state)`; replace `tag->data`/`sizeof(tag->data)`/`tag->auth_state` at `sim_table.c:132-169`. Drop `#include iso15.h` from the header. Add `#define SIM_FLAG_RESP_4BIT 0x04`. Struct unchanged (120 bytes PACKED). Fork-local. |
|
||||
| **FW-2** | `armsrc/iso15693.c` | Update the single `sim_table_execute` call site (`~:3081`) to the new signature: `(tag->data, sizeof(tag->data), &tag->auth_state)`. Keeps 15693 green. |
|
||||
| **FW-3** | `armsrc/iso14443a.c` (commit A) | `#include sim_table.h`. Add file-scope `static uint32_t s_auth_state` and `sim_active_sector()` near the sim-trace statics (`~:1727`); reset `s_auth_state=0` in the `FLAG_SIM_TRACE` arm. Bump `DYNAMIC_RESPONSE_BUFFER_SIZE 64→256` and the modulation buffer. Insert **host-poll** block at loop top after `sim_trace_flush` (`~:1907`): `CMD_SIM_TABLE_UPLOAD` (4-byte `initial_groups` + 120B entries → `sim_table_init/add`), `UPDATE`, `CLEAR`, `CMD_HF_MIFARE_EML_MEMSET` (`emlSet`), `CMD_BREAK_LOOP`. Copied ~verbatim from `iso15693.c:2472-2508`. |
|
||||
| **FW-4** | `armsrc/iso14443a.c` (commit B) | **L3 table-check** at `:2493` (after the `response_n`/`modulation_n` reset, before `tagType==10` and the fake-`90 00` switch): `sim_table_lookup` + `sim_table_execute`; honor `SIM_FLAG_RESP_4BIT` (`EmSend4bit`), `response_len==0` (passive-ACK silence), byte response (join the `:2591` build path), `SIM_FLAG_APPEND_CRC`. **Sector-offset graft** on native READ/FAST_READ (`sim_active_sector()*0x400`). Add labels `build_send:` (`:2591`) and `after_send:` (past `:2620`). |
|
||||
| **FW-5** | `include/pm3_cmd.h` | Append `CMD_SIM_RELAY_EVENT 0x0953` and `CMD_SIM_RELAY_RESP 0x0954` in the existing `0x095x` block. Additive. |
|
||||
| **FW-6** | `armsrc/iso14443a.c` (commit C) | Add file-scope `bool iso_dep_active` (near `order` `:1870`); set at RATS→ATS (`:2234`), clear on HALT (`:2214`) and S(DESELECT). Extend the `:2493` hook: detect I-block, `off=1+CID+NAD`, strip PCB/CID/NAD+CRC, `MATCH_PREFIX` on the bare APDU; on hit re-wrap and join `build_send`. Overrides fake `90 00`. |
|
||||
| **FW-7** | `armsrc/iso14443a.c` (commit D, then E) | **D:** `EmSendWtx(uint8_t wtxm)` tag-side S(WTX) sender modeled on `EmSendCmd` (the `:3787-3808` `send_wtx` is reader-side, not reusable). On L4 miss: `EmSendWtx(59)` → await reader S(WTX) echo (`(b&0xF2)==0xF2`) → `reply_ng(CMD_SIM_RELAY_EVENT,{L4,apdu})` → spin `data_available()`/`receive_ng()` for `CMD_SIM_RELAY_RESP` (GetTickCount timeout) → re-wrap + `EmSendCmd`. **E:** block-number toggle + reader→tag chaining reassembly + tag→reader FSD split. |
|
||||
|
||||
## pm3py steps
|
||||
|
||||
| ID | File | Change |
|
||||
|----|------|--------|
|
||||
| **PY-1** | `pm3py/core/protocol.py` | **FIX id drift:** repoint `SIM_TABLE_UPLOAD/CLEAR/UPDATE` from stale `0x0900/0901/0902` → `0x0950/0951/0952`. Add `SIM_RELAY_EVENT=0x0953`, `SIM_RELAY_RESP=0x0954`. Reuse `HF_MIFARE_EML_MEMSET=0x0602` for EML load. |
|
||||
| **PY-2** | `pm3py/sim/table_compiler.py` | Add `RESP_FLAG_4BIT=0x04` + group constants (`GRP_SEL_PENDING=1, GRP_SECTOR0=2, GRP_SECTOR1=3, GRP_SECTOR3=4`). Add `compile_ntag_i2c(tag)`: `base=compile_14a(tag)` + **only** the SECTOR_SELECT machine (no per-page reads). Add an `enumerate_table_entries()` hook so a model can yield full `TableEntry` objects (groups/flags) alongside the legacy `enumerate_responses` 3-tuple. |
|
||||
| **PY-3** | `pm3py/transponders/hf/iso14443a/nxp/ntag_i2c.py` | Add `build_eml_image()` laying out `[mfu prefix][sector0, invalid pages zeroed][sector1][sector3 regs]` at `0x400` stride, reusing `_masked_page`/`_page_readable`. Add `enumerate_table_entries()` yielding the SECTOR_SELECT machine. Keep `enumerate_responses` (sector-0) for other consumers. |
|
||||
| **PY-4** | `pm3py/sim/sim_session.py` (L3) | Fix constants. In `start_14a`: if the tag is table-backed, load the EML image (`CMD_HF_MIFARE_EML_MEMSET`) then `_compile_and_upload_table` with a 14a dispatch branch (`NtagI2C→compile_ntag_i2c`, `MifareClassic→compile_mifare`, else `compile_14a`). Factor the `initial_groups`+120B chunked uploader into a shared `_upload(table, initial_groups)`. |
|
||||
| **PY-5** | `pm3py/sim/sim_session.py` (L4) | Async relay path for L4-crypto tags: send `HF_ISO14443A_SIMULATE` via async `PM3Transport`, `create_task(self._relay_loop(tag))`; **do not** spawn the sync `_trace_reader` thread (single async reader owns trace + relay on one fd). Flesh out `_relay_loop`: `CMD_SIM_RELAY_EVENT` → `RESELECT`→`tag.power_on()`; `L4`→`out=await tag.handle_frame(RFFrame.from_bytes(apdu))`, reply `CMD_SIM_RELAY_RESP` with `resp_flags` + bare bytes. |
|
||||
|
||||
## New / reused command IDs & flags
|
||||
|
||||
- `CMD_SIM_RELAY_EVENT = 0x0953` **(new)** fw→host: normalized L4 APDU on table miss.
|
||||
- `CMD_SIM_RELAY_RESP = 0x0954` **(new)** host→fw: computed L4 response.
|
||||
- `CMD_SIM_TABLE_UPLOAD/CLEAR/UPDATE = 0x0950/0951/0952` **(reused;** fix `protocol.Cmd` stale `0x0900` series).
|
||||
- `CMD_HF_MIFARE_EML_MEMSET = 0x0602` **(reused)** to load the multi-sector 14a EML image (native sim already reads tag memory from the EM BigBuf via `emlGet`).
|
||||
- `SIM_FLAG_RESP_4BIT = 0x04` **(new)** response-flag (fw `sim_table.h` + `RESP_FLAG_4BIT` in `table_compiler.py`) — not a wire command.
|
||||
- `GRP_SEL_PENDING=1 / GRP_SECTOR0=2 / GRP_SECTOR1=3 / GRP_SECTOR3=4` — compiler-side group-bit convention (max 31 groups), not firmware constants.
|
||||
|
||||
## Wire formats
|
||||
|
||||
**`sim_table_entry_t`** (120-byte PACKED, reused byte-for-byte, LE
|
||||
`<32s B B 64s B B B H B B B B B I I B B B B>`): `match[32]@0`, `match_len@32`,
|
||||
`match_mode@33` (0=EXACT, 1=PREFIX), `response[64]@34`, `response_len@98`,
|
||||
`response_flags@99` (bit0=`APPEND_CRC`, **bit2=`RESP_4BIT`** new), `eml_action@100`,
|
||||
`eml_offset(u16)@101`, `eml_len@103`, `eml_resp_insert@104`, `cmd_data_offset@105`,
|
||||
`cmd_data_len@106`, `group@107`, `activate_groups(u32)@108`, `deactivate_groups(u32)@112`,
|
||||
`set_auth@116`, `clear_auth@117`, `flags@118` (bit1=`CONSUME`), `_pad@119`.
|
||||
`TableEntry.serialize()` already emits exactly this.
|
||||
|
||||
**Upload** (unchanged): `CMD_SIM_TABLE_UPLOAD` first frame = 4-byte `initial_groups` (LE)
|
||||
+ N×120B entries filling `512-4` bytes; `CMD_SIM_TABLE_UPDATE` = `(512//120)*120`B chunks;
|
||||
`CMD_SIM_TABLE_CLEAR` = empty.
|
||||
|
||||
**Relay event** (`CMD_SIM_RELAY_EVENT` payload): `byte[0]=ev_type` (0=RESELECT, 3=L3-debug,
|
||||
4=L4), `byte[1]=norm_len`, `byte[2..]=normalized frame` (L4: PCB/CID/NAD+CRC stripped).
|
||||
**Relay resp** (`CMD_SIM_RELAY_RESP` payload): `byte[0]=ev_type echo`, `byte[1]=resp_flags`
|
||||
(bit0=append-CRC, bit1=4BIT, bit2=passive-ACK/no-tx, bit4=chaining-more), `byte[2..]=bare
|
||||
response`. `tag.handle_frame` returns **bare bytes** — firmware owns PCB/CID/NAD framing
|
||||
and block-number.
|
||||
|
||||
## NTAG I2C worked example (NT3H2211, 2k) — reading Sector 1
|
||||
|
||||
**Compile (`compile_ntag_i2c`):** `initial_groups` seeds `GRP_SECTOR0` active. Multi-sector
|
||||
EML image loaded via `CMD_HF_MIFARE_EML_MEMSET`. ~6 table entries (far under any FDT/entry
|
||||
limit):
|
||||
- `E1` `[C2 FF]` EXACT → `[0A]` `RESP_4BIT`, activate `GRP_SEL_PENDING`.
|
||||
- `E2` `[00 00 00 00]` EXACT, group=`GRP_SEL_PENDING`, `response_len=0`, activate
|
||||
`GRP_SECTOR0`, deactivate `SECTOR1|SECTOR3|SEL_PENDING`.
|
||||
- `E3` `[01 00 00 00]` → `GRP_SECTOR1`. `E4` `[03 00 00 00]` → `GRP_SECTOR3`.
|
||||
- `E5` `[]` PREFIX (last) in `GRP_SEL_PENDING` → `[00]` 4-bit NAK, deactivate `SEL_PENDING`.
|
||||
|
||||
**Runtime:**
|
||||
1. Native anticoll/SELECT (unchanged). `sim_active_sector()==0`.
|
||||
2. Reader → `C2 FF` → `:2493` hit `E1` → `EmSend4bit(0x0A)`, `active_groups |= SEL_PENDING`.
|
||||
3. Reader → `01 00 00 00` (bare sector byte) → `:2493` in `SEL_PENDING` hit `E3` →
|
||||
`response_len==0` ⇒ apply state (+`SECTOR1`, clear others + `SEL_PENDING`), **transmit
|
||||
nothing** = the >1ms passive-ACK. `sim_active_sector()` now returns 1.
|
||||
4. Reader → `FAST_READ 3A 00 0F` → **native** FAST_READ fires first (correct), reads
|
||||
`emlGet` at `base + 1*0x400` = sector-1 memory, returns via `EmSendCmd`. Sector-correct,
|
||||
O(1), wire-speed, no table scan, no USB.
|
||||
5. Back to sector 0: `C2 FF` (E1), `00 00 00 00` (E2 → `SECTOR0`, silence).
|
||||
6. Invalid sector: `C2 FF` then `07 00 00 00` → `E5` → 4-bit NAK, `SEL_PENDING` cleared.
|
||||
|
||||
All frames trace via `EmLogTrace`/`sim_trace_push` automatically.
|
||||
|
||||
## Stateful command handling — five representations
|
||||
|
||||
1. **In-table group state** (no host, wire-speed): SECTOR_SELECT active sector,
|
||||
SLIX2-style privacy, PWD_AUTH-gated read sets. `activate_groups`/`deactivate_groups`
|
||||
masks; two-phase handshakes use a transient PENDING group so a stray frame can't
|
||||
false-flip; silent success = `response_len==0`; 4-bit ACK/NAK = `RESP_4BIT`.
|
||||
2. **Firmware sector projection** (the graft): `sim_active_sector()` derives the active
|
||||
sector *read-only* from `active_groups` — single source of truth, native reads O(1).
|
||||
3. **EML-backed mutable state** (no host): live WRITE persists into EM BigBuf; later
|
||||
native reads reflect it.
|
||||
4. **Native-auth state** (no host): UL-C/UL-AES/MFC via existing firmware branches with
|
||||
key in EML; `auth_state` word + firmware nonce chain carry session state. First cut
|
||||
fixed-nonce.
|
||||
5. **Relay state** (host, L4 only): dynamic ISO-DEP crypto; Python owns per-session
|
||||
nonce/IV/auth in `handle_frame`. A challenge-prefix entry may be `SIM_FLAG_CONSUME`
|
||||
(one-shot) to hand the encrypted tail to the relay.
|
||||
|
||||
**Session reset:** on REQA/WUPA/HALT firmware resets `active_groups` + `iso_dep_active`
|
||||
and (for L4 relay tags) emits `CMD_SIM_RELAY_EVENT{RESELECT}` so Python calls
|
||||
`tag.power_on()`, keeping the C and Python state machines in lockstep across a reader
|
||||
give-up → re-anticollision.
|
||||
|
||||
## Test plan
|
||||
|
||||
- **UNIT (pm3py):** `TableEntry.serialize()` round-trips `RESP_4BIT`/group fields at exact
|
||||
byte offsets (99, 107-118), `sizeof==120`.
|
||||
- **UNIT:** `compile_ntag_i2c` emits exactly the E1-E5 machine (correct match/groups/masks/
|
||||
flags); assert **no** per-page sector reads.
|
||||
- **UNIT:** `build_eml_image()` lays out `[prefix][sector0 zeroed-invalid][sector1]
|
||||
[sector3]` at 0x400 stride with per-sector PWD/PACK masking (no secret leakage).
|
||||
- **UNIT:** `protocol.Cmd` drift fixed (`SIM_TABLE_* == 0x0950/1/2`, relay ids); sim_session
|
||||
constants match; FLAG_SIM_TRACE bit confirmed against firmware.
|
||||
- **UNIT:** `_relay_loop` parses `CMD_SIM_RELAY_EVENT{L4}`, calls `handle_frame`, replies
|
||||
with correct `resp_flags`; RESELECT triggers `power_on`.
|
||||
- **FIRMWARE UNIT (host build of `sim_table.c`):** group gating + activate/deactivate
|
||||
transitions E1→E3; `response_len==0` path; `RESP_4BIT` path; `sim_active_sector()` maps
|
||||
bits → `{0,1,3}`.
|
||||
- **FIRMWARE UNIT:** worst-case `sim_table_lookup` latency on the 48MHz ARM for the ~30-entry
|
||||
table vs 86µs FDT — **measure, do not hand-wave** (must-pass gate).
|
||||
- **HARDWARE L3:** PM3 Easy sims NT3H2211; reader issues `C2 FF` + `01 00 00 00` +
|
||||
FAST_READ; live-trace confirms 4-bit ACK, >1ms silence, sector-1 data, invalid NAK; and
|
||||
that a table hit fully suppresses the fake-`90 00` path.
|
||||
- **HARDWARE L3:** verify passive-ACK phase-2 emits **nothing** (no spurious frame the
|
||||
reader misreads as NAK).
|
||||
- **HARDWARE L4 static:** Type-4 NDEF SELECT-App/File/ReadBinary from the table, overriding
|
||||
fake `90 00`; TagInfo reads NDEF.
|
||||
- **HARDWARE L4 relay:** DESFire GET_VERSION (`0xAF` chaining) and a >FSD NDEF read via WTX
|
||||
relay; assert S(WTX) echo, block-number toggle, chained reassembly both directions.
|
||||
- **REGRESSION:** 15693 sim still works after the `sim_table_execute` signature change; full
|
||||
pytest suite green.
|
||||
|
||||
## Sequencing
|
||||
|
||||
**L3 track (ship first, independently testable):**
|
||||
1. FW-1 (`sim_table` signature + `RESP_4BIT`) → FW-2 (`iso15693.c` call site, keeps 15693 green).
|
||||
2. FW-3 (commit A: include, statics, buffer bump, host-poll upload).
|
||||
3. FW-4 (commit B: L3 table-check + sector-offset graft).
|
||||
4. PY-1 (id fix) → PY-2 (`compile_ntag_i2c`) → PY-3 (`build_eml_image` + entries) → PY-4 (`start_14a` upload + dispatch).
|
||||
5. **Test L3 on hardware.** Commit + bump firmware gitlink.
|
||||
|
||||
**L4 track (independent, builds on the L3 host-poll hook):**
|
||||
6. FW-5 (`pm3_cmd.h` relay ids).
|
||||
7. FW-6 (commit C: `iso_dep_active` + static-APDU serve overriding fake `90 00`).
|
||||
8. FW-7 (commit D: `EmSendWtx` + WTX relay; commit E: block-number toggle + chaining).
|
||||
9. PY-5 (async `start_14a` relay path + `_relay_loop`).
|
||||
10. **Test L4 on hardware** (DESFire chaining, NDEF>FSD).
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| **FDT scan blowout** — linear `sim_table_lookup` approaches 86µs on a big table. | Sector-offset graft keeps the 14a table tiny (~6-30 entries); cross-sector reads are native (O(1)). Firmware-host unit test **measures** worst-case latency vs 86µs before shipping. Contingency: first-byte bucket index in `sim_table.c` (benefits 15693 too). |
|
||||
| **Dispatch order** — native READ/FAST_READ fire before `:2493` and would serve flat sector-0 data. | Resolved by design: cross-sector reads are **not** routed through the table; native branches are made sector-aware via one `emlGet` offset. Native-first is now correct. |
|
||||
| **Passive-ACK phase-2** emits a spurious frame the reader reads as NAK. | `sim_table_execute` sets `*resp_len=0`; hook sets `p_response=NULL` and jumps past send; the `if(response_n>0)` build guard yields silence. Hardware test asserts >1ms silence. |
|
||||
| **Command-id drift** — `protocol.py` at stale `0x0900` series mismatches framing. | PY-1 fixes it as the first Python commit; unit test asserts the values. |
|
||||
| **UL-C/UL-AES miscategorized** as L4. | Routed to existing native firmware auth branches with model key via EML; documented fixed-nonce fidelity gap. Not claimed under table/relay. |
|
||||
| **L4 relay claims DESFire/NDEF** but chaining/block-number don't exist in FW. | Chaining is an explicit gating commit (FW-7 E) before DESFire is claimed; buffer bumped to 256 first; v1 gated to single-frame + `0xAF`. |
|
||||
| **`iso14443a.c` is a hot upstream file** — hooks churn on rebase. | Atomic single-file commits anchored to stable symbols; heavy logic in self-contained blocks; `sim_table`/`sim_crypto` fork-local; only one 15693 call site changes. |
|
||||
| **CONSUME one-shots** not reset until re-UPLOAD. | Session-reset emits `RESELECT`; where a CONSUME challenge must re-arm, Python re-uploads/re-adds; documented constraint. |
|
||||
|
||||
## Open decisions
|
||||
|
||||
1. **`FLAG_SIM_TRACE` bit:** `sim_session.py` uses `0x2000`, the 14a live-trace plan doc
|
||||
says `0x4000`; confirm the firmware's actual bit before wiring the async relay.
|
||||
*(Note: reconciled this session — firmware F1 landed `FLAG_SIM_TRACE 0x2000`; the doc's
|
||||
`0x4000` is stale. Verify against `pm3_cmd.h`.)*
|
||||
2. **Multi-sector EML layout:** confirm the EM BigBuf `CARD_MEMORY` fits sector0+1+3
|
||||
(~2KB + config) and that `CMD_HF_MIFARE_EML_MEMSET` writes at the offset the sector graft
|
||||
expects; else add a dedicated `CMD_HF_ISO14443A_EML_SETMEM` (fallback id `0x033A`).
|
||||
3. **NTAG I2C no-rollover fidelity:** native READ may roll over at emulator-memory end, not
|
||||
sector end. Decide pre-zeroing vs an explicit sector-length clamp in the READ branch.
|
||||
4. **NTAG21x first-READ NFC counter bump** is a side effect the native/static path loses.
|
||||
Accept as documented deviation, or force that first READ through a CONSUME entry (same
|
||||
for EV1 INCR_CNT edges).
|
||||
5. **UL-C 3DES fixed nonce vs model random `RndB`:** acceptable for sim; decide whether
|
||||
firmware 3DES-with-supplied-nonce is wanted or a downscope note suffices.
|
||||
6. **L4 chaining scope for v1:** minimum viable = single-frame APDUs + DESFire `0xAF` +
|
||||
tag→reader FSD split. Confirm whether reader→tag chaining is needed for any target
|
||||
reader in the first cut or can be deferred.
|
||||
7. **Empirical reader behaviour:** do target NTAG I2C reader stacks actually issue
|
||||
SECTOR_SELECT for sector 1, or only read sector-0 NDEF? If the latter, sector-1 can be
|
||||
validated later without blocking the L3 ship.
|
||||
8. **Apply hooks to `SimulateIso14443aTagAID` (`:4716`)?** Deferred; `TagEx` is the primary
|
||||
`FLAG_SIM_TRACE` path.
|
||||
|
||||
## Provenance
|
||||
|
||||
Produced by the `plan-14a-custom-commands` design workflow (13 agents: 6 readers → 3
|
||||
designs → 3 adversarial judges → synthesis). Judge outcome: static-table-first and hybrid
|
||||
"adopt-with-changes" (reuse 5/5, fdt_safety 3-4); relay-first "reject" (FDT-unsafe at L3).
|
||||
The recommended architecture is static-table-first L3 grafted with a firmware sector
|
||||
projection to resolve the SECTOR_SELECT/entry-explosion risk, plus a WTX relay for dynamic
|
||||
L4 crypto.
|
||||
76
docs/plans/2026-07-05-14a-live-trace-plan.md
Normal file
76
docs/plans/2026-07-05-14a-live-trace-plan.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Live trace for ISO14443-A sim (14a-3, extensible to 14a-4)
|
||||
|
||||
Add a live reader↔tag trace to the ISO14443-A card simulation, mirroring the
|
||||
working ISO15693 sim trace, designed so 14a-4 (ISO-DEP / Layer 4) reuses the
|
||||
same firmware mechanism. Backed by the feasibility eval (verdict:
|
||||
**feasible with care** — 86µs FDT does not break it).
|
||||
|
||||
## Design invariants (why 86µs is not "too fast")
|
||||
1. **Response is never touched** — table/precompiled, FPGA-timed via
|
||||
`EmSendCmd14443aRaw` (SSP-clock alignment + FPGA delay-line). Tracing is
|
||||
architecturally incapable of affecting FDT.
|
||||
2. **Both trace pushes deferred to *after* transmit** → zero added work inside
|
||||
the 86µs window. (A push is a ≤128B memcpy ≈ <200 cycles / <4.2µs anyway.)
|
||||
3. **Flush ≤1 ring entry per idle iteration** (NOT 15693's bulk-drain) — the
|
||||
only USB-touching part must never overlap a reader-command edge, because
|
||||
`reply_ng` and the Miller software-UART receive share the ARM thread.
|
||||
4. **Ring + drop-on-full** → lossy under anticollision bursts, but the sim
|
||||
never stalls. RF timing is preserved; trace is merely lossy in tight gaps.
|
||||
|
||||
## P0 — Prerequisite (standalone bug): fix `start_14a` sim-start payload
|
||||
`sim_session.py:101` builds `payload = atqa + sak + uid` then calls
|
||||
`send_ng_no_response(HF_ISO14443A_SIMULATE)` with **no payload** (`:123` TODO).
|
||||
The sim never starts correctly today. Fix: send the proper `HF_ISO14443A_SIMULATE`
|
||||
NG struct — the exact `<BH10sB20sBB16s16sB>` layout already used by the fixed
|
||||
`hf.iso14a.sim()` (tagtype, flags, uid[10], exitAfter, rats…). Land first;
|
||||
14a sim is unusable without it, trace or not.
|
||||
|
||||
## Firmware (atomic single-file commits + build)
|
||||
- **F1 `include/pm3_cmd.h`:** add `CMD_HF_ISO14443A_SIM_TRACE` (default id
|
||||
`0x0339`, next to the 15693 one at `0x0336`); reserve a `FLAG_SIM_TRACE` bit
|
||||
in the 14a sim `flags` word (default `0x4000`, a free high bit) so the host
|
||||
opts trace in/out — mirrors 15693's `flags & 0x02`.
|
||||
- **F2+F3 `armsrc/iso14443a.c`:** copy the `TRACE_PUSH`/`TRACE_FLUSH` ring block
|
||||
from `iso15693.c:2423` but **`ENTRY_MAX` → 128** (ISO-DEP I-blocks exceed 64B,
|
||||
needed for 14a-4) and ring → 16; `trace_enabled` from the new flag. Hook into
|
||||
`SimulateIso14443aTagEx` (`:1725`): buffer received cmd after `:1851` (don't
|
||||
push); after `EmSendPrecompiledCmd` (`:2554`) do `TRACE_PUSH(0,cmd)` then
|
||||
`TRACE_PUSH(1,response)`; `TRACE_FLUSH_ONE()` at loop top (`:1844`); full
|
||||
drain on loop exit.
|
||||
- **F4:** build `fullimage PLATFORM=PM3GENERIC` — must be clean.
|
||||
|
||||
## pm3py (reuses the generic scriptable-trace plumbing)
|
||||
- **Y1 `protocol.py`:** add `Cmd.HF_ISO14443A_SIM_TRACE`.
|
||||
- **Y2 `sim_session.py:284`:** generalize the trace-reader guard
|
||||
(`cmd == CMD_HF_ISO15693_SIM_TRACE`) into a `{cmd: (protocol, decoder)}`
|
||||
dispatch; add the 14a branch (`dir=data[0]&0x0F`, `crc_fail=data[0]&0x80`,
|
||||
`protocol=1`, `decode_14443a`).
|
||||
- **Y3 `start_14a`:** synchronous trace path mirroring `start_15693` (raw
|
||||
pyserial + the existing `_trace_reader` thread) + `trace`/`on_frame`/`quiet`
|
||||
kwargs. `on_frame`/`quiet`/`entries` + `TraceFormatter(mode='sim')` unchanged.
|
||||
|
||||
## I/O model decision (the one real design fork)
|
||||
- **14a-3:** sync trace path (no WTX relay at Layer 3) → avoids dual-reader-on-
|
||||
one-fd contention.
|
||||
- **14a-4 (later):** the async `_relay_loop` owns WTX; route `SIM_TRACE` through
|
||||
*it* (single async reader), not a second thread. Firmware F1–F3 are identical
|
||||
for both layers — only the pm3py consumer differs. WTX creates ms-scale idle
|
||||
windows ideal for flushing.
|
||||
|
||||
## Tests
|
||||
- **T1:** mocked-transport unit test — synthetic `CMD_HF_ISO14443A_SIM_TRACE`
|
||||
frames → assert `TraceFormatter`/`on_frame`/`entries` (mirror
|
||||
`test_trace_scripting.py`).
|
||||
- **T2 (bench):** phone doing anticollision + SELECT + RATS → verify (a) card
|
||||
never drops, (b) frames stream, tolerating loss during anticollision.
|
||||
|
||||
## Risks (+ mitigations)
|
||||
- Ring fill under heavy Layer-3 polling → lossy by design; ring=16, accept loss
|
||||
in anticollision (uninteresting traffic).
|
||||
- Flush overlapping a reader edge → single-entry-per-idle at loop top only;
|
||||
never bulk-drain post-transmit. A missed command = reader retry, not teardown.
|
||||
- `ENTRY_MAX=64` too small for ISO-DEP → 128 (RAM ≈ 1KB static; check budget).
|
||||
- pm3py dual reader on one fd → 14a-3 sync only; 14a-4 converges to async relay.
|
||||
|
||||
## Sequence
|
||||
`P0 → F1 → F2+F3 → F4 → Y1 → Y2 → Y3 → T1 → (T2 on bench)`
|
||||
50
docs/plans/2026-07-05-firmware-flasher.md
Normal file
50
docs/plans/2026-07-05-firmware-flasher.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Pure-Python firmware flasher + `pm3flash`
|
||||
|
||||
**Status:** ✅ Landed (hardware-validated on a PM3 Easy) · **Date:** 2026-07-05
|
||||
Related: [firmware-fork DT-Gitea migration](2026-07-05-firmware-fork-dt-migration.md)
|
||||
|
||||
## Goal
|
||||
|
||||
Let pm3py get the fork firmware onto a device by itself — no C `pm3-flash`/DFU/JTAG — so
|
||||
`pip install pm3py` + one command flashes the build this pm3py corresponds to. Supports the
|
||||
project "in the meantime", before tagged firmware releases exist on the DT Gitea.
|
||||
|
||||
## What landed
|
||||
|
||||
- **`Flasher`** (`pm3py/core/flash.py`) over the legacy 544-byte **OLD frame** (bootloader
|
||||
protocol, distinct from the NG frames the rest of the client speaks; OLD-frame plumbing in
|
||||
`core/transport.py`). Automatic OS→bootloader handover (`CMD_START_FLASH`, no button),
|
||||
`CMD_FINISH_WRITE` block loop with per-block ACK/NACK, reset, reopen-and-verify.
|
||||
- **Safety:** fullimage-only by default; the bootrom region is refused unless
|
||||
`allow_bootrom=True`. A bad OS write is recoverable (proven — recovered a corrupted-OS
|
||||
device); a bad bootrom write bricks to JTAG.
|
||||
- **Firmware pin** (`pm3py/_firmware.py`): `FIRMWARE_PIN` = the fork build pm3py corresponds
|
||||
to (currently the submodule SHA). `pm3.firmware.matches_pin` / `.expected_firmware` on
|
||||
connect.
|
||||
- **`pm3flash` CLI** (`pm3py/flash_cli.py`): detect → resolve/build image → flash → verify.
|
||||
`--image` (prebuilt), `--build PLATFORM` (`make` from the submodule; platform explicit —
|
||||
never inferred from `is_rdv4`, which reports the running firmware, not the board),
|
||||
`--force`, `--allow-bootrom`.
|
||||
|
||||
## Lessons (found on hardware / by adversarial review)
|
||||
|
||||
- **Merge segments sharing a flash page.** The SAM7 erase-programs whole pages; two adjacent
|
||||
PT_LOAD segments in one page must be written once with both segments' data, else 0xFF
|
||||
padding clobbers real bytes (bug: the flashed OS booted but had invalid version info).
|
||||
- **`--build` must flash what it built,** not gate on the pin — a dirty rebuild keeps the same
|
||||
base SHA, so `matches_pin` can't distinguish it (`--build` therefore implies `--force`).
|
||||
- **No CWD-relative firmware discovery** — running `make` on a stray `./firmware` is arbitrary
|
||||
code execution; only an explicit arg / `$PM3PY_FIRMWARE_SRC` / the package repo root.
|
||||
|
||||
## Open follow-ups
|
||||
|
||||
- **Auto-download** of the pinned release asset (toolchain-free install) — needs a tagged
|
||||
firmware release to exist; rides on the DT-Gitea migration. Drops into `resolve_image`.
|
||||
- **Read-back verify** via `CMD_READ_MEM_DOWNLOAD` (v1 trusts per-block ACK/NACK, like the C
|
||||
flasher).
|
||||
|
||||
## Provenance
|
||||
|
||||
Built + hardware-validated in one session; the `--build` layer was adversarially reviewed by
|
||||
a multi-agent workflow (correctness / subprocess-safety / CLI-UX lenses with a verify pass),
|
||||
which surfaced the pin-skip, make-missing, and CWD-Makefile issues fixed above.
|
||||
96
docs/plans/2026-07-05-firmware-fork-dt-migration.md
Normal file
96
docs/plans/2026-07-05-firmware-fork-dt-migration.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Firmware fork → DT Gitea migration + clean RRG-tracking model
|
||||
|
||||
**Status:** Planned, not started · **Date:** 2026-07-05 · Related: [2026-07-03-firmware-upstream-rebase-plan.md](2026-07-03-firmware-upstream-rebase-plan.md)
|
||||
|
||||
## Goal
|
||||
|
||||
Host pm3py and its Proxmark3 firmware fork on the DT Gitea, customize the firmware,
|
||||
and pull RRG (`RfidResearchGroup/proxmark3`) updates **without ever touching the RRG
|
||||
version** — via the standard **rebase-fork** model.
|
||||
|
||||
## The model
|
||||
|
||||
One firmware repo, `<dt-org>/proxmark3`, with a strict branch split:
|
||||
|
||||
| Branch | Role |
|
||||
|--------|------|
|
||||
| `master` | **pristine RRG mirror** — only ever fast-forwarded from `upstream`, never our commits |
|
||||
| `pm3py` | our customizations (atomic single-file commits) on top of `master`; the submodule pins this |
|
||||
|
||||
Remotes: `origin` = `<dt-org>/proxmark3` (SSH), `upstream` = RRG (already configured).
|
||||
pm3py itself moves to `<dt-org>/pm3py`; its `.gitmodules` points at the `pm3py` branch.
|
||||
|
||||
Why rebase-fork (not merge / subtree / quilt): our ~33 patches already *are* a hand-managed
|
||||
atomic-commit patch queue; rebase is the native git form. It keeps `git diff master..pm3py`
|
||||
a clean picture of exactly our changes, and resolves the recurring `iso15693.c` conflict
|
||||
(24/33 commits touch it) **once** per update instead of on every merge.
|
||||
|
||||
## The three real gotchas (all handled)
|
||||
|
||||
1. **Gitlink must be published or fresh clones dangle.** The firmware `pm3py` tip must be
|
||||
pushed *before* pm3py's gitlink references it (Phase 6 before 7).
|
||||
2. **The uncommitted EML host-poll change is in no ref** — it would be silently lost on any
|
||||
checkout/reclone. Backed up (`scratchpad/eml-hostpoll.patch`) and committed on `pm3py`
|
||||
in Phase 5.
|
||||
3. **Our history is intermixed with upstream merges** — `merge-base(main, upstream/master)`
|
||||
is only 11 commits below HEAD, so 48 of our 59 commits sit *below* it in the upstream DAG.
|
||||
A naive `rebase --onto master $MB pm3py` would carry only the top 11 and **drop 48**. So
|
||||
the migration does **not** rebase — it mirrors everything verbatim and defers the clean
|
||||
split to the first upstream pull (see Ongoing).
|
||||
|
||||
## Migration runbook (no rewrite, no data loss)
|
||||
|
||||
Split by **who**: `[A]` = I do it locally (offline); `[U]` = you run it (Gitea web / pushes,
|
||||
since my sandbox can't reach `:20022`).
|
||||
|
||||
- **Phase 0 [A] — safety net:** back up the EML diff (done); tag every must-preserve head
|
||||
`bak/<name>`; `git bundle create --all` for both repos.
|
||||
- **Phase 1 [U] — create EMPTY DT repos** `<dt-org>/proxmark3` and `<dt-org>/pm3py` (no
|
||||
README/license — a first commit blocks a mirror push).
|
||||
- **Phase 2 [A] — materialize local heads:** `git branch pm3py main` (verbatim), and local
|
||||
branches for the 6 feature branches. No rebase.
|
||||
- **Phase 3 [U] — mirror-push firmware:** `git fetch upstream master` then
|
||||
`git push --mirror ssh://git@git.dngr.us:20022/<dt-org>/proxmark3.git` (safe: target empty).
|
||||
- **Phase 4 [U] — seed `master`:** `git push <dt> refs/remotes/upstream/master:refs/heads/master`.
|
||||
- **Phase 5 [A] — commit EML on `pm3py`, repoint config, bump gitlink:** commit the EML
|
||||
host-poll; rename firmware `origin`→`mikefaith-backup`, add DT `origin`; set `.gitmodules`
|
||||
`url`=DT SSH + `branch=pm3py`, `submodule sync`; `git add .gitmodules firmware` + commit.
|
||||
- **Phase 6 [U] — publish the firmware tip (CRITICAL):** `git push origin pm3py`.
|
||||
- **Phase 7 [U] — mirror-push pm3py + repoint origin** to `<dt-org>/pm3py`.
|
||||
- **Phase 8 [U] — VERIFY** a fresh `git clone --recurse-submodules` of `<dt-org>/pm3py`:
|
||||
submodule resolves (no "reference is not a tree"), `make PLATFORM=PM3GENERIC` + `pytest`
|
||||
pass, then a hardware smoke test.
|
||||
- **Phase 9 [U] — retire old repos** (ARCHIVE read-only, never delete) only after Phase 8.
|
||||
|
||||
Rollback at any point: nothing is rewritten/deleted, so `git remote rename mikefaith-backup
|
||||
origin` restores the pre-migration state; the old repos and the Phase-0 bundles are the
|
||||
backstop until Phase 8 passes.
|
||||
|
||||
## Ongoing: pull RRG updates without impacting RRG
|
||||
|
||||
```
|
||||
git -C firmware fetch upstream master
|
||||
git -C firmware push origin refs/remotes/upstream/master:refs/heads/master # ff-only, pristine
|
||||
# rebase our patches onto the new base in an isolated worktree, build+test, then:
|
||||
git -C firmware push origin rebase-wip:pm3py --force-with-lease # only our branch, leased
|
||||
cd <pm3py> && git add firmware && git commit -m "chore(firmware): rebase onto RRG <tip>, bump gitlink"
|
||||
```
|
||||
`master` is append-only (never rewritten); only `pm3py` is force-pushed, always with a lease.
|
||||
The **first** pull also does the deferred clean split: enumerate our commits
|
||||
(`git log --author=michael --author=dangerous-tac0s upstream/master..pm3py`), skip
|
||||
already-upstreamed ones (hf-mfu-esetblk PR #3404), and rebuild `pm3py` as an explicit
|
||||
patch series onto fresh `master` — validate `git diff <rebuilt> pm3py` shows only deltas
|
||||
upstream lacks. Later pulls are the simple linear `rebase --onto` case. See the 2026-07-03
|
||||
plan for the per-file conflict detail (`iso15693.c` hot spot).
|
||||
|
||||
## Inputs needed from you
|
||||
|
||||
- The **DT org name** on git.dngr.us (substitutes `<dt-org>`).
|
||||
- Fork default branch: recommend `master` (matches RRG).
|
||||
- Confirm your SSH key has write in the `<dt-org>` namespace.
|
||||
|
||||
## Provenance
|
||||
|
||||
Multi-agent workflow (strategy eval + state audit + topology design → synthesized runbook).
|
||||
The adversarial data-loss verify pass was cut short by a rate limit; the mirror-to-empty-repo
|
||||
safety, publish-before-reference ordering, and EML backup were re-checked by hand.
|
||||
158
docs/plans/2026-07-05-pm3py-pyws-plugin-plan.md
Normal file
158
docs/plans/2026-07-05-pm3py-pyws-plugin-plan.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# pm3py ↔ pyws plugin — implementation plan (2026-07-05)
|
||||
|
||||
A pyws plugin, living in **this** repo, that turns pm3py into a first-class pyws workspace:
|
||||
open `pyws pm3` and you land in a shell with the reader connected (or the sim ready), the
|
||||
transponder classes in scope, your tags reconstructed from history, and replay/save that
|
||||
understand which pm3py operations touch hardware.
|
||||
|
||||
pyws itself stays domain-agnostic; all pm3py knowledge lives here and is discovered through the
|
||||
`pyws.plugins` entry point.
|
||||
|
||||
## Decisions (locked with the user)
|
||||
|
||||
- **Sim arming is explicit (option b).** On load the connection opens and the tag model is
|
||||
reconstructed, but the sim goes live only when *you* call `sim.start(tag)`. No auto-arm.
|
||||
- **One connection, one mode per workspace** — sim-mode (`SimSession` owns the serial port) or
|
||||
reader-mode (`Proxmark3` owns it), never both live. They can't share `/dev/ttyACM*`.
|
||||
- **`sim` is a lifecycle wrapper around `SimSession`** (which already runs its own trace thread) —
|
||||
response-table sims only for now, since upstream's WTX relay (`sim_session.py:_relay_loop`) is a
|
||||
stub for future 14a Layer-4.
|
||||
- **14a / NTAG first**, ISO-15693 a fast-follow.
|
||||
- `SimSession.open()` → `connect` effect (replayable); it raises loudly if no PM3 is attached — the
|
||||
resource surfaces that as a *disconnected* status on load (non-fatal) and as a clear error on an
|
||||
explicit call.
|
||||
|
||||
## Wiring
|
||||
|
||||
- Module: `pm3py/pyws_plugin.py`, class `Pm3pyPlugin(WorkspacePlugin)`.
|
||||
- Entry point in pm3py's `pyproject.toml`:
|
||||
```toml
|
||||
[project.entry-points."pyws.plugins"]
|
||||
pm3py = "pm3py.pyws_plugin:Pm3pyPlugin"
|
||||
|
||||
[project.optional-dependencies]
|
||||
pyws = ["pyws"] # installs the engine; dependency points pm3py -> pyws, never the reverse
|
||||
```
|
||||
- Install for use: `pip install -e ".[pyws]"` into the pm3py venv (which already has pm3py + deps).
|
||||
|
||||
## What the plugin provides
|
||||
|
||||
### Namespace (`Pm3pyPlugin.namespace(session)`)
|
||||
Inject what a pm3py workspace user reaches for, so `startup.py` needs no `from pm3py… import …`:
|
||||
- `SimSession` and the common tag classes (`NTAG213/215/216`, `MifareUltralight*`,
|
||||
`MifareClassicTag`, `NfcType2Tag`, `Tag15693`/`NfcType5Tag`, implant factories `xNT/xM1/NExT/…`).
|
||||
These are re-exported from `pm3py.sim` (`pm3py/sim/__init__.py:71`).
|
||||
- `reader` and/or `sim` (the resource objects, see below), bound by the resource layer.
|
||||
- NDEF helpers (`ndef_uri`, `ndef_text`, `ndef_mime` from `pm3py.sim.type5`).
|
||||
|
||||
Keep it a curated set, not a wildcard dump — anything else is a normal `from pm3py.sim import X`.
|
||||
|
||||
### `sim` resource (sim-mode) — `create_resource("sim", cfg, session)`
|
||||
A pyws `Resource` wrapping a `SimSession`.
|
||||
- `start()` (resource lifecycle, at session start): `SimSession.open(port, baudrate)`
|
||||
(`sim_session.py:73`). Port from cfg (`sim: {port: /dev/ttyACM0}`), else pm3py auto-detect.
|
||||
On failure (no device) → catch, `status = disconnected`, log a notice; **do not crash the
|
||||
session** (you can still edit tags offline).
|
||||
- Bound object `sim` exposes a thin facade over the session:
|
||||
- `sim.start(tag, *, tagtype=…, trace=False)` → dispatch to `sess.start_14a(tag, …)`
|
||||
(`sim_session.py:116`) or `sess.start_15693(tag, …)` (`:226`) by tag base class. **This is the
|
||||
explicit arm.** Effect: `sim`.
|
||||
- `sim.stop()` → `sess.stop()` (`:515`). Effect: `sim`.
|
||||
- `sim.push(tag=None)` → `tag.sync()` (or `sess.sync()`), the "push my edits to the running sim"
|
||||
step. Effect: `write`.
|
||||
- `sim.frames` → `sess.entries` (decoded reader↔tag trace).
|
||||
- `stop()` (session exit): `sess.stop(); sess.close()` (`:531`) — port released, tag unbound.
|
||||
|
||||
### `reader` resource (reader-mode) — `create_resource("reader", cfg, session)`
|
||||
A pyws `Resource` wrapping `Proxmark3` (`core/client.py:50`).
|
||||
- `start()`: build `Proxmark3(port, baudrate)` and connect. Since pyws resources start on the
|
||||
ResourceManager's async loop, `await pm3.connect()` fits naturally; the bound `reader` object is
|
||||
the connected client (`.hw/.hf/.lf`, `pm3.hf.iso14a.scan()`, `pm3.hf.mfu.rdbl()`, …). No-device →
|
||||
disconnected status, non-fatal.
|
||||
- `stop()`: `await pm3.disconnect()`.
|
||||
|
||||
### One mode per workspace
|
||||
A workspace declares exactly one of `reader:` / `sim:` under `resources:`. If both are declared,
|
||||
the plugin refuses the second with a clear message (they contend on the port). In-session switching
|
||||
(`sim.stop()` then connect a reader) is possible but not the default path.
|
||||
|
||||
### `tag` — user-created, **not** a resource
|
||||
The tag model is created by you (`authorized_tag = NTAG213(uid=…, password=…, pack=…)`) in
|
||||
`startup.py` or interactively, and reconstructed on load by autoreplay. It is *not* a plugin
|
||||
resource. (Optional future convenience: a `tag:` resource that builds a default tag from yaml
|
||||
config — deferred; yaml is a poor place for uid/keys/memory.)
|
||||
|
||||
## Effect declarations (replay safety)
|
||||
|
||||
`Pm3pyPlugin.effects` — pattern policy consumed by pyws's classifier. Safe-by-default deny-list:
|
||||
anything classified `write/field/sim/destructive` is withheld on autoreplay; everything else
|
||||
(incl. `connect`, `read`, pure model edits) replays.
|
||||
|
||||
```python
|
||||
effects = {
|
||||
"connect": ["sim.open", "sim.close", "reader.connect", "reader.disconnect"],
|
||||
"read": ["reader.hf.*.scan", "reader.hf.*.rdbl", "reader.hf.*.rdsc",
|
||||
"reader.hf.mfu.rdbl", "reader.hf.*.inventory", "*.read_block*"],
|
||||
"field": ["reader.hf.tune", "reader.hf.dropfield", "reader.lf.tune"],
|
||||
"write": ["reader.hf.*.wrbl", "reader.hf.*.writebl", "reader.lf.*.writebl",
|
||||
"*.sync", "*.write_block*"],
|
||||
"sim": ["sim.start", "sim.stop", "sim.start_14a", "sim.start_15693",
|
||||
"reader.hf.*.sim"],
|
||||
"destructive": ["*.format*", "*.lock*"],
|
||||
}
|
||||
```
|
||||
|
||||
Consequences (the important ones):
|
||||
- `authorized_tag = NTAG213(…)` and `tag.set_page(4, …)` / `tag.set_ndef(…)` → no match → `unknown`
|
||||
→ **replay** (pure-Python model edits, no device). So the tag rebuilds on load.
|
||||
- `tag.sync()` → `write` → **withheld** on autoreplay. Reconstruction rebuilds the *model*, never
|
||||
re-pushes to firmware.
|
||||
- `sim.start(tag)` → `sim` → **withheld** → the sim is armed explicitly (decision b).
|
||||
- `reader.hf.iso14a.scan()` → `read` → replays; `reader.hf.mfu.wrbl(…)` → `write` → withheld.
|
||||
|
||||
Patterns are refined against real method names during P3; treat the above as the shape.
|
||||
|
||||
## How it rides pyws's features
|
||||
|
||||
- **Autoreplay + `tag.sync()` ordering** — the model rebuilds from history (`set_page` etc. replay),
|
||||
but `sync()` is withheld, so after load the *model* is current while firmware is not. Since arming
|
||||
is explicit, your `sim.start(tag)` (or `sim.push()`) is what pushes the final model — no stale-sync
|
||||
hazard, and no arming before the tag exists.
|
||||
- **save() / state.py** — the tag is a live object → not written to `state.py` (only round-trippable
|
||||
data is). It comes back via history replay of its construction + edits. Keys/uids that you typed as
|
||||
literals persist fine; the object is rebuilt, not serialized.
|
||||
- **Events** — the plugin may subscribe to `WorkspaceReloaded` later for niceties (e.g. warn if the
|
||||
live sim is now serving a tag you've edited but not re-pushed). Not required for v1.
|
||||
- **Serial arbitration** — enforced by one-mode-per-workspace; the resource owns the single port.
|
||||
|
||||
## Phases
|
||||
|
||||
- **P1 — reader + namespace.** `Pm3pyPlugin` + entry point + `[pyws]` extra; `reader` resource
|
||||
(connect/disconnect) + namespace injection. `pyws` in a reader-mode workspace → connected `reader`.
|
||||
Tested against pm3py's existing `AsyncMock` transport (no hardware).
|
||||
- **P2 — sim resource.** `sim` resource wrapping `SimSession` (open on start, explicit
|
||||
`sim.start(tag)` / `sim.stop`, `sim.push`, `sim.frames`, clean teardown). Mock-tested where the
|
||||
serial layer allows; otherwise a thin fake `SimSession`.
|
||||
- **P3 — effects + docs.** Effect patterns above, verified against real method names; add the pyws
|
||||
integration section to pm3py's `CLAUDE.md`; a documented (non-scaffolded) `pm3` workspace example.
|
||||
- **P4 — hardware acceptance.** `pyws pm3` in sim-mode → create/reconstruct a tag → `sim.start(tag)`
|
||||
→ read it back with the ACR1552U. The real end-to-end.
|
||||
|
||||
## Testing (hardware-free)
|
||||
|
||||
- Reuse pm3py's `AsyncMock` transport pattern for the `reader` resource.
|
||||
- For `sim`, inject a fake `SimSession` (or patch `SimSession.open`) so the resource lifecycle,
|
||||
arming, effect classification, and teardown are all testable without a PM3.
|
||||
- pyws-side already covers resource lifecycle, effect gating, autoreplay — the plugin tests only its
|
||||
own wiring.
|
||||
|
||||
## Open / future
|
||||
- ISO-15693 sim path (`start_15693`, response-table compile/upload).
|
||||
- Auto-arm option (opt-in `sim.live: true`) if the explicit model gets tedious — the `WorkspaceLoaded`
|
||||
hook is already there.
|
||||
- Dual-interface (NTAG5, PM3 RF + MCU I2C via `DualInterfaceSession`) as a distinct resource.
|
||||
- WTX relay for 14a Layer-4 once upstream `_relay_loop` is real.
|
||||
|
||||
## pm3py docs to update (P3)
|
||||
- `CLAUDE.md`: a short "pyws integration" section — entry point, `[pyws]` extra, the reader/sim
|
||||
resources, and the effect/replay semantics.
|
||||
93
docs/plans/2026-07-16-c-client-oracle-and-lf-demod-parity.md
Normal file
93
docs/plans/2026-07-16-c-client-oracle-and-lf-demod-parity.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# C client oracle + LF demod parity — future-work notes
|
||||
|
||||
*2026-07-16. Written while finishing the rawcli T5577 catalog and validating on real
|
||||
hardware (a T5577 emulating **EM4100 `00FFFFFFFF`**, config word **`00148040`**).*
|
||||
|
||||
I wanted the stock proxmark3 C client as a ground-truth **oracle** for LF demod — to
|
||||
cross-check pm3py's decode of the live tag. It wouldn't run in this environment, and digging
|
||||
into why surfaced the notes below. **Net: there is no C-client _code_ bug to PR from what we
|
||||
found** — the two real items are (1) an environment blocker and (2) a pm3py-side demod-parity
|
||||
gap. Captured here so a future effort doesn't re-derive it.
|
||||
|
||||
## 1. The C client won't run inside the VS Code snap sandbox (oracle blocker)
|
||||
|
||||
- **Symptom:** `./pm3 -p /dev/ttyACM0 -c 'lf search'` →
|
||||
`symbol lookup error: /snap/core20/current/lib/x86_64-linux-gnu/libpthread.so.0:
|
||||
undefined symbol: __libc_pthread_init, version GLIBC_PRIVATE`
|
||||
- **Root cause:** this shell runs *inside* the VS Code snap (`SNAP_REVISION`, `SNAP_REAL_HOME`,
|
||||
`/snap/code/250/...`). The snap runtime injects core20's older glibc / `libpthread.so.0`
|
||||
ahead of the system libs the client was built against, so a `GLIBC_PRIVATE` symbol the
|
||||
system libc no longer exports gets looked up in the old snap libpthread and fails.
|
||||
- **Confirmed it is NOT a client defect:**
|
||||
- The binary has **no RPATH/RUNPATH**; its `NEEDED` libs (`libpython3.12`, `Qt5*`,
|
||||
`readline`, …) all resolve to system `/lib/x86_64-linux-gnu` under `ldd`.
|
||||
- `LD_LIBRARY_PATH` is **unset**; clearing `LD_LIBRARY_PATH`/`GTK_PATH` did not help — the
|
||||
injection is at the snap-runtime level, not via a variable we can scrub.
|
||||
- **To use the client as an oracle:** run it from a **non-snap login shell / real terminal**
|
||||
(outside VS Code's snap), or build+run it inside a matched (non-snap) environment. No
|
||||
proxmark3 change fixes a sandbox library injection.
|
||||
- **Marginal upstream idea (only if it ever matters):** the `pm3` launcher could sanitize
|
||||
obviously-contaminating library paths before `exec`, but that is a weak, environment-specific
|
||||
band-aid — not worth a PR on its own.
|
||||
- Client checkout here: `dangerous-tac0s/proxmark3` `v4.20728-1264-g273777b21`.
|
||||
|
||||
## 2. LF clock detection — pm3py's port was weaker than the C client (pm3py-side parity)
|
||||
|
||||
We found and fixed a real bug in pm3py `pm3py/lf/dsp.py::detect_clock`: it estimated the bit
|
||||
clock from the **minimum** edge interval, so a handful of stray sub-bit edges on a
|
||||
strongly-coupled, rail-clipped tag collapsed the estimate (true half-bit 32 → ~3), snapping the
|
||||
data clock to 8 and producing an all-phase-error ASK demod (nothing decoded). Fixed to key off
|
||||
the **shortest _well-supported_ interval** (≥20% of the modal count) instead of the raw minimum.
|
||||
|
||||
The C client's `DetectASKClock` (`common/lfdemod.c`) is already robust to this — worth porting
|
||||
its approach for full parity:
|
||||
|
||||
- It first runs `DetectCleanAskWave` and, for clean/strong/**clipped** peaks, routes to a
|
||||
dedicated `DetectStrongAskClock` — exactly the case that broke our port.
|
||||
- Otherwise it **error-scores every candidate clock** `{8,16,32,40,50,64,100,128,272}` against
|
||||
the wave and picks the best fit — not a single-interval heuristic.
|
||||
|
||||
**Action (pm3py, not a C-client PR):** port the candidate-clock error-scoring + a
|
||||
strong/clean-ASK fast path into `pm3py/lf`. Our support-based `detect_clock` fix is a partial,
|
||||
targeted step; the C client's scoring is the fuller solution.
|
||||
|
||||
## 3. Data-block reads — investigated to ground truth (no bug), one real improvement
|
||||
|
||||
Post-fix, pm3py decodes the live tag as:
|
||||
|
||||
- Emitted credential: **EM4100 `00FFFFFFFF`**
|
||||
- T5577 config block 0: **`00148040`** (Manchester, RF/64, MAXBLK 2, `ST=0`, non-inverted →
|
||||
EM4100/EM4102) — decoded rotation-resolved against presets (reliable).
|
||||
- Data blocks: `READ(1)=A108421F`, `READ(2)=007FE108`, which at first looked *wrong* (not a
|
||||
rotation of the encoder's `FF801EF7`/`BDEF7BC0`).
|
||||
|
||||
Chased to ground truth against the **ATA5577C datasheet + proxmark3 C source + an on-hardware
|
||||
write-read-back** — and there is **no read/addressing bug**:
|
||||
|
||||
- Datasheet §5.9: the direct-access command (`opcode 10` + `0` + 3-bit addr — exactly what the
|
||||
firmware sends) reads *only the addressed block*, repetitively. A clean 32-bit repeat in the
|
||||
capture proves block-read mode engaged (the 64-bit emulation stream can't produce one).
|
||||
- `T55xx_SetBits` clocks the address MSB-first; pm3py's `readbl` payload matches the firmware
|
||||
struct byte-for-byte. Addressing is stock-proxmark, verified end-to-end.
|
||||
- A block-3 write-read-back was **not** decisive on its own: `writebl` and `readbl` share the
|
||||
same address path, so they round-trip regardless of whether addressing is correct (it only
|
||||
proved the demod inverts). Proxmark, Flipper, and pm3py all write the header into block 1, so
|
||||
the alternative "pm3py reads 1↔2 swapped" had to be excluded with an **addressing-free** read.
|
||||
- **Decisive test (MAXBLK=1):** temporarily set the config MAXBLK field to 1 (`00148040 →
|
||||
00148020`) so regular-read streams *only physical block 1* (datasheet §5.11.1), read it off
|
||||
the air (no read-command addressing), then restored `00148040`. Physical block 1 came back as
|
||||
`~BDEF7BC0` — the **non-header** half — matching `READ(1)`. So `READ(1)` == physical block 1.
|
||||
|
||||
Conclusion: this tag was simply **cloned with the two EM4100 halves in reverse order**
|
||||
(block 1 = `BDEF7BC0`, block 2 = `FF801EF7`) by a non-proxmark/Flipper/pm3py tool; both orders
|
||||
stream the same cyclic waveform and read as `00FFFFFFFF`. pm3py reads them correctly. The "wrong"
|
||||
appearance was polarity (inversion) + rotation, i.e. the documented "best-effort — bit alignment
|
||||
not verified".
|
||||
|
||||
**Real improvement (open):** `read_t55xx_block` should *resolve* data-block polarity/rotation
|
||||
instead of leaving it best-effort. Polarity is determinable — read block 0 (config), resolve its
|
||||
polarity against the known preset, and carry that polarity to the data blocks (they share the
|
||||
tag's modulation). Rotation can be anchored off the block-read response start. The C client's
|
||||
`cmdlft55xx` demod already does this alignment — worth porting alongside §2. Still not
|
||||
cross-checked against `lf t55 dump` (client blocked by §1), but the write-read-back makes that
|
||||
non-blocking.
|
||||
68
docs/plans/README.md
Normal file
68
docs/plans/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# pm3py roadmap — design & implementation plans
|
||||
|
||||
This directory is the project roadmap: dated design docs (`*-design.md`) and
|
||||
implementation plans (`*-plan.md`). Each file is a self-contained spec — problem,
|
||||
approach, concrete steps, tests, risks.
|
||||
|
||||
> **Status column is best-effort**, inferred from the codebase and memory at the time of
|
||||
> writing (2026-07-05). Correct it if a plan's real state differs. Legend:
|
||||
> ✅ landed · 🔄 in progress · 📋 planned · 📎 reference/ongoing.
|
||||
|
||||
## ISO 14443-A sim expansion
|
||||
|
||||
| Plan | Status | Summary |
|
||||
|------|--------|---------|
|
||||
| [2026-07-05-14a-live-trace-plan](2026-07-05-14a-live-trace-plan.md) | 🔄 | Live reader↔tag trace for the 14a sim (firmware ring + `EmLogTrace` capture + pm3py consumer). Code complete; flush + direct-send-capture fixes awaiting hardware verify. |
|
||||
| [2026-07-05-14a-custom-commands-design](2026-07-05-14a-custom-commands-design.md) | 📋 | **Design:** how to serve custom 14a-3 (NTAG I2C SECTOR_SELECT + cross-sector reads) and 14a-4 (vendor ISO-DEP APDUs) commands — table-first L3 + WTX-L4 hybrid layered on the native-EML sim. |
|
||||
| [2026-07-05-14a-custom-commands-plan](2026-07-05-14a-custom-commands-plan.md) | 📋 | **Plan:** sequenced L3 + L4 tracks — `sim_table` hook, sector-offset graft, WTX relay, `TableCompiler` extension, tests. |
|
||||
|
||||
## Sim framework & response table
|
||||
|
||||
| Plan | Status | Summary |
|
||||
|------|--------|---------|
|
||||
| [2026-03-17-stateful-table-firmware-design](2026-03-17-stateful-table-firmware-design.md) | ✅ | Response-table sim in firmware BigBuf: stateful `sim_table` + `sim_crypto`, group/auth state. |
|
||||
| [2026-03-17-stateful-table-firmware-plan](2026-03-17-stateful-table-firmware-plan.md) | ✅ | Implementation of the above (15693 sim fully working: inventory, NDEF, NXP custom commands). |
|
||||
| [2026-03-17-table-compiler-update-plan](2026-03-17-table-compiler-update-plan.md) | ✅ | `TableCompiler` / `ResponseTable` — compile per-IC responses into the 120-byte entry format. |
|
||||
|
||||
## Trace & sniff
|
||||
|
||||
| Plan | Status | Summary |
|
||||
|------|--------|---------|
|
||||
| [2026-03-17-trace-formatter-design](2026-03-17-trace-formatter-design.md) | ✅ | `TraceFormatter` — colored, decoded, column-wrapped trace output (sim/reader/sniff modes). |
|
||||
| [2026-03-17-trace-formatter-plan](2026-03-17-trace-formatter-plan.md) | ✅ | Implementation of the trace formatter. |
|
||||
| [2026-03-18-ndef-trace-decode](2026-03-18-ndef-trace-decode.md) | ✅ | NDEF TLV/record decode for trace annotation. |
|
||||
| [2026-03-19-live-sniff-design](2026-03-19-live-sniff-design.md) | ✅ | Live streaming sniff (firmware `CMD_HF_SNIFF_STREAM` + `SniffSession`). |
|
||||
| [2026-03-19-live-sniff-plan](2026-03-19-live-sniff-plan.md) | ✅ | Implementation of live sniff. |
|
||||
|
||||
## Package refactor & migration
|
||||
|
||||
| Plan | Status | Summary |
|
||||
|------|--------|---------|
|
||||
| [2026-03-18-refactor-design](2026-03-18-refactor-design.md) | ✅ | Split the monolith into `core/`, `trace/`, `sniff/`, `sim/`, `transponders/`. |
|
||||
| [2026-03-18-refactor-progress](2026-03-18-refactor-progress.md) | ✅ | Progress log for the refactor (complete, all on master). |
|
||||
| [2026-03-18-sim-migration](2026-03-18-sim-migration.md) | ✅ | Migrate the sim framework into the new package layout. |
|
||||
|
||||
## Firmware maintenance
|
||||
|
||||
| Plan | Status | Summary |
|
||||
|------|--------|---------|
|
||||
| [2026-07-03-firmware-upstream-rebase-plan](2026-07-03-firmware-upstream-rebase-plan.md) | 📎 | Rebase the `proxmark3-pm3py` fork against upstream PM3 (atomic single-file-commit workflow). |
|
||||
| [2026-07-05-firmware-fork-dt-migration](2026-07-05-firmware-fork-dt-migration.md) | 📋 | Migrate the firmware fork + pm3py to the DT Gitea; clean `master`(RRG-mirror)/`pm3py`(patches) rebase-fork model. |
|
||||
|
||||
## Firmware flashing
|
||||
|
||||
| Plan | Status | Summary |
|
||||
|------|--------|---------|
|
||||
| [2026-07-05-firmware-flasher](2026-07-05-firmware-flasher.md) | ✅ | Pure-Python flasher (OLD-frame bootloader protocol) + `pm3flash` CLI + firmware pin; hardware-validated on a PM3 Easy. Auto-download of the pinned release is the open follow-up (rides on the DT-Gitea migration). |
|
||||
|
||||
---
|
||||
|
||||
### Conventions
|
||||
|
||||
- **Naming:** `YYYY-MM-DD-<topic>-{design,plan}.md`. A design doc explains *why* and *what*;
|
||||
a plan doc gives sequenced, implementation-grade steps. Small features may have only a
|
||||
`-plan.md`.
|
||||
- **Firmware changes** land as atomic single-file commits anchored to stable symbols (not
|
||||
line numbers) for easy rebase against upstream — see the firmware-upstream-rebase plan.
|
||||
- **Provenance:** plans produced with multi-agent design workflows note it in a Provenance
|
||||
section.
|
||||
100
docs/rawcli-todo.md
Normal file
100
docs/rawcli-todo.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# rawcli — catalog & memory-hinting roadmap
|
||||
|
||||
The MVP is a list of **named transponders**, each surfaced through the **same UX**: a command
|
||||
catalog + a datasheet memory map, both offered as **autocomplete suggestions in the completion
|
||||
dropdown** — never on the bottom bar, never in command output.
|
||||
|
||||
## The UX contract (applies to every transponder)
|
||||
|
||||
- Completion dropdown offers: command names (by letter → `READ(`), opcodes (by hex/binary value →
|
||||
raw byte inserted, command as the label), and page/block landmarks with datasheet roles.
|
||||
- **Must work in both hex AND binary entry modes**, rendering in the active base.
|
||||
- Function-call args are base-10 (`READ(4)`); raw bytes are hex, `0b` to intermix binary.
|
||||
|
||||
## FIXED — raw byte-line autocomplete now fires in hex AND binary
|
||||
|
||||
Root cause: `entry._digit` applied each keystroke as `buf.document = Document(...)`, which resets
|
||||
prompt_toolkit's `complete_state` and never re-fires completion — so the live menu never appeared
|
||||
during raw byte entry. This hit **both** hex and binary (the earlier "hex works, binary doesn't"
|
||||
was inaccurate; only command-name completion worked, because that path already used `insert_text`).
|
||||
Fixed by appending the per-keystroke delta via `buf.insert_text` instead — `type_digit` only ever
|
||||
appends (optionally after an auto-space) or drops, so the change is always a pure suffix, and
|
||||
`insert_text` triggers completion exactly like an ordinary keystroke. Verified by driving the real
|
||||
key handler: the menu now populates on every digit in both modes. Regression:
|
||||
`TestKeyBindings::test_digit_binding_fires_completion_trigger_for_raw_bytes`.
|
||||
|
||||
The Ctrl-t / Ctrl-_ toggle correctly flips the breadcrumb `0x` <-> `0b` and requests a redraw
|
||||
(verified, guarded by `test_toggle_updates_breadcrumb_prefix`). An end-to-end live-session test
|
||||
(`TestRawcliLiveSession`) drives the real PromptSession via a pipe input. A human eyeball of the
|
||||
rendered dropdown on a real terminal is the only thing left unautomated.
|
||||
|
||||
## Priority 1 — MIFARE Classic (do this first)
|
||||
|
||||
- **Identify**: distinguish 1K (SAK 0x08) / 4K (0x18) / Mini (0x09); today only named, no map.
|
||||
- **Memory map** (block → role), mirroring the NTAG page map:
|
||||
- Block 0 = manufacturer block: UID + BCC + SAK + ATQA + manufacturer data.
|
||||
- Sector trailer (block 3,7,…,63 on 1K; last block of each sector on 4K): **Key A (0-5),
|
||||
access bits (6-8) + GPB (9), Key B (10-15)** — the access bits are the bit-level detail.
|
||||
- Other blocks = data block (with sector number).
|
||||
- 4K: sectors 0-31 are 4 blocks; sectors 32-39 are 16 blocks each.
|
||||
- **Catalog**: READ/WRITE/HALT exist; note that reads/writes need prior AUTH (Key A/B) — the
|
||||
authenticate step is the reader's job before these.
|
||||
- Autocomplete must work in hex + binary (see the bug above).
|
||||
|
||||
## After MFC — 15693 vicinity chips: ICODE SLIX ✅, SLIX2 ✅, NTAG5 ✅, ICODE DNA (next)
|
||||
|
||||
1. **15693 header builder** ✅ — `iso15_frame()` (flags + command + NXP mfg 0x04), shared by all
|
||||
15693 catalogs. The **full request-flags byte** is settable: sub-carrier (0x01), data-rate,
|
||||
inventory, protocol-ext (0x08), and the mode-dependent bits 5-6 (Select/Address vs AFI byte +
|
||||
Nb_slots 1/16), plus option. `iso15_flags_decode` decodes every bit for the byte-0 hint.
|
||||
2. **Per-chip identify** ✅ — `identify.name_iso15` now splits the whole E0 04 family with a
|
||||
READ_CONFIG (0xC0) probe (addressed + retried), **hardware-verified against a real NTAG5 and a
|
||||
real DNA**: session register 0xA0 succeeds only on NTAG5 (`00 01400000`); DNA errors on 0xA0
|
||||
(`01 0F`) but its config block 0x00 succeeds; SLIX2 has no READ_CONFIG (falls to the 0xBD
|
||||
signature step); plain SLIX answers neither. So NTAG5 → "NTAG 5", DNA → "ICODE DNA" (was
|
||||
mislabeled SLIX2), SLIX2/SLIX as before. Real UIDs: NTAG5 `E0 04 01 58`, DNA `E0 04 01 18`
|
||||
(the model's NTAG5 prefix `…18` was copied from DNA; real NTAG5 is `…58`).
|
||||
3. **Catalogs** — `_SLIX`, `_SLIX2`, and **`_NTAG5`** ✅ all done, dispatched by `catalog_for`,
|
||||
verified live. `_NTAG5` = SLIX2 minus STAY_QUIET_PERSISTENT (NTAG5 rejects it) + READ_CONFIG
|
||||
(0xC0) / WRITE_CONFIG (0xC1) / PICK_RANDOM_UID (0xC2) / READ_SRAM (0xD2) / WRITE_SRAM (0xD3).
|
||||
**Follow-up:** the AES / ISO 29167-10 commands AUTHENTICATE (0x35) / CHALLENGE (0x39) / READBUFFER
|
||||
(0x3A) for Link/Boost (AES-capable) variants — a crypto sub-protocol with its own param format.
|
||||
4. Autocomplete UX ✅ (hex + binary, command names + opcode-by-value; the byte-line completion bug
|
||||
is fixed).
|
||||
|
||||
`INVENTORY_READ` (0xA0) / `FAST_INVENTORY_READ` (0xA1) ✅ — in `_SLIX` (inherited by `_SLIX2`),
|
||||
**full request body** via `_inv_read_frame`: `[AFI] | mask_len(bits) | mask value | first_block |
|
||||
count-1`, with the inventory flags set by `iso15_frame(inventory=…, afi=…)`. Mask-less (default),
|
||||
mask-based selective anticollision, and AFI filter all supported. **15693 is now datasheet-complete
|
||||
end to end** (flags byte + both SLIX/SLIX2 command sets + inventory-read body).
|
||||
|
||||
### NEXT: ICODE DNA catalog (`_DNA`)
|
||||
|
||||
DNA is now identified ("ICODE DNA") but borrows `_SLIX2`. Build a dedicated `_DNA` catalog. Sourcing
|
||||
(datasheets are local in the repo root — see [[reference_datasheets]] in memory):
|
||||
- **Command set + semantics + memory map:** `SL2S6002_SDS.pdf` (the DNA short data sheet). It names
|
||||
the commands but — being the *short* sheet — omits exact opcodes (defers to the NDA full sheet).
|
||||
- **Opcodes for the shared commands:** `SL2S2602.pdf` (SLIX2) — DNA extends SLIX2, so EAS/AFI,
|
||||
password (0xB2-B5), READ_SIGNATURE (0xBD), persistent-quiet, etc. carry over. Start from
|
||||
`_SLIX2.commands` like `_NTAG5` did.
|
||||
- **DNA-specific:** READ_CONFIG (0xC0) / WRITE_CONFIG (0xC1) — hardware-confirmed on the real DNA;
|
||||
CID (default 0xC000); AES CHALLENGE (0x39) / AUTHENTICATE (0x35) / READBUFFER (0x3A) from
|
||||
`docs/NTAG5_SECURITY.md` (verified protocol) — same crypto sub-protocol as the NTAG5 AES follow-up.
|
||||
- **Memory map** (for `memory.py`): user = **63 blocks (0-62)**, **block 63 = 16-bit counter**;
|
||||
**config memory = 48 blocks** via READ_CONFIG (holds keys, CID, originality signature, privileges).
|
||||
- Route `"DNA"` → `_DNA` in `catalog_for` (currently → `_SLIX2`). Hardware-verify against the real
|
||||
DNA (finicky: 16-slot inventory + addressed + retries, per [[project_ntag5_dna_id]]).
|
||||
|
||||
The NTAG5 AES commands (0x35/0x39/0x3A) follow-up shares this crypto work — source from `NTA5332.pdf`
|
||||
(the real NTAG5 Link/Boost datasheet, also local) + `docs/NTAG5_SECURITY.md`.
|
||||
|
||||
**Hardware verify pending:** SLIX2 catalog was verified only against unit tests + a live-session
|
||||
drive (no SLIX2 tag on the reader). Confirm on a real SLIX2 when one is available.
|
||||
|
||||
## Also queued
|
||||
|
||||
- **T5577 config block (block 0)** bitfield decode (modulation, bit-rate, block count, PWD/AOR) —
|
||||
currently just "config block".
|
||||
- NTAG I2C plus (model exists, identify names it) — catalog + map.
|
||||
|
||||
See [[project_rawcli_mvp]] in memory for the named-transponder scope.
|
||||
2
firmware
2
firmware
Submodule firmware updated: 35db99fa58...3b6201d972
Binary file not shown.
Binary file not shown.
39
pm3py/_firmware.py
Normal file
39
pm3py/_firmware.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Firmware pin — the fork firmware build this pm3py release corresponds to.
|
||||
|
||||
pm3py and the firmware fork share a wire contract (command IDs, the response-table
|
||||
format, the WTX relay, the sim-trace path), so a given pm3py must be paired with a
|
||||
specific firmware build. This module records that pairing.
|
||||
|
||||
The pin is stamped from the ``firmware/`` submodule commit. Until tagged firmware
|
||||
releases exist it is the raw commit SHA; a release process will later set ``tag``
|
||||
and asset URLs, and auto-download will resolve the matching ``fullimage.elf``
|
||||
against them. Nothing here reads the submodule at runtime — a pip-installed wheel
|
||||
has no submodule — so the value is baked in and bumped when the pin moves.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FirmwarePin:
|
||||
"""A pinned firmware build."""
|
||||
sha: str # firmware/ submodule commit (short is fine)
|
||||
tag: str | None = None # release tag, once tagged releases exist
|
||||
|
||||
|
||||
# The firmware/ submodule commit this pm3py build was developed and tested against.
|
||||
FIRMWARE_PIN = FirmwarePin(sha="3b6201d97")
|
||||
|
||||
|
||||
def matches(version_string: str, pin: FirmwarePin = FIRMWARE_PIN) -> bool:
|
||||
"""True if a device's firmware version string corresponds to ``pin``.
|
||||
|
||||
The firmware embeds its git revision (e.g. ``Iceman/main/303bd5873-dirty``)
|
||||
in the version string, so a 7-char SHA prefix (git's default abbreviation) is
|
||||
a reliable substring test. A configured ``tag`` matches too, once releases
|
||||
are tagged.
|
||||
"""
|
||||
if not version_string:
|
||||
return False
|
||||
if pin.tag and pin.tag in version_string:
|
||||
return True
|
||||
return bool(pin.sha) and pin.sha[:7] in version_string
|
||||
5
pm3py/cli/__init__.py
Normal file
5
pm3py/cli/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""pm3py command-line tools. The ``pm3py`` console-script dispatches to subcommands (rawcli,
|
||||
and future flash/client). See :mod:`pm3py.cli.main`."""
|
||||
from .main import main
|
||||
|
||||
__all__ = ["main"]
|
||||
8
pm3py/cli/clientcli/__init__.py
Normal file
8
pm3py/cli/clientcli/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""``pm3py client`` — an interactive prompt_toolkit REPL that wraps the stock Proxmark3 C client.
|
||||
|
||||
Unlike :mod:`pm3py.cli.rawcli` (which speaks pm3py's pure-Python protocol stack), this drives the
|
||||
real ``proxmark3`` binary over pexpect, so it works with any Proxmark3 / firmware. It offers
|
||||
autocompletion and help hints for the full pm3 command tree — sourced from the client's
|
||||
``commands.json`` help dump — plus a right-arrow help toggle: highlight a completion, press RIGHT to
|
||||
expand its full help, LEFT / ESCAPE to back out.
|
||||
"""
|
||||
101
pm3py/cli/clientcli/app.py
Normal file
101
pm3py/cli/clientcli/app.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""clientcli interactive loop — a prompt_toolkit REPL that drives the stock ``proxmark3`` client
|
||||
with command-tree autocompletion, live hints, and right-arrow help shown in the completion
|
||||
dropdown. Mirrors the :mod:`pm3py.cli.rawcli.app` skeleton; the testable logic lives in the
|
||||
sibling modules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from .tree import load_command_tree
|
||||
from .driver import make_driver, DriverError
|
||||
from .session import ClientSession
|
||||
from .completer import ClientCompleter
|
||||
from .keys import install_key_bindings
|
||||
from .hints import one_line_hint
|
||||
|
||||
_DEFAULT_HINT = "type a pm3 command · Tab to complete · → help on a selection · 'quit'"
|
||||
|
||||
|
||||
def dispatch(state, line, out=print) -> bool:
|
||||
"""Handle one input line. Returns False to exit. ``quit``/``exit``/``q`` leave the wrapper;
|
||||
everything else is sent to the client and its output printed. A driver failure ends the loop."""
|
||||
cmd = line.strip()
|
||||
if cmd.lower() in ("quit", "exit", "q"):
|
||||
return False
|
||||
try:
|
||||
output = state.driver.execute(cmd)
|
||||
except DriverError as exc:
|
||||
out(f"client error: {exc}")
|
||||
return False
|
||||
if output:
|
||||
out(output)
|
||||
return True
|
||||
|
||||
|
||||
def run(port=None, commands=None, client="proxmark3", driver="auto") -> int:
|
||||
from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.application import get_app
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import ANSI
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from prompt_toolkit.shortcuts import CompleteStyle
|
||||
|
||||
def out(text):
|
||||
# render the ANSI our client/formatters emit (colour on a TTY, stripped when piped)
|
||||
print_formatted_text(ANSI(str(text)))
|
||||
|
||||
try:
|
||||
tree, meta = load_command_tree(commands)
|
||||
except FileNotFoundError as exc:
|
||||
print(f"pm3py client: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
dev = make_driver(port=port, client=client, driver=driver)
|
||||
try:
|
||||
dev.start()
|
||||
except DriverError as exc:
|
||||
print(f"pm3py client: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
state = ClientSession(dev, tree)
|
||||
|
||||
def bottom_toolbar():
|
||||
# only ever the one-line relevance hint — the full help lives in the completion dropdown
|
||||
try:
|
||||
hint = one_line_hint(tree, get_app().current_buffer.text)
|
||||
except Exception:
|
||||
hint = None
|
||||
return ANSI(hint) if hint else _DEFAULT_HINT
|
||||
|
||||
kb = KeyBindings()
|
||||
install_key_bindings(kb, state)
|
||||
session = PromptSession(
|
||||
completer=ClientCompleter(state),
|
||||
complete_while_typing=True, # ipython-style live menu
|
||||
complete_style=CompleteStyle.COLUMN, # single column keeps ←/→ free for the toggle
|
||||
auto_suggest=AutoSuggestFromHistory(), # inline suggestion hints
|
||||
bottom_toolbar=bottom_toolbar, # live relevance / help panel
|
||||
key_bindings=kb,
|
||||
)
|
||||
|
||||
def message():
|
||||
return ANSI(state.colored_breadcrumb() + " ")
|
||||
|
||||
n = meta.get("commands_extracted", "?")
|
||||
out(f"pm3py client — {n} commands · {dev.backend} backend · → help · 'quit' to exit")
|
||||
with patch_stdout():
|
||||
while True:
|
||||
state.help_open = False # each new line starts with help collapsed
|
||||
state.help_target = None
|
||||
try:
|
||||
line = session.prompt(message)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if not line.strip():
|
||||
continue
|
||||
if not dispatch(state, line, out):
|
||||
break
|
||||
dev.close()
|
||||
out("bye")
|
||||
return 0
|
||||
82
pm3py/cli/clientcli/completer.py
Normal file
82
pm3py/cli/clientcli/completer.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Completion for ``pm3py client``: walk the command trie by cursor position, offering subcommands
|
||||
at each level and option flags on a leaf. Models :class:`pm3py.cli.rawcli.completer.RawCompleter`
|
||||
— route by position, yield ``Completion`` with ``display_meta`` as the tooltip."""
|
||||
from __future__ import annotations
|
||||
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
|
||||
from .hints import help_rows
|
||||
from .tree import parse_option
|
||||
|
||||
|
||||
def _skip(flags) -> bool:
|
||||
return "--help" in flags or "-h" in flags
|
||||
|
||||
|
||||
class ClientCompleter(Completer):
|
||||
def __init__(self, session):
|
||||
self._session = session
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
# help mode (RIGHT on a selection): the dropdown becomes the command's help — the same
|
||||
# two-column rows as normal completions, but non-inserting (LEFT restores completion).
|
||||
if self._session.help_open:
|
||||
for disp, meta in help_rows(self._session.help_target):
|
||||
yield Completion("", start_position=0, display=disp, display_meta=meta)
|
||||
return
|
||||
|
||||
tree = self._session.tree
|
||||
text = document.text_before_cursor
|
||||
tokens = text.split()
|
||||
at_word = bool(tokens) and not text.endswith(" ")
|
||||
partial = tokens[-1] if at_word else ""
|
||||
path = tokens[:-1] if at_word else tokens
|
||||
|
||||
node = tree.node_at(path)
|
||||
if node is not None:
|
||||
# inside the tree: a leaf with no more subcommands (or a flag being typed) → options;
|
||||
# otherwise the child subcommands at this level.
|
||||
if node.is_leaf and (partial.startswith("-") or not node.children):
|
||||
yield from self._options(node.entry, partial)
|
||||
else:
|
||||
yield from self._subcommands(node, partial)
|
||||
return
|
||||
|
||||
# a path token fell off the tree — we're in a leaf's argument territory; offer flags only.
|
||||
leaf = self._deepest_leaf(tree, path)
|
||||
if leaf is not None and partial.startswith("-"):
|
||||
yield from self._options(leaf, partial)
|
||||
|
||||
def _subcommands(self, node, partial):
|
||||
for tok in sorted(node.children):
|
||||
if not tok.startswith(partial):
|
||||
continue
|
||||
child = node.children[tok]
|
||||
if child.is_leaf:
|
||||
meta = child.entry.get("description", "")
|
||||
else:
|
||||
meta = f"{len(child.children)} subcommands"
|
||||
# insert a trailing space so accepting the completion commits the token and immediately
|
||||
# re-triggers completion at the next level ("hf " + <tab> → "hf 14a " → 14a's children)
|
||||
yield Completion(tok + " ", start_position=-len(partial), display=tok, display_meta=meta)
|
||||
|
||||
def _options(self, entry, partial):
|
||||
for opt in entry.get("options", []):
|
||||
flags, meta, desc = parse_option(opt)
|
||||
if not flags or _skip(flags):
|
||||
continue
|
||||
match = next((f for f in flags if f.startswith(partial)), None)
|
||||
if match is None:
|
||||
continue
|
||||
disp = (", ".join(flags) + (f" {meta}" if meta else "")).strip()
|
||||
yield Completion(match, start_position=-len(partial), display=disp, display_meta=desc)
|
||||
|
||||
def _deepest_leaf(self, tree, path):
|
||||
node, leaf = tree.root, None
|
||||
for tok in path:
|
||||
node = node.children.get(tok)
|
||||
if node is None:
|
||||
break
|
||||
if node.is_leaf:
|
||||
leaf = node.entry
|
||||
return leaf
|
||||
281
pm3py/cli/clientcli/driver.py
Normal file
281
pm3py/cli/clientcli/driver.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Drivers for the stock Proxmark3 C client.
|
||||
|
||||
``pm3py client`` does not reimplement the wire protocol — it drives the real client, so it works
|
||||
with any Proxmark3 / firmware. Two interchangeable backends behind :class:`ClientDriver`:
|
||||
|
||||
- :class:`SwigDriver` (default) — the in-process ``_pm3`` SWIG bindings: ``pm3.pm3(port).console()``
|
||||
+ ``grabbed_output``. Cleaner (no prompt-matching) but needs the built ``_pm3`` extension and a
|
||||
connected device (the C ``pm3_open`` calls ``exit()`` on a real port with no responding PM3).
|
||||
- :class:`PexpectDriver` — spawns the ``proxmark3`` binary and reads output up to the next
|
||||
``pm3 -->`` prompt. Zero build; the fallback when ``_pm3`` isn't importable. The client's ANSI
|
||||
colour is preserved (we ANSI-strip only for prompt/echo matching), and we never mutate ``prefs``.
|
||||
|
||||
:func:`make_driver` picks SWIG when ``_pm3`` is importable, else pexpect.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .tree import find_repo_root
|
||||
|
||||
#: full prompt matcher for clean text: ``"[<dev><net><ctx>] pm3 --> "``. Used to parse a label the
|
||||
#: caller already has in hand (and in tests); pexpect matches on the tail instead — see below.
|
||||
PROMPT_RE = re.compile(r"\[([^\]]*)\] pm3 -->\s*$")
|
||||
#: what pexpect waits for. The ``pm3 --> `` tail is emitted uncoloured, so this matches regardless
|
||||
#: of colour or the bracketed-paste sequences readline emits around the ``[...]`` label — matching
|
||||
#: the label directly would let a stray ``[`` (from ``\x1b[?2004h``) capture the wrong bracket.
|
||||
_EXPECT_RE = re.compile(r"pm3 -->\s*$")
|
||||
#: the ``[label]`` at the end of the ANSI-stripped text just before the tail.
|
||||
_LABEL_RE = re.compile(r"\[([^\]]*)\]\s*$")
|
||||
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
||||
#: every CSI sequence except SGR colour (final byte ``m``) — strips readline's bracketed-paste /
|
||||
#: cursor / erase controls from passed-through output while keeping the client's colour intact.
|
||||
_NONSGR = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-ln-~]")
|
||||
|
||||
#: fallback locations searched when a bare client name isn't on PATH (typical fork build layouts).
|
||||
_CLIENT_FALLBACKS = (
|
||||
"~/proxmark3/client/proxmark3",
|
||||
"/usr/local/bin/proxmark3",
|
||||
"/usr/bin/proxmark3",
|
||||
)
|
||||
|
||||
|
||||
def resolve_client(client: str) -> str:
|
||||
"""Resolve the client binary. An explicit path is used verbatim; a bare name is looked up on
|
||||
PATH. Only the *default* ``proxmark3`` name falls back to known fork build locations — an
|
||||
explicitly-named client that isn't found is returned unchanged, so the spawn fails clearly
|
||||
rather than silently running a different binary. """
|
||||
if os.sep in client or (os.altsep and os.altsep in client):
|
||||
return client
|
||||
found = shutil.which(client)
|
||||
if found:
|
||||
return found
|
||||
if client == "proxmark3": # auto-locate only the default binary
|
||||
candidates = []
|
||||
root = find_repo_root()
|
||||
if root is not None: # the checkout this file was dropped beside
|
||||
candidates.append(root / "client" / "proxmark3")
|
||||
candidates += [Path(c).expanduser() for c in _CLIENT_FALLBACKS]
|
||||
for p in candidates:
|
||||
if p.is_file() and os.access(p, os.X_OK):
|
||||
return str(p)
|
||||
return client
|
||||
|
||||
|
||||
class DriverError(RuntimeError):
|
||||
"""The underlying client could not be launched, timed out, or exited."""
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
return _ANSI.sub("", text or "")
|
||||
|
||||
|
||||
def _scrubbed_env() -> dict:
|
||||
"""A minimal environment for spawning the client. Snap / GTK / ``LD_*`` vars leaking in from a
|
||||
sandboxed terminal (e.g. VSCode's snap) make the Qt-linked client crash on a libpthread symbol
|
||||
clash, so we hand it only what it needs."""
|
||||
env = {"TERM": os.environ.get("TERM", "xterm")}
|
||||
for key in ("HOME", "PATH", "USER", "LANG", "LC_ALL"):
|
||||
if key in os.environ:
|
||||
env[key] = os.environ[key]
|
||||
env.setdefault("PATH", "/usr/local/bin:/usr/bin:/bin")
|
||||
return env
|
||||
|
||||
|
||||
def _extract_output(raw: str, sent: str) -> str:
|
||||
"""A command's output: ``child.before`` minus the echoed command line and framing blank lines.
|
||||
The client's ANSI colour is preserved; readline's non-colour control sequences (bracketed
|
||||
paste, cursor moves) are stripped, and the echo comparison is fully ANSI-stripped."""
|
||||
lines = _NONSGR.sub("", raw or "").replace("\r", "").split("\n")
|
||||
while lines and not strip_ansi(lines[0]).strip():
|
||||
lines.pop(0)
|
||||
if lines and strip_ansi(lines[0]).strip() == sent.strip():
|
||||
lines.pop(0)
|
||||
return "\n".join(lines).strip("\n")
|
||||
|
||||
|
||||
class ClientDriver:
|
||||
"""Driver interface. ``prompt`` is the last-seen context label (``usb`` / ``offline`` / …);
|
||||
``backend`` names the mechanism (``swig`` / ``pexpect``)."""
|
||||
|
||||
prompt = "offline"
|
||||
backend = "?"
|
||||
|
||||
def start(self) -> None: ...
|
||||
def execute(self, line: str) -> str: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
class PexpectDriver(ClientDriver):
|
||||
backend = "pexpect"
|
||||
|
||||
def __init__(self, port=None, client="proxmark3", timeout=30):
|
||||
self._port = port
|
||||
self._client = resolve_client(client)
|
||||
self._timeout = timeout
|
||||
self._child = None
|
||||
self.prompt = "offline"
|
||||
|
||||
def start(self) -> None:
|
||||
import pexpect
|
||||
|
||||
# the stock binary does NOT auto-detect a port (that's the `pm3` wrapper's job) — without
|
||||
# one it drops to offline mode, so resolve a port ourselves when none was given
|
||||
port = self._port or _autodetect_port()
|
||||
args = [port] if port else []
|
||||
try:
|
||||
self._child = pexpect.spawn(
|
||||
self._client, args, env=_scrubbed_env(),
|
||||
encoding="utf-8", codec_errors="replace", timeout=self._timeout,
|
||||
)
|
||||
self._child.expect(_EXPECT_RE)
|
||||
except pexpect.ExceptionPexpect as exc:
|
||||
raise DriverError(
|
||||
f"could not start the proxmark3 client ({self._client!r}): {exc}\n"
|
||||
" pass --client /path/to/proxmark3, or add it to your PATH"
|
||||
) from exc
|
||||
self._sync_prompt()
|
||||
|
||||
def execute(self, line: str) -> str:
|
||||
import pexpect
|
||||
|
||||
if self._child is None:
|
||||
raise DriverError("client not started")
|
||||
try:
|
||||
self._child.sendline(line)
|
||||
self._child.expect(_EXPECT_RE)
|
||||
except pexpect.EOF as exc:
|
||||
raise DriverError("client exited") from exc
|
||||
except pexpect.TIMEOUT as exc:
|
||||
raise DriverError(f"client did not respond within {self._timeout}s") from exc
|
||||
out = _extract_output(self._child.before, line)
|
||||
self._sync_prompt()
|
||||
return out
|
||||
|
||||
def _sync_prompt(self) -> None:
|
||||
# the "[label]" sits at the end of the text just before the matched "pm3 -->" tail
|
||||
m = _LABEL_RE.search(strip_ansi(self._child.before or ""))
|
||||
if m:
|
||||
self.prompt = m.group(1).strip() or "offline"
|
||||
|
||||
def close(self) -> None:
|
||||
if self._child is None:
|
||||
return
|
||||
try:
|
||||
self._child.sendline("quit")
|
||||
self._child.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._child = None
|
||||
|
||||
|
||||
#: extra checkout roots probed for the SWIG build (the discovered repo is tried first).
|
||||
_SWIG_ROOTS = ("~/proxmark3",)
|
||||
|
||||
|
||||
def _load_pm3_module():
|
||||
"""Import and return the SWIG ``pm3`` module. If it isn't already importable, locate the built
|
||||
library (``libpm3rrg_rdv4.so``, which exports ``PyInit__pm3``) and the ``pm3.py`` stub from the
|
||||
discovered checkout and load the extension under the name ``_pm3`` — no symlink or filesystem
|
||||
write. Raises :class:`ImportError` if the build can't be found."""
|
||||
try:
|
||||
import pm3
|
||||
return pm3
|
||||
except ImportError:
|
||||
pass
|
||||
roots = []
|
||||
if os.environ.get("PM3_SWIG_PATH"):
|
||||
roots.append(os.environ["PM3_SWIG_PATH"])
|
||||
repo = find_repo_root()
|
||||
if repo is not None:
|
||||
roots.append(str(repo))
|
||||
roots += [os.path.expanduser(r) for r in _SWIG_ROOTS]
|
||||
for root in roots:
|
||||
build = os.path.join(root, "client", "experimental_lib", "build")
|
||||
pyscripts = os.path.join(root, "client", "pyscripts")
|
||||
lib = os.path.join(build, "libpm3rrg_rdv4.so")
|
||||
stub = os.path.join(pyscripts, "pm3.py")
|
||||
if not (os.path.isfile(lib) and os.path.isfile(stub)):
|
||||
# PM3_SWIG_PATH may itself be the dir holding both files
|
||||
lib, stub, pyscripts = (os.path.join(root, "libpm3rrg_rdv4.so"),
|
||||
os.path.join(root, "pm3.py"), root)
|
||||
if not (os.path.isfile(lib) and os.path.isfile(stub)):
|
||||
continue
|
||||
if "_pm3" not in sys.modules:
|
||||
loader = importlib.machinery.ExtensionFileLoader("_pm3", lib)
|
||||
spec = importlib.util.spec_from_loader("_pm3", loader)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(mod)
|
||||
sys.modules["_pm3"] = mod
|
||||
if pyscripts not in sys.path:
|
||||
sys.path.insert(0, pyscripts)
|
||||
import pm3
|
||||
return pm3
|
||||
raise ImportError(
|
||||
"the pm3 SWIG extension (_pm3) isn't importable. Build it (in client/experimental_lib: "
|
||||
"cmake .. && make -j), set $PM3_SWIG_PATH, or use --driver pexpect"
|
||||
)
|
||||
|
||||
|
||||
def _autodetect_port():
|
||||
ports = sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*"))
|
||||
return ports[0] if ports else None
|
||||
|
||||
|
||||
class SwigDriver(ClientDriver):
|
||||
"""In-process driver over the ``_pm3`` SWIG bindings. Needs a connected device: the C
|
||||
``pm3_open`` calls ``exit()`` on a real port with no responding PM3, so we can't recover from a
|
||||
bad connection — we only open an auto-detected or explicitly given port."""
|
||||
|
||||
backend = "swig"
|
||||
|
||||
def __init__(self, port=None, pm3_module=None):
|
||||
self._port = port
|
||||
self._mod = pm3_module
|
||||
self._p = None
|
||||
self.prompt = "offline"
|
||||
|
||||
def start(self) -> None:
|
||||
mod = self._mod or _load_pm3_module()
|
||||
port = self._port or _autodetect_port()
|
||||
if port is None:
|
||||
raise DriverError(
|
||||
"no serial device found for the SWIG driver (no /dev/ttyACM*). plug in a "
|
||||
"Proxmark, pass --port, or use --driver pexpect"
|
||||
)
|
||||
self._p = mod.pm3(port) # exits the process if this port has no PM3
|
||||
self.prompt = "usb"
|
||||
|
||||
def execute(self, line: str) -> str:
|
||||
if self._p is None:
|
||||
raise DriverError("client not started")
|
||||
# capture=False, quiet=False: let the client print its OWN coloured output straight to
|
||||
# stdout. The grab buffer always strips ANSI (ui.c force-filters it), so capturing would
|
||||
# lose the Proxmark's colours — the whole point of the PRINT path. Returns "" (already out).
|
||||
self._p.console(line, False, False)
|
||||
return ""
|
||||
|
||||
def close(self) -> None:
|
||||
self._p = None
|
||||
|
||||
|
||||
def make_driver(port=None, client="proxmark3", driver="auto"):
|
||||
"""The driver for ``pm3py client``. ``driver`` is ``auto`` (SWIG if ``_pm3`` importable, else
|
||||
pexpect), ``swig`` (error if unavailable), or ``pexpect``."""
|
||||
if driver in ("auto", "swig"):
|
||||
try:
|
||||
mod = _load_pm3_module()
|
||||
except ImportError as exc:
|
||||
if driver == "swig":
|
||||
raise DriverError(str(exc)) from exc
|
||||
mod = None # auto → fall back to pexpect
|
||||
if mod is not None:
|
||||
return SwigDriver(port=port, pm3_module=mod)
|
||||
return PexpectDriver(port=port, client=client)
|
||||
81
pm3py/cli/clientcli/hints.py
Normal file
81
pm3py/cli/clientcli/hints.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Hints for clientcli, all driven by the command tree.
|
||||
|
||||
- :func:`one_line_hint` — the one-line relevance hint on the bottom bar for the current input.
|
||||
- :func:`help_rows` — the full help of a command as ``(display, meta)`` rows for the *completion
|
||||
dropdown* (never the bottom bar — the dropdown is where all hints live, per rawcli 65be53d);
|
||||
:func:`resolve_target` maps the *highlighted* completion back to the node whose help to show.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .tree import parse_option
|
||||
|
||||
|
||||
def _skip(flags) -> bool:
|
||||
return "--help" in flags or "-h" in flags
|
||||
|
||||
|
||||
def one_line_hint(tree, text):
|
||||
"""A one-line hint for ``text``: a leaf's description + usage, or an interior node's subcommand
|
||||
count. Resolves on the completed prefix when the buffer ends mid-word. ``None`` if unknown."""
|
||||
tokens = (text or "").split()
|
||||
if not tokens:
|
||||
return None
|
||||
node, used = tree.node_at(tokens), tokens
|
||||
if node is None:
|
||||
node, used = tree.node_at(tokens[:-1]), tokens[:-1]
|
||||
if node is None or node is tree.root:
|
||||
return None
|
||||
if node.is_leaf:
|
||||
e = node.entry
|
||||
return f"{e['command']} — {e['description']} · {e['usage']}"
|
||||
return f"{' '.join(used)} — {len(node.children)} subcommands"
|
||||
|
||||
|
||||
def help_rows(node):
|
||||
"""The full help of a highlighted ``node`` as ``(display, meta)`` rows for the completion
|
||||
dropdown — a leaf's name/usage/options/notes, or an interior node's subcommands. Presented in
|
||||
the same two-column form the normal completions use. ``[]`` when there is nothing to show."""
|
||||
if node is None:
|
||||
return []
|
||||
if node.is_leaf:
|
||||
e = node.entry
|
||||
rows = [(e["command"], e.get("description", ""))]
|
||||
if e.get("usage"):
|
||||
rows.append((e["usage"], "usage"))
|
||||
for opt in e.get("options", []):
|
||||
flags, meta, desc = parse_option(opt)
|
||||
if not flags or _skip(flags):
|
||||
continue
|
||||
rows.append(((", ".join(flags) + (f" {meta}" if meta else "")).strip(), desc))
|
||||
rows += [(n, "example") for n in e.get("notes", [])]
|
||||
return rows
|
||||
kids = sorted(node.children)
|
||||
rows = [(node.token or "(commands)", f"{len(kids)} subcommands")]
|
||||
for tok in kids:
|
||||
child = node.children[tok]
|
||||
meta = child.entry.get("description", "") if child.is_leaf else f"{len(child.children)} subcommands"
|
||||
rows.append((tok, meta))
|
||||
return rows
|
||||
|
||||
|
||||
def resolve_target(tree, buffer_text, current_completion):
|
||||
"""The tree node whose help to show for the highlighted completion. A subcommand completion
|
||||
extends the current path; a flag completion targets the leaf under the cursor. ``None`` if
|
||||
nothing resolves."""
|
||||
tokens = (buffer_text or "").split()
|
||||
at_word = bool(tokens) and not (buffer_text or "").endswith(" ")
|
||||
path = tokens[:-1] if at_word else tokens
|
||||
# subcommand completions carry a trailing space (the Tab-descend mechanism) — strip it or
|
||||
# node_at's exact-token lookup misses and the help panel silently never shows
|
||||
ctext = (getattr(current_completion, "text", "") if current_completion else "").strip()
|
||||
if ctext.startswith("-"):
|
||||
for i in range(len(path), -1, -1):
|
||||
node = tree.node_at(path[:i])
|
||||
if node is not None and node.is_leaf:
|
||||
return node
|
||||
return None
|
||||
# menu navigation has already inserted the highlighted completion into the buffer
|
||||
# (Buffer.go_to_completion), so the path may already end with ctext — don't append it twice
|
||||
if ctext and (not path or path[-1] != ctext):
|
||||
path = path + [ctext]
|
||||
return tree.node_at(path)
|
||||
130
pm3py/cli/clientcli/keys.py
Normal file
130
pm3py/cli/clientcli/keys.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""The help-toggle key bindings for clientcli — the centrepiece interaction.
|
||||
|
||||
When a completion is highlighted in the menu, RIGHT swaps the *dropdown* to the full help of that
|
||||
command (name, usage, options, notes as rows); LEFT (primary) or ESCAPE / Ctrl-g (secondary)
|
||||
restores normal completion. The help never touches the bottom bar — the dropdown is where all hints
|
||||
live. The highlighted node is captured into ``session.help_target`` at the moment RIGHT is pressed,
|
||||
then the completer renders its help while ``help_open`` is set.
|
||||
|
||||
The pure ``open_help`` / ``close_help`` helpers carry the state transitions for unit tests; the key
|
||||
wiring is exercised in the live REPL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def open_help(session, node) -> bool:
|
||||
"""Open help iff ``node`` resolved to something. Captures it as ``help_target`` so the completer
|
||||
can render its help. Returns the resulting ``help_open`` state."""
|
||||
if node is not None:
|
||||
session.help_target = node
|
||||
session.help_open = True
|
||||
return session.help_open
|
||||
|
||||
|
||||
def close_help(session) -> bool:
|
||||
session.help_open = False
|
||||
session.help_target = None
|
||||
return session.help_open
|
||||
|
||||
|
||||
def apply_tab(buff) -> None:
|
||||
"""Commit the highlighted completion (or the first, if none is highlighted) into ``buff``.
|
||||
|
||||
The default Tab only *previews* under ``complete_while_typing`` — completions regenerate on
|
||||
every buffer change, so the preview never sticks. ``apply_completion`` commits it; our
|
||||
trailing-space completions then re-trigger completion at the next level. When no menu is open
|
||||
yet, start one (selecting the first)."""
|
||||
state = buff.complete_state
|
||||
if state is None:
|
||||
buff.start_completion(select_first=True)
|
||||
return
|
||||
completion = state.current_completion or (state.completions[0] if state.completions else None)
|
||||
if completion is not None:
|
||||
buff.apply_completion(completion)
|
||||
|
||||
|
||||
def _bind(kb, key, handler, **kw) -> None:
|
||||
"""Add a binding, ignoring key names a given prompt_toolkit build doesn't accept."""
|
||||
try:
|
||||
kb.add(key, **kw)(handler)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def install_key_bindings(kb, session) -> None:
|
||||
"""Wire TAB completion and RIGHT/LEFT/ESCAPE/Ctrl-g help toggling onto ``kb``. Never raises on
|
||||
an unknown key."""
|
||||
from prompt_toolkit.filters import Condition, completion_is_selected
|
||||
from prompt_toolkit.key_binding.bindings.named_commands import get_by_name
|
||||
|
||||
from .hints import resolve_target
|
||||
|
||||
help_is_open = Condition(lambda: session.help_open)
|
||||
|
||||
# The exact buffer document + completion menu captured when RIGHT was pressed, so LEFT can put
|
||||
# the user back on the completion they had highlighted (not a freshly regenerated menu).
|
||||
saved = {}
|
||||
|
||||
# Tab commits the highlighted (or first) completion so it sticks and descends a level;
|
||||
# Shift-Tab keeps prompt_toolkit's default (previous completion).
|
||||
_bind(kb, "tab", lambda event: apply_tab(event.current_buffer))
|
||||
|
||||
# complete_while_typing only re-runs completion on INSERT, so deleting characters leaves the
|
||||
# dropdown stale (or blank). Re-run the completer after each deletion so the menu tracks the
|
||||
# now-shorter input. Wrap the default readline command, then refresh — but not while the help
|
||||
# panel is up (editing exits help first, handled by the LEFT/close path).
|
||||
def _delete_then_complete(command_name):
|
||||
binding = get_by_name(command_name)
|
||||
|
||||
def _handler(event):
|
||||
binding.handler(event) # the default deletion
|
||||
if not session.help_open:
|
||||
event.current_buffer.start_completion(select_first=False) # refresh the dropdown
|
||||
return _handler
|
||||
|
||||
for _key, _cmd in (("backspace", "backward-delete-char"),
|
||||
("delete", "delete-char"),
|
||||
("c-w", "unix-word-rubout"),
|
||||
("c-u", "unix-line-discard")):
|
||||
_bind(kb, _key, _delete_then_complete(_cmd))
|
||||
|
||||
def _open(event):
|
||||
buff = event.current_buffer
|
||||
state = buff.complete_state
|
||||
cur = state.current_completion if state else None
|
||||
node = resolve_target(session.tree, buff.text, cur)
|
||||
if not open_help(session, node): # only if it resolved to a real command
|
||||
return
|
||||
# Remember exactly where we are. DETACH the menu (complete_state = None) rather than
|
||||
# cancel_completion() — the latter calls go_to_completion(None), which would mutate this
|
||||
# very state object and wipe the highlighted index we need to restore.
|
||||
saved["document"] = buff.document
|
||||
saved["state"] = state
|
||||
buff.complete_state = None
|
||||
buff.start_completion(select_first=False) # regenerate → help rows (completer sees help_open)
|
||||
event.app.invalidate()
|
||||
|
||||
def _close(event):
|
||||
buff = event.current_buffer
|
||||
close_help(session)
|
||||
if saved.get("state") is not None:
|
||||
# Restore the pre-RIGHT selection exactly. Set the document first (this detaches
|
||||
# complete_state without firing completion), then re-attach the saved menu — the same
|
||||
# order Buffer.go_to_completion uses.
|
||||
buff.document = saved["document"]
|
||||
buff.complete_state = saved["state"]
|
||||
saved.clear()
|
||||
else:
|
||||
buff.cancel_completion()
|
||||
event.app.invalidate()
|
||||
|
||||
# RIGHT opens help for the highlighted completion. With nothing selected (or help already open)
|
||||
# the filter is false, so the binding is inert and the default cursor-right still works.
|
||||
_bind(kb, "right", _open, filter=completion_is_selected & ~help_is_open)
|
||||
# LEFT is the instant back-out; active only while help is open, else default cursor-left.
|
||||
_bind(kb, "left", _close, filter=help_is_open)
|
||||
# ESCAPE also backs out, but it is prompt_toolkit's meta prefix, so a bare escape fires only
|
||||
# after ~0.5s (timeoutlen); Ctrl-g is the instant alternative. Do NOT use eager=True — it would
|
||||
# fix the delay at the cost of breaking Alt-key sequences.
|
||||
_bind(kb, "escape", _close, filter=help_is_open)
|
||||
_bind(kb, "c-g", _close, filter=help_is_open)
|
||||
142
pm3py/cli/clientcli/pack.py
Normal file
142
pm3py/cli/clientcli/pack.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Generate a standalone, dependency-light single-file ``pm3cli`` from this package.
|
||||
|
||||
The runtime modules (:mod:`tree`, :mod:`driver`, :mod:`session`, :mod:`hints`, :mod:`completer`,
|
||||
:mod:`keys`, :mod:`app`) are concatenated in dependency order with their intra-package imports
|
||||
stripped, then a self-contained ``main()`` is appended. The result is one file that needs no pm3py
|
||||
install — drop it inside a stock Iceman/RRG Proxmark3 checkout and run it. Two outputs:
|
||||
|
||||
- ``pm3cli.py`` — a readable single file; the target runs ``pip install prompt_toolkit`` (and
|
||||
``pexpect`` for the non-SWIG backend), then ``python3 pm3cli.py``.
|
||||
- ``pm3cli.pyz`` — a zipapp with prompt_toolkit/pexpect bundled inside; ``python3 pm3cli.pyz`` with
|
||||
nothing to install.
|
||||
|
||||
The package (with its tests) stays the source of truth — this only re-emits it, so the standalone
|
||||
can't drift.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipapp
|
||||
|
||||
#: runtime modules, in definition-before-use order (tree first, app last).
|
||||
_MODULES = ("tree", "driver", "session", "hints", "completer", "keys", "app")
|
||||
|
||||
#: bundled into the .pyz so it runs with zero pip installs (all pure-Python).
|
||||
_PYZ_DEPS = ("prompt_toolkit", "pexpect", "ptyprocess", "wcwidth")
|
||||
|
||||
_HEADER = '''\
|
||||
#!/usr/bin/env python3
|
||||
"""pm3cli — an autocompleting REPL for the stock Proxmark3 client.
|
||||
|
||||
GENERATED from pm3py.cli.clientcli — do not edit by hand; regenerate with `pm3py client --pack`.
|
||||
|
||||
Drop this beside (or inside) a Proxmark3 checkout and run it: python3 pm3cli.py
|
||||
It finds the checkout's doc/commands.json + client automatically, auto-detects /dev/ttyACM*,
|
||||
and drives the stock client (SWIG _pm3 if built, else the proxmark3 binary via pexpect).
|
||||
|
||||
Needs prompt_toolkit (pip install prompt_toolkit); the pexpect backend also needs pexpect.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
'''
|
||||
|
||||
_MAIN = '''
|
||||
|
||||
# --------------------------------------------------------------------------- standalone entrypoint
|
||||
def main(argv=None) -> int:
|
||||
import argparse
|
||||
import importlib.util
|
||||
import sys
|
||||
|
||||
p = argparse.ArgumentParser(
|
||||
prog="pm3cli", description="autocompleting REPL for the stock Proxmark3 client")
|
||||
p.add_argument("--port", default=None, help="serial port (default: auto-detect /dev/ttyACM*)")
|
||||
p.add_argument("--commands", default=None,
|
||||
help="path to commands.json (default: auto-locate in the checkout)")
|
||||
p.add_argument("--client", default="proxmark3",
|
||||
help="proxmark3 binary to wrap (default: PATH, then the checkout)")
|
||||
p.add_argument("--driver", choices=["auto", "swig", "pexpect"], default="auto",
|
||||
help="backend: auto (SWIG if built, else pexpect), swig, or pexpect")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if importlib.util.find_spec("prompt_toolkit") is None:
|
||||
print("pm3cli needs prompt_toolkit: pip install prompt_toolkit", file=sys.stderr)
|
||||
return 2
|
||||
if args.driver != "swig" and importlib.util.find_spec("pexpect") is None:
|
||||
print("pm3cli's pexpect backend needs pexpect: pip install pexpect\\n"
|
||||
" (or build the SWIG _pm3 lib and pass --driver swig)", file=sys.stderr)
|
||||
return 2
|
||||
return run(port=args.port, commands=args.commands, client=args.client, driver=args.driver)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
'''
|
||||
|
||||
|
||||
def _drop(line: str) -> bool:
|
||||
"""True for intra-package / future imports that must not survive amalgamation."""
|
||||
s = line.lstrip()
|
||||
return (s.startswith("from __future__")
|
||||
or s.startswith("from .")
|
||||
or s.startswith("from pm3py")
|
||||
or s.startswith("import pm3py"))
|
||||
|
||||
|
||||
def amalgamate() -> str:
|
||||
"""Return the full standalone source as a string."""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
parts = [_HEADER]
|
||||
for mod in _MODULES:
|
||||
src = open(os.path.join(here, mod + ".py"), encoding="utf-8").read()
|
||||
body = "\n".join(ln for ln in src.splitlines() if not _drop(ln))
|
||||
parts.append(f"\n\n# ===================================================== clientcli.{mod}\n"
|
||||
+ body.strip() + "\n")
|
||||
parts.append(_MAIN)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _build_pyz(code: str, out_path: str) -> None:
|
||||
import importlib.metadata as _im
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with open(os.path.join(d, "__main__.py"), "w", encoding="utf-8") as f:
|
||||
f.write(code)
|
||||
for pkg in _PYZ_DEPS:
|
||||
try:
|
||||
mod = importlib.import_module(pkg)
|
||||
except Exception:
|
||||
continue
|
||||
src = mod.__file__
|
||||
if src.endswith("__init__.py"): # a package → copy the whole dir
|
||||
shutil.copytree(os.path.dirname(src), os.path.join(d, pkg),
|
||||
ignore=shutil.ignore_patterns("__pycache__", "*.pyc",
|
||||
"tests", "test"))
|
||||
else: # a single-module dep
|
||||
shutil.copy2(src, os.path.join(d, os.path.basename(src)))
|
||||
# bundle the .dist-info too — some packages read their own version via
|
||||
# importlib.metadata at import time (e.g. prompt_toolkit), which reads it from the zip.
|
||||
try:
|
||||
info = getattr(_im.distribution(pkg), "_path", None)
|
||||
if info is not None and os.path.isdir(info):
|
||||
shutil.copytree(info, os.path.join(d, os.path.basename(str(info))))
|
||||
except Exception:
|
||||
pass
|
||||
zipapp.create_archive(d, out_path, interpreter="/usr/bin/env python3")
|
||||
os.chmod(out_path, 0o755)
|
||||
|
||||
|
||||
def pack(out_path: str) -> str:
|
||||
"""Write the standalone to ``out_path``. A ``.pyz`` extension builds a dependency-bundled
|
||||
zipapp; anything else writes the readable single ``.py``. Returns ``out_path``."""
|
||||
code = amalgamate()
|
||||
compile(code, out_path, "exec") # fail loudly if the amalgamation is invalid
|
||||
if out_path.endswith(".pyz"):
|
||||
_build_pyz(code, out_path)
|
||||
else:
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write(code)
|
||||
os.chmod(out_path, 0o755)
|
||||
return out_path
|
||||
42
pm3py/cli/clientcli/session.py
Normal file
42
pm3py/cli/clientcli/session.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""clientcli session state: the driver, the loaded command tree, and the help-toggle flag. Kept
|
||||
free of prompt_toolkit so it stays unit-testable (mirrors :mod:`pm3py.cli.rawcli.session`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# ANSI palette aligned with pm3py/trace/format.py (same as rawcli's breadcrumb)
|
||||
_DIM = "\033[2m"
|
||||
_BGREEN = "\033[1;32m"
|
||||
_BRED = "\033[1;31m"
|
||||
_BOLD = "\033[1m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
|
||||
class ClientSession:
|
||||
"""Mutable clientcli state. ``driver`` runs the stock client, ``tree`` is the loaded command
|
||||
trie. ``help_open`` swaps the completion dropdown to the help of ``help_target`` (the node
|
||||
captured when RIGHT was pressed); LEFT clears both back to normal completion."""
|
||||
|
||||
def __init__(self, driver: Any, tree: Any):
|
||||
self.driver = driver
|
||||
self.tree = tree
|
||||
self.help_open: bool = False
|
||||
self.help_target: Any = None
|
||||
|
||||
@property
|
||||
def prompt_label(self) -> str:
|
||||
"""The client's current context label (``usb`` / ``fpc`` / ``offline`` / ``usb|tcp`` …)."""
|
||||
return getattr(self.driver, "prompt", "offline") or "offline"
|
||||
|
||||
def breadcrumb(self) -> str:
|
||||
return f"[client / {self.prompt_label}] pm3py -->"
|
||||
|
||||
def colored_breadcrumb(self) -> str:
|
||||
"""The breadcrumb with ANSI colour — bold ``client``, green when a device is attached, red
|
||||
when offline. Stripping the ANSI yields :meth:`breadcrumb`."""
|
||||
label = self.prompt_label
|
||||
color = _BRED if "offline" in label else _BGREEN
|
||||
sep = f"{_DIM} / {_RESET}"
|
||||
segs = [f"{_BOLD}client{_RESET}", f"{color}{label}{_RESET}"]
|
||||
return (f"{_DIM}[{_RESET}" + sep.join(segs) + f"{_DIM}]{_RESET} "
|
||||
f"{_BOLD}pm3py -->{_RESET}")
|
||||
158
pm3py/cli/clientcli/tree.py
Normal file
158
pm3py/cli/clientcli/tree.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Command-tree model for ``pm3py client``.
|
||||
|
||||
Loads the stock Proxmark3 client's machine-readable help dump (``doc/commands.json``) and builds a
|
||||
prefix trie over the full command paths (``"hf mf rdbl"``). The JSON keys are **leaf** paths only —
|
||||
interior nodes (``"hf"``, ``"hf mf"``) are synthesised from the path segments (no leaf path is a
|
||||
prefix of another, so the trie is unambiguous). Option strings are pre-rendered by the client
|
||||
(``"-k, --key <hex> key, 6 hex bytes"``); :func:`parse_option` splits them into
|
||||
``(flags, metavar, description)`` for option-flag completion.
|
||||
|
||||
Regenerate the JSON for another client build with::
|
||||
|
||||
proxmark3 --fulltext | client/pyscripts/pm3_help2json.py - -
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
#: env override for the commands.json path (highest priority in :func:`load_command_tree`).
|
||||
_ENV_VAR = "PM3_COMMANDS_JSON"
|
||||
|
||||
#: a single option flag, e.g. ``-a``, ``-1``, ``--key`` (numeric short flags like ``-1`` exist).
|
||||
_FLAG = re.compile(r"^--?[A-Za-z0-9][\w-]*$")
|
||||
|
||||
|
||||
def find_repo_root(start=None):
|
||||
"""Locate a Proxmark3 client checkout (stock Iceman/RRG or a fork) by its ``doc/commands.json``
|
||||
marker, walking up from ``$PM3_REPO``/``$PM3_ROOT``, ``start``, the current directory, and this
|
||||
file's location. Returns a :class:`~pathlib.Path` to the repo root, or ``None``. This is what
|
||||
lets a dropped-in single file find whatever checkout it lives beside."""
|
||||
seeds = []
|
||||
for env in ("PM3_REPO", "PM3_ROOT"):
|
||||
if os.environ.get(env):
|
||||
seeds.append(Path(os.environ[env]))
|
||||
if start:
|
||||
seeds.append(Path(start))
|
||||
seeds.append(Path.cwd())
|
||||
try:
|
||||
seeds.append(Path(__file__).resolve().parent)
|
||||
except NameError: # __file__ absent (e.g. frozen/REPL)
|
||||
pass
|
||||
seen = set()
|
||||
for seed in seeds:
|
||||
base = seed.resolve() if seed.exists() else seed
|
||||
for cand in (base, *base.parents):
|
||||
if cand in seen:
|
||||
continue
|
||||
seen.add(cand)
|
||||
if (cand / "doc" / "commands.json").is_file():
|
||||
return cand # dropped inside the checkout
|
||||
sub = cand / "proxmark3" # a side-by-side checkout (dev layout)
|
||||
if (sub / "doc" / "commands.json").is_file():
|
||||
return sub
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandNode:
|
||||
"""A node in the command trie. ``entry`` is the ``commands.json`` leaf dict for a command, or
|
||||
``None`` for a synthesised interior (category) node."""
|
||||
|
||||
token: str # last path segment; "" for the root
|
||||
children: dict[str, "CommandNode"] = field(default_factory=dict)
|
||||
entry: dict | None = None
|
||||
|
||||
@property
|
||||
def is_leaf(self) -> bool:
|
||||
return self.entry is not None
|
||||
|
||||
|
||||
class CommandTree:
|
||||
def __init__(self, commands: dict):
|
||||
self.root = CommandNode("")
|
||||
for path, entry in commands.items():
|
||||
node = self.root
|
||||
for seg in path.split():
|
||||
node = node.children.setdefault(seg, CommandNode(seg))
|
||||
node.entry = entry
|
||||
|
||||
def node_at(self, tokens) -> CommandNode | None:
|
||||
"""The node reached by walking ``tokens`` from the root, or ``None`` if any is unknown."""
|
||||
node = self.root
|
||||
for tok in tokens:
|
||||
node = node.children.get(tok)
|
||||
if node is None:
|
||||
return None
|
||||
return node
|
||||
|
||||
def children_of(self, tokens) -> list[CommandNode]:
|
||||
"""Completion candidates below ``tokens`` (alphabetical), or ``[]`` for an unknown path."""
|
||||
node = self.node_at(tokens)
|
||||
if node is None:
|
||||
return []
|
||||
return [node.children[k] for k in sorted(node.children)]
|
||||
|
||||
def entry_of(self, tokens) -> dict | None:
|
||||
"""The leaf metadata at ``tokens`` (``None`` for an interior or unknown path)."""
|
||||
node = self.node_at(tokens)
|
||||
return node.entry if node else None
|
||||
|
||||
|
||||
def parse_option(line: str):
|
||||
"""Split a rendered option string into ``(flags, metavar, description)``.
|
||||
|
||||
``"-k, --key <hex> key, 6 hex bytes"`` -> ``(["-k", "--key"], "<hex>", "key, 6 hex bytes")``
|
||||
``"--blk <dec> block number"`` -> ``(["--blk"], "<dec>", "block number")``
|
||||
``"-a input key type is key A (def)"`` -> ``(["-a"], "", "input key type is key A (def)")``
|
||||
|
||||
The leading run of flag tokens is delimited by trailing commas (``-k,`` promises another flag);
|
||||
a following ``<...>`` token is the metavar; the rest is the description.
|
||||
"""
|
||||
toks = line.split()
|
||||
flags: list[str] = []
|
||||
i, more = 0, True
|
||||
while i < len(toks) and more:
|
||||
raw = toks[i]
|
||||
core = raw[:-1] if raw.endswith(",") else raw
|
||||
if not _FLAG.match(core):
|
||||
break
|
||||
flags.append(core)
|
||||
more = raw.endswith(",") # a trailing comma promises another flag
|
||||
i += 1
|
||||
meta = ""
|
||||
if i < len(toks) and toks[i].startswith("<"):
|
||||
meta = toks[i]
|
||||
i += 1
|
||||
return flags, meta, " ".join(toks[i:])
|
||||
|
||||
|
||||
def load_command_tree(path=None):
|
||||
"""Return ``(CommandTree, metadata)``. Resolution order: an explicit ``path`` (``--commands``),
|
||||
then ``$PM3_COMMANDS_JSON``, then the discovered repo's ``doc/commands.json`` (see
|
||||
:func:`find_repo_root`), then ``./doc/commands.json``. Raises :class:`FileNotFoundError` (with
|
||||
the regen hint) when nothing is found."""
|
||||
candidates = []
|
||||
if path:
|
||||
candidates.append(Path(path))
|
||||
elif os.environ.get(_ENV_VAR):
|
||||
candidates.append(Path(os.environ[_ENV_VAR]))
|
||||
else:
|
||||
root = find_repo_root()
|
||||
if root is not None:
|
||||
candidates.append(root / "doc" / "commands.json")
|
||||
candidates.append(Path("doc/commands.json"))
|
||||
for cand in candidates:
|
||||
p = cand.expanduser()
|
||||
if p.is_file():
|
||||
data = json.loads(p.read_text())
|
||||
return CommandTree(data["commands"]), data.get("metadata", {})
|
||||
raise FileNotFoundError(
|
||||
"no Proxmark3 commands.json found (looked in: "
|
||||
+ ", ".join(str(c) for c in candidates) + ").\n"
|
||||
" run this from inside a Proxmark3 checkout, pass --commands PATH, set $PM3_REPO, or\n"
|
||||
" regenerate it: proxmark3 --fulltext | client/pyscripts/pm3_help2json.py - -"
|
||||
)
|
||||
72
pm3py/cli/main.py
Normal file
72
pm3py/cli/main.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""`pm3py` top-level CLI dispatcher.
|
||||
|
||||
Subcommands live in submodules: ``rawcli`` (the interactive raw-command TUI over pm3py's own
|
||||
protocol stack) and ``client`` (an autocompleting REPL wrapping the stock ``proxmark3`` binary).
|
||||
Room is left for ``flash`` (wrapping pm3flash).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="pm3py", description="pm3py command-line tools")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
p = sub.add_parser("rawcli", help="interactive raw-command TUI for a connected Proxmark3")
|
||||
p.add_argument("--port", default=None, help="serial port (default: auto-detect)")
|
||||
|
||||
c = sub.add_parser("client", help="autocompleting REPL wrapping the stock proxmark3 client")
|
||||
c.add_argument("--port", default=None, help="serial port passed to proxmark3 (default: auto)")
|
||||
c.add_argument("--commands", default=None, help="path to commands.json (default: auto-locate)")
|
||||
c.add_argument("--client", default="proxmark3", help="proxmark3 binary to wrap (default: PATH)")
|
||||
c.add_argument("--driver", choices=["auto", "swig", "pexpect"], default="auto",
|
||||
help="backend: auto (SWIG if built, else pexpect), swig, or pexpect")
|
||||
c.add_argument("--pack", metavar="OUT", default=None,
|
||||
help="spin off a standalone drop-in instead of launching: OUT.py (single file) "
|
||||
"or OUT.pyz (deps bundled). Distributable to stock Proxmark3 users.")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "rawcli":
|
||||
try:
|
||||
import prompt_toolkit # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
print(
|
||||
"pm3py rawcli needs prompt_toolkit (a core dependency) but it isn't importable.\n"
|
||||
" reinstall pm3py, or: pip install prompt_toolkit",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
from pm3py.cli.rawcli.app import run
|
||||
return run(port=args.port)
|
||||
if args.command == "client":
|
||||
if args.pack:
|
||||
from pm3py.cli.clientcli.pack import pack
|
||||
out = pack(args.pack)
|
||||
print(f"wrote standalone drop-in: {out}")
|
||||
return 0
|
||||
# prompt_toolkit is always needed; pexpect only for the pexpect backend (SWIG doesn't use it)
|
||||
needs = ["prompt_toolkit"] + (["pexpect"] if args.driver != "swig" else [])
|
||||
missing = [m for m in needs if not _importable(m)]
|
||||
if missing:
|
||||
print(
|
||||
f"pm3py client needs {' and '.join(missing)} but "
|
||||
f"{'they are' if len(missing) > 1 else 'it is'} not importable.\n"
|
||||
f" reinstall pm3py, or: pip install {' '.join(missing)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
from pm3py.cli.clientcli.app import run
|
||||
return run(port=args.port, commands=args.commands, client=args.client, driver=args.driver)
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
|
||||
def _importable(name: str) -> bool:
|
||||
import importlib.util
|
||||
|
||||
return importlib.util.find_spec(name) is not None
|
||||
6
pm3py/cli/rawcli/__init__.py
Normal file
6
pm3py/cli/rawcli/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""rawcli — an interactive, annotated raw-command TUI for a connected Proxmark3.
|
||||
|
||||
Launched via ``pm3py rawcli``. Distinct from the pyws Python REPL and the future ``pm3py client``
|
||||
command CLI: it has its own flexible grammar (loose hex/binary entry with 0x/0b toggle,
|
||||
function-style transponder commands) and a live Proxmark-style annotated trace pane.
|
||||
"""
|
||||
261
pm3py/cli/rawcli/app.py
Normal file
261
pm3py/cli/rawcli/app.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""rawcli interactive loop.
|
||||
|
||||
Phase 1 skeleton: connect (best-effort), a live breadcrumb prompt, ``help``/``quit``, and a raw
|
||||
hex payload → annotated exchange. Entry-mode toggling, identify/connection, the command catalog,
|
||||
and TLV editing arrive in later phases (see the plan). The interactive loop is intentionally thin;
|
||||
the testable logic lives in :mod:`session` / :mod:`trace_view` / (later) ``parser``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from .session import RawSession
|
||||
from .parser import parse_line
|
||||
from .entry import install_key_bindings
|
||||
from .identify import identify, clear, format_summary
|
||||
from .catalog import catalog_for
|
||||
from .trace_view import render_exchange
|
||||
|
||||
_HELP = """\
|
||||
rawcli — raw command interface
|
||||
|
||||
help [cmd] show this help (or one command's usage)
|
||||
identify probe the field; sets HF/LF + transponder, keeps the connection
|
||||
transponder show the identified transponder
|
||||
tlv [bytes] decode / edit a TLV (NDEF) blob
|
||||
close drop the tag connection
|
||||
quit | exit | q leave rawcli
|
||||
<hex/bin ...> send raw bytes to the tag (CRC auto-appended on 14a); trace prints above
|
||||
NAME(args) run a command from the identified tag (READ(4), GET_VERSION()) — listed below
|
||||
|
||||
Entry:
|
||||
* Raw bytes are hex by default ("30 04" or "3004" auto-spaces into bytes).
|
||||
* Ctrl-/ (or Ctrl-t) toggles hex (0x) / binary (0b) entry — shown in the breadcrumb.
|
||||
* Base is tracked PER BYTE: the toggle only changes the base of the NEXT byte, it never
|
||||
rewrites what you've typed. So `30` then toggle then `00000100` gives 30 00000100 and
|
||||
sends 0x30 0x04. A byte auto-seals at its width (2 hex / 8 binary) and the next digit
|
||||
starts a fresh byte. A 0x/0b prefix on one token overrides the mode for that token.
|
||||
* Function-call arguments are base-10 unless prefixed: READ(4) = page 4, READ(0x2B) = hex.
|
||||
* Suggestions (commands, opcodes by value, and the tag's memory map) appear in the
|
||||
completion menu as you type — in whichever base you're entering.
|
||||
* You can't mix a command and raw bytes on one line — that's an error, not a transmit."""
|
||||
|
||||
|
||||
def _connect(port):
|
||||
"""Best-effort device connection; returns the client or None (offline)."""
|
||||
try:
|
||||
from pm3py import Proxmark3
|
||||
return Proxmark3.sync(port) # sync proxy: scan()/raw() are plain calls
|
||||
except Exception as exc: # no device / connect failure — run offline
|
||||
print(f"rawcli: no device ({type(exc).__name__}: {exc}); running offline", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
ISO14A_APPEND_CRC = 0x20
|
||||
|
||||
|
||||
def _needs_crc(payload: bytes) -> bool:
|
||||
"""Whether a 14a frame should get a CRC appended — everything except the frames ISO14443-A
|
||||
sends without one: the 7-bit short frames REQA (0x26) / WUPA (0x52) and the ANTICOLLISION
|
||||
frames (0x9x 0x20, which carry a BCC, not a CRC)."""
|
||||
if not payload:
|
||||
return False
|
||||
b0 = payload[0]
|
||||
if len(payload) == 1 and b0 in (0x26, 0x52):
|
||||
return False
|
||||
if len(payload) >= 2 and b0 in (0x93, 0x95, 0x97) and payload[1] == 0x20:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _ensure_selected(device, protocol: str) -> None:
|
||||
"""Re-select the 14a tag before a command so a stale/dropped selection (or a prior bad frame)
|
||||
can't leave the tag unresponsive. Silent — the re-select isn't traced. Note: this resets any
|
||||
prior auth, so a PWD_AUTH must be issued together with the command it protects (a later
|
||||
keep-session mode will address multi-step auth)."""
|
||||
if device is None or protocol != "hf14a":
|
||||
return
|
||||
try:
|
||||
device.hf.iso14a.scan()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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, 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
|
||||
else:
|
||||
return None # lf raw exchange lands in a later phase
|
||||
if not isinstance(resp, dict):
|
||||
return None
|
||||
data = resp.get("raw") # bytes; "data" is the hex string form
|
||||
if data is None:
|
||||
hexstr = resp.get("data")
|
||||
data = bytes.fromhex(hexstr) if isinstance(hexstr, str) else hexstr
|
||||
return data
|
||||
except Exception as exc:
|
||||
print(f"rawcli: exchange failed ({type(exc).__name__}: {exc})", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def dispatch(state: RawSession, line: str, out=print) -> bool:
|
||||
"""Handle one input line. ``out`` prints a (possibly ANSI-colored) string. Returns False to
|
||||
exit the REPL, True to keep going. Testable without a live prompt (default ``out=print``)."""
|
||||
try:
|
||||
cmd = parse_line(line, state.entry_mode, state.byte_bases)
|
||||
except ValueError as exc:
|
||||
out(f"rawcli: {exc}")
|
||||
return True
|
||||
finally:
|
||||
state.byte_bases = [] # the line is consumed — start the next one fresh
|
||||
|
||||
if cmd.kind == "control":
|
||||
if cmd.name in ("quit", "exit", "q"):
|
||||
return False
|
||||
if cmd.name == "help":
|
||||
_handle_help(state, cmd.args, out)
|
||||
elif cmd.name == "identify":
|
||||
result = identify(state, emit=out) # stream the probe exchanges to the trace
|
||||
out(format_summary(result))
|
||||
elif cmd.name == "transponder":
|
||||
out(state.transponder or "no transponder identified — run 'identify'")
|
||||
elif cmd.name == "close":
|
||||
clear(state)
|
||||
out("connection closed")
|
||||
elif cmd.name == "tlv":
|
||||
_handle_tlv(state, cmd.args, out)
|
||||
return True
|
||||
|
||||
if cmd.kind == "call":
|
||||
_handle_call(state, cmd, out)
|
||||
return True
|
||||
|
||||
if not cmd.payload:
|
||||
return True
|
||||
_ensure_selected(state.device, state.protocol)
|
||||
response = _raw_exchange(state.device, cmd.payload, protocol=state.protocol,
|
||||
crc=state.protocol == "hf14a") # 14a frames need CRC; bare `60` works
|
||||
out(render_exchange(cmd.payload, response, protocol=state.protocol, is_tty=True))
|
||||
return True
|
||||
|
||||
|
||||
def _handle_call(state: RawSession, cmd, out=print) -> None:
|
||||
catalog = catalog_for(state)
|
||||
if catalog is None:
|
||||
out("no transponder identified — run 'identify' first")
|
||||
return
|
||||
tc = catalog.get(cmd.name)
|
||||
if tc is None:
|
||||
out(f"unknown command {cmd.name!r} for {catalog.name}; try 'help'")
|
||||
return
|
||||
|
||||
# LF commands aren't a raw-byte exchange (14a/15693) — they drive the device via lf.t55 etc.
|
||||
# and return a human result line. (build() still exists for the opcode hint in completion.)
|
||||
if tc.run is not None:
|
||||
if state.device is None:
|
||||
out("no device connected")
|
||||
return
|
||||
try:
|
||||
out(str(tc.run(state.device, *cmd.args)))
|
||||
except (TypeError, ValueError) as exc:
|
||||
out(f"usage: {tc.usage()} ({exc})")
|
||||
except Exception as exc: # a flaky device shouldn't kill the REPL
|
||||
out(f"rawcli: {tc.name} failed ({type(exc).__name__}: {exc})")
|
||||
return
|
||||
|
||||
try:
|
||||
built = tc.build(*cmd.args)
|
||||
except (TypeError, ValueError) as exc:
|
||||
out(f"usage: {tc.usage()} ({exc})")
|
||||
return
|
||||
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
|
||||
_ensure_selected(state.device, catalog.protocol) # re-select once for the command
|
||||
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:
|
||||
from .tlv import format_tlv, prompt_tlv
|
||||
from .parser import parse_bytes
|
||||
if args:
|
||||
try:
|
||||
data = parse_bytes(" ".join(args), state.entry_mode)
|
||||
except ValueError as exc:
|
||||
out(f"rawcli: {exc}")
|
||||
return
|
||||
else:
|
||||
data = prompt_tlv(state) # interactive multi-line editor
|
||||
if not data:
|
||||
return
|
||||
out(format_tlv(data))
|
||||
|
||||
|
||||
def _handle_help(state: RawSession, args, out=print) -> None:
|
||||
catalog = catalog_for(state)
|
||||
if args and catalog is not None:
|
||||
tc = catalog.get(args[0])
|
||||
if tc is not None:
|
||||
out(f"{tc.usage()} — {tc.help}")
|
||||
return
|
||||
lines = [_HELP]
|
||||
if catalog is not None:
|
||||
lines.append(f"\n{catalog.name} commands:")
|
||||
lines += [f" {catalog.get(n).usage():<28}{catalog.get(n).help}" for n in catalog.names()]
|
||||
out("\n".join(lines))
|
||||
|
||||
|
||||
def run(port=None) -> int:
|
||||
from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import ANSI
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
|
||||
from .completer import RawCompleter
|
||||
|
||||
def out(text):
|
||||
# parse the ANSI our formatters emit and render it through prompt_toolkit (renders
|
||||
# color on a terminal, strips it when piped) — plain print() shows the codes literally
|
||||
print_formatted_text(ANSI(str(text)))
|
||||
|
||||
state = RawSession(device=_connect(port))
|
||||
|
||||
def bottom_toolbar():
|
||||
# just the entry-mode status — all command/page hints live in the completion menu
|
||||
return f"entry {state.entry_prefix} · Ctrl-/ hex/binary · 'help'"
|
||||
|
||||
kb = KeyBindings()
|
||||
install_key_bindings(kb, state)
|
||||
session = PromptSession(
|
||||
key_bindings=kb,
|
||||
completer=RawCompleter(state),
|
||||
complete_while_typing=True, # ipython-style live menu
|
||||
auto_suggest=AutoSuggestFromHistory(), # inline suggestion hints
|
||||
bottom_toolbar=bottom_toolbar, # live relevance hint
|
||||
)
|
||||
|
||||
def message():
|
||||
return ANSI(state.colored_breadcrumb() + " ")
|
||||
|
||||
with patch_stdout():
|
||||
while True:
|
||||
try:
|
||||
line = session.prompt(message)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if not line.strip():
|
||||
continue
|
||||
if not dispatch(state, line, out):
|
||||
break
|
||||
out("bye")
|
||||
return 0
|
||||
575
pm3py/cli/rawcli/catalog.py
Normal file
575
pm3py/cli/rawcli/catalog.py
Normal file
@@ -0,0 +1,575 @@
|
||||
"""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
|
||||
opcode_byte: int | None = None # explicit wire opcode. ISO15693 sets this to the
|
||||
# COMMAND byte (frame byte 1), since byte 0 is flags
|
||||
|
||||
def usage(self) -> str:
|
||||
return f"{self.name}({', '.join(self.params)})"
|
||||
|
||||
def opcode(self) -> int | None:
|
||||
"""The command's wire opcode — an explicit ``opcode_byte`` if given (ISO15693: the command
|
||||
byte, not the flags-first frame byte), else the first built byte. None for run-only LF
|
||||
commands with no build."""
|
||||
if self.opcode_byte is not None:
|
||||
return self.opcode_byte
|
||||
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, opcode=None) -> TagCommand:
|
||||
return TagCommand(name, tuple(params), build, help, run, opcode)
|
||||
|
||||
|
||||
# ---- 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"
|
||||
|
||||
|
||||
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 <block> then transfer to <dest>
|
||||
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 <value> 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 <value> 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 <block> -> <dest> (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)"),
|
||||
)
|
||||
|
||||
# ---- ISO 15693 request framing --------------------------------------------------------------
|
||||
# A 15693 request is flags | command | [mfg] | [UID LSB-first] | params | CRC. The first byte is
|
||||
# the request-FLAGS bitfield, NOT the command — so 15693 TagCommands carry an explicit opcode=
|
||||
# (the command byte) for completion, and iso15_frame() assembles the wire frame (CRC added by
|
||||
# hf.iso15.raw()).
|
||||
# Request-flags byte (byte 0). Bits 5-6 (0x10/0x20) have two meanings, chosen by the Inventory
|
||||
# flag (0x04): non-inventory -> Select/Address; inventory -> AFI/Nb_slots (ISO 15693-3 §7.3.1).
|
||||
F15_SUBCARRIER = 0x01 # two subcarriers (else one)
|
||||
F15_HIGH_RATE = 0x02 # data_rate: high (else low)
|
||||
F15_INVENTORY = 0x04
|
||||
F15_PROTO_EXT = 0x08
|
||||
F15_SELECT = 0x10 # non-inventory: Select flag
|
||||
F15_AFI = 0x10 # inventory: AFI flag (same bit, inventory mode)
|
||||
F15_ADDRESSED = 0x20 # non-inventory: Address flag
|
||||
F15_1SLOT = 0x20 # inventory: Nb_slots = 1 (else 16)
|
||||
F15_OPTION = 0x40
|
||||
NXP_MFG = 0x04
|
||||
|
||||
|
||||
def iso15_frame(command: int, params: bytes = b"", *, uid=None, addressed: bool = False,
|
||||
high_rate: bool = True, option: bool = False, inventory: bool = False,
|
||||
select: bool = False, proto_ext: bool = False, two_subcarrier: bool = False,
|
||||
slots16: bool = False, afi: int | None = None, mfg: int | None = None) -> bytes:
|
||||
"""Assemble an ISO 15693 request frame (pre-CRC): flags | command | [mfg] | [UID|AFI] | params.
|
||||
|
||||
Exposes the full request-flags byte (byte 0). Bits 5-6 differ by mode (ISO 15693-3 §7.3.1):
|
||||
non-inventory -> Select(0x10)/Address(0x20); inventory -> AFI(0x10)/Nb_slots(0x20). Set
|
||||
``inventory=True`` for the INVENTORY family — 1 slot unless ``slots16``, and the AFI byte + flag
|
||||
when ``afi`` is given; otherwise ``select``/``addressed`` apply. ``two_subcarrier`` (0x01),
|
||||
``proto_ext`` (0x08) and ``option`` (0x40) apply in both modes. An addressed UID is transmitted
|
||||
LSByte-first; the inventory AFI byte precedes ``params`` (i.e. the mask length/value)."""
|
||||
flags = 0
|
||||
if two_subcarrier:
|
||||
flags |= F15_SUBCARRIER
|
||||
if high_rate:
|
||||
flags |= F15_HIGH_RATE
|
||||
if proto_ext:
|
||||
flags |= F15_PROTO_EXT
|
||||
if option:
|
||||
flags |= F15_OPTION
|
||||
if inventory:
|
||||
flags |= F15_INVENTORY
|
||||
if afi is not None:
|
||||
flags |= F15_AFI # inventory: AFI flag (bit 5)
|
||||
if not slots16:
|
||||
flags |= F15_1SLOT # inventory: Nb_slots = 1 (bit 6)
|
||||
else:
|
||||
if select:
|
||||
flags |= F15_SELECT # non-inventory: Select (bit 5)
|
||||
if addressed and uid:
|
||||
flags |= F15_ADDRESSED # non-inventory: Address (bit 6)
|
||||
out = bytearray([flags, command])
|
||||
if mfg is not None:
|
||||
out.append(mfg)
|
||||
if inventory:
|
||||
if afi is not None:
|
||||
out.append(afi)
|
||||
elif addressed and uid:
|
||||
u = bytes.fromhex(uid) if isinstance(uid, str) else bytes(uid)
|
||||
out += u[::-1] # UID is transmitted LSByte-first
|
||||
return bytes(out) + bytes(params)
|
||||
|
||||
|
||||
def iso15_flags_decode(flags: int) -> str:
|
||||
"""Human decode of a 15693 request-flags byte (byte 0), for the raw-entry hint. Covers every
|
||||
bit; bits 5-6 are mode-dependent (Select/Address vs AFI/Nb_slots, see iso15_frame)."""
|
||||
if flags & F15_INVENTORY:
|
||||
parts = ["inventory", "high rate" if flags & F15_HIGH_RATE else "low rate"]
|
||||
if flags & F15_SUBCARRIER:
|
||||
parts.append("two subcarrier")
|
||||
parts.append("1 slot" if flags & F15_1SLOT else "16 slots")
|
||||
if flags & F15_AFI:
|
||||
parts.append("AFI")
|
||||
if flags & F15_PROTO_EXT:
|
||||
parts.append("protocol-ext")
|
||||
else:
|
||||
parts = ["high rate" if flags & F15_HIGH_RATE else "low rate"]
|
||||
if flags & F15_SUBCARRIER:
|
||||
parts.append("two subcarrier")
|
||||
parts.append("addressed" if flags & F15_ADDRESSED else "non-addressed")
|
||||
if flags & F15_SELECT:
|
||||
parts.append("select")
|
||||
if flags & F15_PROTO_EXT:
|
||||
parts.append("protocol-ext")
|
||||
if flags & F15_OPTION:
|
||||
parts.append("option")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
# common flags bytes offered at frame byte 0 (the decode hint covers any value you type)
|
||||
ISO15_FLAG_LANDMARKS = [0x02, 0x22, 0x12, 0x42, 0x62, 0x0A, 0x26, 0x06, 0x36, 0x00]
|
||||
|
||||
|
||||
def _inv_read_frame(cmd: int, first_block, count, mask_len="0", mask="", afi="") -> bytes:
|
||||
"""INVENTORY READ / FAST INVENTORY READ request body:
|
||||
``[AFI] | mask_len(bits) | mask_value | first_block | (count-1)``.
|
||||
|
||||
``mask_len`` 0 (default) reads the one tag in the field. A non-zero ``mask_len`` (bits) plus a
|
||||
``mask`` value (hex, LSByte-first on the wire, padded/truncated to ceil(bits/8) bytes) does
|
||||
selective anticollision; ``afi`` (when given) adds the AFI filter byte + flag. This is the full
|
||||
ICODE inventory-read request per the datasheet."""
|
||||
ml = _int(mask_len)
|
||||
nbytes = (ml + 7) // 8
|
||||
mask_bytes = ((_hex(mask) if mask else b"") + b"\x00" * nbytes)[:nbytes]
|
||||
body = bytes([ml]) + mask_bytes + bytes([_int(first_block), _int(count) - 1])
|
||||
return iso15_frame(cmd, body, inventory=True,
|
||||
afi=(_int(afi) if str(afi) != "" else None), mfg=NXP_MFG)
|
||||
|
||||
|
||||
# ---- ISO 15693 (generic) ----
|
||||
ISO15 = _catalog(
|
||||
"ISO 15693", "hf15",
|
||||
_c("READ_BLOCK", ["block"], lambda block: iso15_frame(0x20, bytes([_int(block)])),
|
||||
"read a single block (0x20)", opcode=0x20),
|
||||
_c("WRITE_BLOCK", ["block", "data"],
|
||||
lambda block, data: iso15_frame(0x21, bytes([_int(block)]) + _hex(data)),
|
||||
"write a single 4-byte block (0x21)", opcode=0x21),
|
||||
_c("GET_SYSTEM_INFO", [], lambda: iso15_frame(0x2B), "get system information (0x2B)", opcode=0x2B),
|
||||
)
|
||||
|
||||
|
||||
# ---- ICODE SLIX (NXP SL2S2002) — datasheet command set via the header builder ----
|
||||
_SLIX = _catalog(
|
||||
"ICODE SLIX", "hf15",
|
||||
# standard ISO 15693
|
||||
_c("READ_BLOCK", ["block"], lambda block: iso15_frame(0x20, bytes([_int(block)])),
|
||||
"read a single 4-byte block (0x20)", opcode=0x20),
|
||||
_c("WRITE_BLOCK", ["block", "data"],
|
||||
lambda block, data: iso15_frame(0x21, bytes([_int(block)]) + _hex(data).ljust(4, b"\x00")[:4]),
|
||||
"write a single 4-byte block (0x21)", opcode=0x21),
|
||||
_c("LOCK_BLOCK", ["block"], lambda block: iso15_frame(0x22, bytes([_int(block)])),
|
||||
"permanently lock a block (0x22)", opcode=0x22),
|
||||
_c("READ_MULTIPLE", ["first", "count"],
|
||||
lambda first, count: iso15_frame(0x23, bytes([_int(first), _int(count) - 1])),
|
||||
"read <count> blocks from <first> (0x23)", opcode=0x23),
|
||||
_c("SELECT", [], lambda: iso15_frame(0x25), "put the tag in Selected state (0x25)", opcode=0x25),
|
||||
_c("RESET_TO_READY", [], lambda: iso15_frame(0x26), "reset the tag to Ready (0x26)", opcode=0x26),
|
||||
_c("STAY_QUIET", [], lambda: iso15_frame(0x02), "silence the tag until reset (0x02)", opcode=0x02),
|
||||
_c("WRITE_AFI", ["afi"], lambda afi: iso15_frame(0x27, bytes([_int(afi)])),
|
||||
"write the AFI byte (0x27)", opcode=0x27),
|
||||
_c("LOCK_AFI", [], lambda: iso15_frame(0x28), "lock the AFI (0x28)", opcode=0x28),
|
||||
_c("WRITE_DSFID", ["dsfid"], lambda dsfid: iso15_frame(0x29, bytes([_int(dsfid)])),
|
||||
"write the DSFID byte (0x29)", opcode=0x29),
|
||||
_c("LOCK_DSFID", [], lambda: iso15_frame(0x2A), "lock the DSFID (0x2A)", opcode=0x2A),
|
||||
_c("GET_SYSTEM_INFO", [], lambda: iso15_frame(0x2B), "get system information (0x2B)", opcode=0x2B),
|
||||
_c("GET_SECURITY", ["first", "count"],
|
||||
lambda first, count: iso15_frame(0x2C, bytes([_int(first), _int(count) - 1])),
|
||||
"get block security (lock) status (0x2C)", opcode=0x2C),
|
||||
# NXP custom (manufacturer code 0x04 inserted by the builder)
|
||||
_c("GET_NXP_SYSTEM_INFO", [], lambda: iso15_frame(0xAB, mfg=NXP_MFG),
|
||||
"NXP system info — protection pointer, lock, features (0xAB)", opcode=0xAB),
|
||||
_c("GET_RANDOM", [], lambda: iso15_frame(0xB2, mfg=NXP_MFG),
|
||||
"get a 16-bit challenge to XOR the password with (0xB2)", opcode=0xB2),
|
||||
_c("SET_PASSWORD", ["pwd_id", "xor_pwd"],
|
||||
lambda pwd_id, xor_pwd: iso15_frame(0xB3, bytes([_int(pwd_id)]) + _hex(xor_pwd), mfg=NXP_MFG),
|
||||
"present a password (4 bytes XOR'd with GET_RANDOM) (0xB3)", opcode=0xB3),
|
||||
_c("WRITE_PASSWORD", ["pwd_id", "pwd"],
|
||||
lambda pwd_id, pwd: iso15_frame(0xB4, bytes([_int(pwd_id)]) + _hex(pwd), mfg=NXP_MFG),
|
||||
"write a new password (0xB4)", opcode=0xB4),
|
||||
_c("LOCK_PASSWORD", ["pwd_id"],
|
||||
lambda pwd_id: iso15_frame(0xB5, bytes([_int(pwd_id)]), mfg=NXP_MFG),
|
||||
"lock a password (0xB5)", opcode=0xB5),
|
||||
_c("PROTECT_PAGE", ["page", "prot"],
|
||||
lambda page, prot: iso15_frame(0xB6, bytes([_int(page), _int(prot)]), mfg=NXP_MFG),
|
||||
"set a page's read/write protection (0xB6)", opcode=0xB6),
|
||||
_c("LOCK_PAGE_PROTECTION", ["page"],
|
||||
lambda page: iso15_frame(0xB7, bytes([_int(page)]), mfg=NXP_MFG),
|
||||
"lock a page's protection condition (0xB7)", opcode=0xB7),
|
||||
_c("SET_EAS", [], lambda: iso15_frame(0xA2, mfg=NXP_MFG), "set the EAS bit (0xA2)", opcode=0xA2),
|
||||
_c("RESET_EAS", [], lambda: iso15_frame(0xA3, mfg=NXP_MFG), "clear the EAS bit (0xA3)", opcode=0xA3),
|
||||
_c("LOCK_EAS", [], lambda: iso15_frame(0xA4, mfg=NXP_MFG), "lock the EAS bit (0xA4)", opcode=0xA4),
|
||||
_c("EAS_ALARM", [], lambda: iso15_frame(0xA5, mfg=NXP_MFG), "read the EAS alarm sequence (0xA5)", opcode=0xA5),
|
||||
_c("PROTECT_EAS_AFI", [], lambda: iso15_frame(0xA6, mfg=NXP_MFG),
|
||||
"password-protect EAS/AFI (0xA6)", opcode=0xA6),
|
||||
_c("WRITE_EAS_ID", ["eas_id"], lambda eas_id: iso15_frame(0xA7, _hex(eas_id), mfg=NXP_MFG),
|
||||
"write the 16-bit EAS ID (0xA7)", opcode=0xA7),
|
||||
_c("ENABLE_PRIVACY", ["xor_pwd"], lambda xor_pwd: iso15_frame(0xBA, _hex(xor_pwd), mfg=NXP_MFG),
|
||||
"enter privacy mode with the privacy password (0xBA)", opcode=0xBA),
|
||||
_c("DESTROY", ["xor_pwd"], lambda xor_pwd: iso15_frame(0xB9, _hex(xor_pwd), mfg=NXP_MFG),
|
||||
"permanently destroy the tag — IRREVERSIBLE (0xB9)", opcode=0xB9),
|
||||
# INVENTORY READ family: inventory + block read in one exchange. Full body via _inv_read_frame
|
||||
# ([AFI] | mask_len | mask | first_block | count-1); mask-less reads the one tag in the field.
|
||||
_c("INVENTORY_READ", ["first_block", "count", "mask_len", "mask", "afi"],
|
||||
lambda first_block, count, mask_len="0", mask="", afi="":
|
||||
_inv_read_frame(0xA0, first_block, count, mask_len, mask, afi),
|
||||
"inventory + read <count> blocks from <first_block>; optional <mask_len> bits + <mask> value "
|
||||
"(selective anticollision) and <afi> filter (0xA0)", opcode=0xA0),
|
||||
_c("FAST_INVENTORY_READ", ["first_block", "count", "mask_len", "mask", "afi"],
|
||||
lambda first_block, count, mask_len="0", mask="", afi="":
|
||||
_inv_read_frame(0xA1, first_block, count, mask_len, mask, afi),
|
||||
"INVENTORY_READ with the response at the higher data rate (0xA1)", opcode=0xA1),
|
||||
)
|
||||
|
||||
|
||||
# ICODE SLIX2 (SL2S2602): everything the SLIX has, plus three SLIX2-only commands and a larger
|
||||
# 80-block memory. READ_SIGNATURE (0xBD) is the one identify probes to tell SLIX2 from SLIX.
|
||||
_SLIX2 = _catalog(
|
||||
"ICODE SLIX2", "hf15", *_SLIX.commands.values(),
|
||||
_c("READ_SIGNATURE", [], lambda: iso15_frame(0xBD, mfg=NXP_MFG),
|
||||
"read the 32-byte ECC originality signature — SLIX2 only (0xBD)", opcode=0xBD),
|
||||
_c("PASSWORD_PROTECTION_64BIT", [], lambda: iso15_frame(0xBB, mfg=NXP_MFG),
|
||||
"combine the read+write passwords into 64-bit protection — SLIX2 only (0xBB)", opcode=0xBB),
|
||||
_c("STAY_QUIET_PERSISTENT", [], lambda: iso15_frame(0xBC, mfg=NXP_MFG),
|
||||
"persistent quiet that survives power cycles — SLIX2 only (0xBC)", opcode=0xBC),
|
||||
)
|
||||
|
||||
|
||||
# NTAG 5 (NTP5210 Switch / NTA5332 Link/Boost): the SLIX2 command set minus STAY_QUIET_PERSISTENT
|
||||
# (NTAG5 rejects it) plus config memory + session registers, PICK RANDOM UID, and — on Link/Boost —
|
||||
# SRAM. NTAG5 shares the DNA UID (E0 04 01 18), so routing here needs a functional identify probe.
|
||||
_NTAG5 = _catalog(
|
||||
"NTAG 5", "hf15",
|
||||
*[c for c in _SLIX2.commands.values() if c.name != "STAY_QUIET_PERSISTENT"],
|
||||
_c("READ_CONFIG", ["block", "count"],
|
||||
lambda block, count: iso15_frame(0xC0, bytes([_int(block), _int(count) - 1]), mfg=NXP_MFG),
|
||||
"read config / session-register blocks (0xA0 STATUS_REG, 0xA1 CONFIG_REG) (0xC0)", opcode=0xC0),
|
||||
_c("WRITE_CONFIG", ["block", "data"],
|
||||
lambda block, data: iso15_frame(
|
||||
0xC1, bytes([_int(block)]) + _hex(data).ljust(4, b"\x00")[:4], mfg=NXP_MFG),
|
||||
"write a 4-byte config / session-register block (0xC1)", opcode=0xC1),
|
||||
_c("PICK_RANDOM_UID", [], lambda: iso15_frame(0xC2, mfg=NXP_MFG),
|
||||
"generate a random UID for privacy mode — AES variants only (0xC2)", opcode=0xC2),
|
||||
_c("READ_SRAM", ["block", "count"],
|
||||
lambda block, count: iso15_frame(0xD2, bytes([_int(block), _int(count) - 1]), mfg=NXP_MFG),
|
||||
"read SRAM blocks — Link/Boost only (0xD2)", opcode=0xD2),
|
||||
_c("WRITE_SRAM", ["block", "data"],
|
||||
lambda block, data: iso15_frame(
|
||||
0xD3, bytes([_int(block)]) + _hex(data).ljust(4, b"\x00")[:4], mfg=NXP_MFG),
|
||||
"write a 4-byte SRAM block — Link/Boost only (0xD3)", opcode=0xD3),
|
||||
)
|
||||
|
||||
|
||||
# ---- 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 _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, 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']}{_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, page=pg, password=pwd)
|
||||
where = f"block {b}" + (" (page 1)" if pg else "")
|
||||
if word is None:
|
||||
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, 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
|
||||
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(_word(password))
|
||||
return f"WAKE: {'ok' if isinstance(r, dict) and r.get('status') == 0 else 'failed'}"
|
||||
|
||||
|
||||
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, password=_optpwd(password))
|
||||
if not r:
|
||||
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']}{_pwd_note(password)}")
|
||||
|
||||
|
||||
def _lf_reread(dev):
|
||||
from pm3py.lf import identify_lf
|
||||
r = identify_lf(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 <page>
|
||||
# (1 = traceability data) and <password> for password-mode access.
|
||||
T5577 = _catalog(
|
||||
"T5577 / ATA5577", "lf",
|
||||
_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); <page> 1 = traceability, "
|
||||
"optional <password> — 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 <data> to <block>; optional <page> / <password> (0x02)"),
|
||||
_c("WAKE", ["password"], build=lambda password: bytes([0x03]), run=_t55_wake,
|
||||
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 <password>"),
|
||||
)
|
||||
|
||||
# ---- 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":
|
||||
if "NTAG5" in name or "NTAG 5" in name:
|
||||
return _NTAG5
|
||||
if "SLIX2" in name or "DNA" in name: # DNA: closest existing set; own catalog is a follow-up
|
||||
return _SLIX2
|
||||
return _SLIX if "SLIX" in name or "ICODE" in name else 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
|
||||
263
pm3py/cli/rawcli/completer.py
Normal file
263
pm3py/cli/rawcli/completer.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""ipython-style completion for rawcli.
|
||||
|
||||
Completes the leading token in two ways, both driven by the **identified transponder**:
|
||||
|
||||
- by name — control verbs and the transponder's catalog commands (help text as the tooltip);
|
||||
- by **opcode** — as you type hex or binary bytes, the transponder's command opcodes are matched
|
||||
against the input (respecting a ``0x``/``0b`` prefix and the current entry mode), so ``60`` and
|
||||
``01100000`` both surface ``GET_VERSION``.
|
||||
|
||||
``help <cmd>`` completes catalog command names too. Paired in the app with menu completion while
|
||||
typing and history auto-suggestion for the ipython feel.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
|
||||
from .parser import CONTROL_VERBS, parse_token
|
||||
from .catalog import catalog_for
|
||||
from .memory import landmark_pages
|
||||
from .entry import is_byte_line
|
||||
|
||||
_HEXDIGITS = set("0123456789abcdefABCDEF")
|
||||
_BINDIGITS = set("01")
|
||||
|
||||
# NAME( <first-arg-being-typed> — matches only while the first argument is under the cursor
|
||||
# (a comma ends the match, so second args aren't touched).
|
||||
_CALL_ARG = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\(\s*([^,)]*)$")
|
||||
|
||||
|
||||
def _opcode(tc) -> int | None:
|
||||
"""The command's first byte (opcode) — None for run-only commands with no ``build``."""
|
||||
return tc.opcode()
|
||||
|
||||
|
||||
class RawCompleter(Completer):
|
||||
def __init__(self, session):
|
||||
self._session = session
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
text = document.text_before_cursor
|
||||
stripped = text.lstrip()
|
||||
|
||||
# inside READ(<page> / WRITE(<block> — offer the tag's memory map for the page argument
|
||||
m = _CALL_ARG.match(stripped)
|
||||
if m:
|
||||
yield from self._page_arg(m.group(1), m.group(2))
|
||||
return
|
||||
|
||||
tokens = stripped.split()
|
||||
at_word = bool(tokens) and not stripped.endswith(" ")
|
||||
|
||||
# ISO 15693 raw frame is position-aware: byte 0 = flags, byte 1 = command, byte 2 = mfg
|
||||
# (a bitfield-first frame, unlike 14a where byte 0 is the opcode).
|
||||
if getattr(self._session, "protocol", None) == "hf15" and is_byte_line(stripped):
|
||||
yield from self._iso15_raw(tokens, at_word)
|
||||
return
|
||||
|
||||
# raw byte entry, on the byte after a page-command opcode (30 …): offer the memory map as
|
||||
# raw bytes, so `30 04` gets the same suggestions as READ( — inserting the raw value
|
||||
on_second = (len(tokens) == 2 and at_word) or (len(tokens) == 1 and not at_word)
|
||||
if on_second and self._is_raw_opcode(tokens[0]):
|
||||
yield from self._raw_page_byte(tokens, at_word)
|
||||
return
|
||||
|
||||
word = tokens[-1] if at_word else ""
|
||||
completing_first = (" " not in stripped) or (len(tokens) == 1 and at_word)
|
||||
if completing_first:
|
||||
yield from self._first_token(word)
|
||||
elif tokens and tokens[0].lower() == "help":
|
||||
yield from self._catalog_names(word)
|
||||
|
||||
def _is_raw_opcode(self, tok: str) -> bool:
|
||||
"""True when ``tok`` parses to a single byte that is a page-command opcode (so the next
|
||||
raw byte is a page argument we can hint)."""
|
||||
catalog = catalog_for(self._session)
|
||||
if catalog is None:
|
||||
return False
|
||||
# parse the opcode byte in ITS base — the base it was typed in, not the current entry mode
|
||||
# (after `30` in hex you toggle to binary to type the page byte; `30` is still hex)
|
||||
bases = getattr(self._session, "byte_bases", []) or []
|
||||
base = bases[0] if bases else getattr(self._session, "entry_mode", "hex")
|
||||
try:
|
||||
b = parse_token(tok, base)
|
||||
except ValueError:
|
||||
return False
|
||||
if len(b) != 1:
|
||||
return False
|
||||
tc = catalog.by_opcode(b[0])
|
||||
return tc is not None and bool(tc.params) and tc.params[0] in ("page", "block", "start")
|
||||
|
||||
def _raw_page_byte(self, tokens: list[str], at_word: bool):
|
||||
"""Offer the tag's memory map for a raw page byte (after a page-command opcode), rendered as
|
||||
a raw byte in the entry base — ``04`` in hex, ``00000100`` in binary. Accepting inserts the
|
||||
raw value, keeping you in raw entry (30 04), not the command name."""
|
||||
partial = tokens[1] if (len(tokens) >= 2 and at_word) else ""
|
||||
low = partial.lower()
|
||||
if low.startswith("0x"):
|
||||
base, body = "hex", low[2:]
|
||||
elif low.startswith("0b"):
|
||||
base, body = "bin", low[2:]
|
||||
elif getattr(self._session, "entry_mode", "hex") == "bin":
|
||||
base, body = "bin", low
|
||||
else:
|
||||
base, body = "hex", low
|
||||
for page, role in landmark_pages(self._session.transponder):
|
||||
# a raw byte is fixed-width (2 hex / 8 binary digits) — match the full-width form only
|
||||
if base == "bin":
|
||||
val = f"{page:08b}"
|
||||
else:
|
||||
val = f"{page:02X}"
|
||||
if body and not val.lower().startswith(body):
|
||||
continue
|
||||
yield Completion(val, start_position=-len(partial),
|
||||
display=f"{val} {role}", display_meta=role)
|
||||
|
||||
def _raw_base(self, partial: str) -> tuple[str, str]:
|
||||
"""(base, body) for a raw-frame byte token — a 0x/0b prefix wins, else the session mode."""
|
||||
low = partial.lower()
|
||||
if low.startswith("0x"):
|
||||
return "hex", low[2:]
|
||||
if low.startswith("0b"):
|
||||
return "bin", low[2:]
|
||||
return ("bin" if getattr(self._session, "entry_mode", "hex") == "bin" else "hex"), low
|
||||
|
||||
def _iso15_byte(self, value: int, partial: str, label: str, meta: str):
|
||||
"""A single raw-byte completion rendered in the entry base, filtered by ``partial``."""
|
||||
base, body = self._raw_base(partial)
|
||||
val = f"{value:08b}" if base == "bin" else f"{value:02X}"
|
||||
if body and not val.lower().startswith(body):
|
||||
return None
|
||||
return Completion(val, start_position=-len(partial), display=f"{val} {label}", display_meta=meta)
|
||||
|
||||
def _iso15_raw(self, tokens: list[str], at_word: bool):
|
||||
"""Position-aware completion for an ISO 15693 raw frame: byte 0 = the request-flags menu
|
||||
(each value decoded), byte 1 = the command, byte 2 = the NXP mfg code on custom commands."""
|
||||
from .catalog import ISO15_FLAG_LANDMARKS, iso15_flags_decode, NXP_MFG
|
||||
pos = (len(tokens) - 1) if at_word else len(tokens)
|
||||
partial = tokens[pos] if (at_word and pos < len(tokens)) else ""
|
||||
if pos == 0: # flags byte — the header menu + decode
|
||||
for f in ISO15_FLAG_LANDMARKS:
|
||||
c = self._iso15_byte(f, partial, iso15_flags_decode(f), iso15_flags_decode(f))
|
||||
if c:
|
||||
yield c
|
||||
elif pos == 1: # command byte
|
||||
catalog = catalog_for(self._session)
|
||||
if catalog is None:
|
||||
return
|
||||
for name in catalog.names():
|
||||
op = catalog.get(name).opcode()
|
||||
if op is None:
|
||||
continue
|
||||
c = self._iso15_byte(op, partial, name, catalog.get(name).help)
|
||||
if c:
|
||||
yield c
|
||||
elif pos == 2: # mfg byte, only after a custom command
|
||||
cmd = self._token_byte(tokens[1]) if len(tokens) > 1 else None
|
||||
if cmd is not None and cmd >= 0xA0:
|
||||
c = self._iso15_byte(NXP_MFG, partial, "NXP manufacturer code", "NXP custom (0x04)")
|
||||
if c:
|
||||
yield c
|
||||
|
||||
def _token_byte(self, tok: str) -> int | None:
|
||||
try:
|
||||
b = parse_token(tok, getattr(self._session, "entry_mode", "hex"))
|
||||
except ValueError:
|
||||
return None
|
||||
return b[0] if len(b) == 1 else None
|
||||
|
||||
def _page_arg(self, cmd_name: str, partial: str):
|
||||
"""Complete a page/block argument with the identified tag's memory landmarks, rendered in
|
||||
the active entry mode — ``0x04`` in hex, ``0b00000100`` in binary (every bit visible,
|
||||
since for config pages each bit carries its own meaning). ``READ(`` lists the whole map;
|
||||
typing narrows it. A ``0x``/``0b`` prefix on the argument overrides the session mode."""
|
||||
catalog = catalog_for(self._session)
|
||||
if catalog is None:
|
||||
return
|
||||
tc = catalog.get(cmd_name)
|
||||
if tc is None or not tc.params or tc.params[0] not in ("page", "block", "start"):
|
||||
return # only a page/block/start address gets a map
|
||||
mode = self._arg_mode(partial)
|
||||
body = partial.lower()
|
||||
if body.startswith(("0x", "0b")):
|
||||
body = body[2:]
|
||||
for page, role in landmark_pages(self._session.transponder):
|
||||
if mode == "bin":
|
||||
val, forms = f"0b{page:08b}", (f"{page:08b}", f"{page:b}")
|
||||
else:
|
||||
val, forms = f"0x{page:02X}", (f"{page:02x}", f"{page:x}", str(page))
|
||||
if body and not any(f.startswith(body) for f in forms):
|
||||
continue
|
||||
yield Completion(val, start_position=-len(partial),
|
||||
display=f"{val} {role}", display_meta=role)
|
||||
|
||||
def _arg_mode(self, partial: str) -> str:
|
||||
"""hex/bin for an argument token — a 0x/0b prefix wins, else the session entry mode."""
|
||||
low = partial.lower()
|
||||
if low.startswith("0x"):
|
||||
return "hex"
|
||||
if low.startswith("0b"):
|
||||
return "bin"
|
||||
return "bin" if getattr(self._session, "entry_mode", "hex") == "bin" else "hex"
|
||||
|
||||
def _first_token(self, word: str):
|
||||
low = word.lower()
|
||||
for verb in sorted(CONTROL_VERBS):
|
||||
if verb.startswith(low):
|
||||
yield Completion(verb, start_position=-len(word), display_meta="command")
|
||||
|
||||
catalog = catalog_for(self._session)
|
||||
if catalog is None:
|
||||
return
|
||||
|
||||
# by command name
|
||||
for name in catalog.names():
|
||||
if name.lower().startswith(low):
|
||||
tc = catalog.get(name)
|
||||
yield Completion(f"{name}(", start_position=-len(word),
|
||||
display=tc.usage(), display_meta=tc.help)
|
||||
|
||||
# by opcode, from hex/binary input (transponder-dependent)
|
||||
yield from self._opcode_matches(word, catalog)
|
||||
|
||||
def _numeric(self, word: str) -> tuple[str, str]:
|
||||
"""(digits, base) for an entry token — honours a 0x/0b prefix, else the session mode."""
|
||||
low = word.lower()
|
||||
if low.startswith("0x"):
|
||||
return low[2:], "hex"
|
||||
if low.startswith("0b"):
|
||||
return low[2:], "bin"
|
||||
return low, getattr(self._session, "entry_mode", "hex")
|
||||
|
||||
def _opcode_matches(self, word: str, catalog):
|
||||
# matching raw hex/binary input to a command by opcode. Accepting inserts the RAW BYTE
|
||||
# (rendered in the entry base), not the command name — you're building a raw byte string.
|
||||
digits, base = self._numeric(word)
|
||||
if not digits:
|
||||
return
|
||||
if base == "hex":
|
||||
valid, match, value = _HEXDIGITS, (lambda op: f"{op:02x}"), (lambda op: f"{op:02X}")
|
||||
else:
|
||||
valid, match, value = _BINDIGITS, (lambda op: f"{op:08b}"), (lambda op: f"{op:08b}")
|
||||
if any(ch not in valid for ch in digits):
|
||||
return
|
||||
prefix = word[:len(word) - len(digits)] # keep any 0x/0b the user typed
|
||||
for name in catalog.names():
|
||||
tc = catalog.get(name)
|
||||
op = _opcode(tc)
|
||||
if op is not None and match(op).startswith(digits):
|
||||
yield Completion(prefix + value(op), start_position=-len(word),
|
||||
display=f"{name} (0x{op:02X})",
|
||||
display_meta=f"opcode 0x{op:02X} — {tc.help}")
|
||||
|
||||
def _catalog_names(self, word: str):
|
||||
catalog = catalog_for(self._session)
|
||||
if catalog is None:
|
||||
return
|
||||
low = word.lower()
|
||||
for name in catalog.names():
|
||||
if name.lower().startswith(low):
|
||||
yield Completion(name, start_position=-len(word),
|
||||
display_meta=catalog.get(name).help)
|
||||
112
pm3py/cli/rawcli/entry.py
Normal file
112
pm3py/cli/rawcli/entry.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Numeric entry: per-byte base-aware input for raw hex/binary, plus the prompt_toolkit key
|
||||
bindings that drive it.
|
||||
|
||||
Raw byte entry mixes bases **per byte** — each byte remembers the base it was typed in, so
|
||||
``30`` (hex) and ``00000100`` (binary) can sit on one line as ``30 00000100`` and send ``0x30
|
||||
0x04``. The display stays clean (no ``0x``/``0b`` clutter); the base of each byte is tracked
|
||||
alongside the text in ``session.byte_bases``. A byte auto-seals at its base width (2 hex / 8
|
||||
binary) and the next digit starts a fresh byte in the current entry mode; toggling the mode
|
||||
(Ctrl-/) only changes the base of the *next* byte — it never rewrites what you've typed.
|
||||
|
||||
The pure ``type_digit`` / ``group_size`` / ``is_byte_line`` functions carry the logic and are
|
||||
unit-tested; the key bindings are thin wiring exercised in the live REPL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
HEX = "hex"
|
||||
BIN = "bin"
|
||||
_DIGITS = {HEX: set("0123456789abcdefABCDEF"), BIN: set("01")}
|
||||
|
||||
|
||||
def group_size(mode: str) -> int:
|
||||
"""Characters per byte group: 2 for hex, 8 for binary."""
|
||||
return 2 if mode == HEX else 8
|
||||
|
||||
|
||||
def is_byte_line(text: str) -> bool:
|
||||
"""Whether ``text`` (spaces ignored) is a pure hex/binary byte run — i.e. raw entry, not a
|
||||
command word or function call. Empty is a byte line (you're about to type bytes)."""
|
||||
return all(ch in _DIGITS[HEX] for ch in text.replace(" ", ""))
|
||||
|
||||
|
||||
def type_digit(text: str, bases: list[str], mode: str, digit: str) -> tuple[str, list[str]]:
|
||||
"""Apply one digit keystroke to a raw-byte line, base-aware. Returns ``(new_text, new_bases)``.
|
||||
|
||||
A byte seals at its base's width (2 hex / 8 binary); once full, the next digit starts a new
|
||||
byte in ``mode``. A digit that isn't valid for the byte it would land in is dropped (no
|
||||
change), so binary bytes can't gain hex digits and vice-versa."""
|
||||
d = digit.lower()
|
||||
groups = text.split(" ") if text else []
|
||||
aligned = len(bases) == len(groups)
|
||||
# extend the trailing byte if it's still open (present, aligned, below its base width)
|
||||
if groups and aligned and groups[-1] and len(groups[-1]) < group_size(bases[-1]):
|
||||
b = bases[-1]
|
||||
if d in _DIGITS[b]:
|
||||
groups[-1] += digit
|
||||
return " ".join(groups), list(bases)
|
||||
return text, list(bases) # wrong base for this byte — drop
|
||||
# otherwise begin a new byte in the current entry mode
|
||||
if d in _DIGITS[mode]:
|
||||
return (" ".join(groups + [digit]) if groups else digit), list(bases) + [mode]
|
||||
return text, list(bases)
|
||||
|
||||
|
||||
def reconcile_bases(text: str, bases: list[str]) -> list[str]:
|
||||
"""Trim ``bases`` to the number of byte groups left in ``text`` (after a backspace/edit). Only
|
||||
a best-effort end-alignment — enough for append + backspace, which is what raw entry does."""
|
||||
groups = [g for g in text.split(" ") if g]
|
||||
return list(bases[:len(groups)])
|
||||
|
||||
|
||||
#: keys that toggle entry mode. Ctrl-/ is emitted as Ctrl-_ (0x1F) by most terminals; c-t is an
|
||||
#: always-available fallback that power users can rely on regardless of terminal.
|
||||
TOGGLE_KEYS = ("c-_", "c-t")
|
||||
|
||||
|
||||
def _bind(kb, key, handler) -> None:
|
||||
"""Add a binding, ignoring key names a given prompt_toolkit build doesn't accept."""
|
||||
try:
|
||||
kb.add(key)(handler)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def install_key_bindings(kb, session) -> None:
|
||||
"""Wire the entry-mode toggle (Ctrl-/ via Ctrl-_, plus Ctrl-t) and per-byte base-aware digit
|
||||
entry onto ``kb``. ``session`` is the :class:`RawSession` whose ``entry_mode`` is toggled/read
|
||||
and whose ``byte_bases`` records each raw byte's base. Never raises on an unknown key name."""
|
||||
|
||||
def _toggle(event):
|
||||
session.toggle_entry_mode() # only flips the mode — the next byte you type uses it
|
||||
event.app.invalidate() # redraw the breadcrumb
|
||||
|
||||
for key in TOGGLE_KEYS:
|
||||
_bind(kb, key, _toggle)
|
||||
|
||||
def _digit(event):
|
||||
buf = event.current_buffer
|
||||
d = event.data
|
||||
text = buf.text
|
||||
# only base-group a raw byte line; a command/function-call being typed inserts verbatim
|
||||
if not is_byte_line(text.replace(" ", "") + d):
|
||||
buf.insert_text(d)
|
||||
return
|
||||
new_text, session.byte_bases = type_digit(text, session.byte_bases, session.entry_mode, d)
|
||||
# Apply the change as an append via insert_text, NOT `buf.document = …`: assigning the
|
||||
# document resets prompt_toolkit's complete_state and never re-fires completion, which
|
||||
# killed the live autocomplete menu during raw byte entry. type_digit only ever appends
|
||||
# (optionally after an auto-space) or drops, so the delta is always a pure suffix.
|
||||
suffix = new_text[len(text):]
|
||||
if suffix:
|
||||
buf.insert_text(suffix)
|
||||
|
||||
for ch in sorted(_DIGITS[HEX] | _DIGITS[BIN]):
|
||||
_bind(kb, ch, _digit)
|
||||
|
||||
def _backspace(event):
|
||||
buf = event.current_buffer
|
||||
buf.delete_before_cursor()
|
||||
session.byte_bases = reconcile_bases(buf.text, session.byte_bases)
|
||||
|
||||
for key in ("backspace", "c-h"):
|
||||
_bind(kb, key, _backspace)
|
||||
314
pm3py/cli/rawcli/identify.py
Normal file
314
pm3py/cli/rawcli/identify.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Tag identification for rawcli.
|
||||
|
||||
``identify`` doesn't just select — it *probes*, and every raw exchange it uses to figure the tag
|
||||
out is streamed to the trace (reconstructed 14a activation + a real GET_VERSION). The GET_VERSION
|
||||
response is reverse-mapped against the transponder models' own ``_version_bytes`` to name the
|
||||
exact chip (NTAG216, not "MIFARE UL/NTAG"); the version's storage-size byte is what separates
|
||||
NTAG213/215/216 and the Ultralight EV1 variants. The 14a scan uses NO_DISCONNECT, so the tag stays
|
||||
selected for the follow-up probes — the persistent connection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from pm3py.trace import TraceFormatter, decode_15693
|
||||
|
||||
ISO14A_APPEND_CRC = 0x20 # pm3_cmd.h: append CRC to a raw 14a frame
|
||||
|
||||
# SAK → 14a family for tags without GET_VERSION (NXP AN10833 "MIFARE type identification
|
||||
# procedure", Table 4). Used as the fallback when GET_VERSION isn't available/applicable.
|
||||
_SAK_NAMES = {
|
||||
0x00: "MIFARE Ultralight / NTAG", # -> GET_VERSION for the exact IC
|
||||
0x04: "UID incomplete (cascade)",
|
||||
0x08: "MIFARE Classic 1K",
|
||||
0x09: "MIFARE Mini",
|
||||
0x10: "MIFARE Plus 2K (SL2)",
|
||||
0x11: "MIFARE Plus 4K (SL2)",
|
||||
0x18: "MIFARE Classic 4K",
|
||||
0x20: "ISO14443-4 (DESFire / Plus SL3 / …)",
|
||||
0x28: "MIFARE Classic 1K + NFC-P2P",
|
||||
0x38: "MIFARE Classic 4K + NFC-P2P",
|
||||
}
|
||||
|
||||
# GET_VERSION byte-2 lower nibble → product family (AN10833 Table 1). Upper nibble is the HW
|
||||
# type (0x0 native, 0x8 implementation, 0x9 Java Card applet, 0xA MIFARE 2GO / DUOX / X DNA).
|
||||
_GETVER_PRODUCT = {
|
||||
0x1: "MIFARE DESFire", 0x2: "MIFARE Plus", 0x3: "MIFARE Ultralight",
|
||||
0x4: "NTAG", 0x7: "NTAG I2C", 0x8: "MIFARE DESFire Light",
|
||||
}
|
||||
|
||||
# GET_VERSION 8-byte response → exact model. Mirrors the transponder models' `_version_bytes`
|
||||
# (kept static here to avoid an import-order circular; a test asserts they stay in sync).
|
||||
_VERSION_MODELS = {
|
||||
"0004040101000b03": "NTAG210",
|
||||
"0004040101000e03": "NTAG212",
|
||||
"0004040201000f03": "NTAG213",
|
||||
"0004040201001103": "NTAG215",
|
||||
"0004040201001303": "NTAG216",
|
||||
"0004030101000b03": "MIFARE Ultralight EV1 (MF0UL11)",
|
||||
"0004030101000e03": "MIFARE Ultralight EV1 (MF0UL21)",
|
||||
"0004040502021303": "NTAG I2C plus 1K (NT3H2111)",
|
||||
"0004040502021503": "NTAG I2C plus 2K (NT3H2211)",
|
||||
}
|
||||
|
||||
|
||||
def _storage_range(byte: int) -> str:
|
||||
"""GET_VERSION storage-size byte → memory-size string. The 7 MSBs code 2^n; the LSB set
|
||||
means the size is *between* 2^n and 2^(n+1) (AN10833)."""
|
||||
lo = 1 << (byte >> 1)
|
||||
return f"{lo}–{lo * 2} B" if byte & 1 else f"{lo} B"
|
||||
|
||||
|
||||
def decode_version(version: bytes) -> str | None:
|
||||
"""Name the chip from an 8-byte GET_VERSION response: exact match against the known models,
|
||||
else a structural best-effort (product family + memory-size range, AN10833)."""
|
||||
exact = _VERSION_MODELS.get(bytes(version[:8]).hex())
|
||||
if exact:
|
||||
return exact
|
||||
if len(version) >= 7 and version[1] == 0x04: # NXP vendor
|
||||
family = _GETVER_PRODUCT.get(version[2] & 0x0F, f"NXP type {version[2]:#04x}")
|
||||
return f"{family} ({_storage_range(version[6])})"
|
||||
return None
|
||||
|
||||
|
||||
def name_14a(scan: dict) -> str:
|
||||
sak = scan.get("sak")
|
||||
if isinstance(sak, int):
|
||||
return _SAK_NAMES.get(sak, f"ISO14443-A (SAK {sak:02X})")
|
||||
return "ISO14443-A"
|
||||
|
||||
|
||||
def _iso15_uid(dev) -> str | None:
|
||||
"""Get a 15693 tag's UID robustly. The anticollision inventory misses often on vicinity tags,
|
||||
and this link also returns the odd garbage frame — so retry both the inventory and a direct
|
||||
GET_SYSTEM_INFO (0x2B, which carries the UID), validating each response. Returns display-order
|
||||
UID hex, or None."""
|
||||
for _ in range(3):
|
||||
scan = _try(lambda: dev.hf.iso15.scan(), {"found": False})
|
||||
uid = scan.get("uid")
|
||||
if scan.get("found") and isinstance(uid, str) and uid.lower().startswith("e0"):
|
||||
return uid
|
||||
for _ in range(4): # GET_SYSTEM_INFO: [flags][info][UID(8)]..
|
||||
resp = _try(lambda: dev.hf.iso15.raw(b"\x02\x2b"), None)
|
||||
raw = resp.get("raw") if isinstance(resp, dict) else None
|
||||
if raw and len(raw) >= 10 and raw[0] == 0x00 and raw[9] == 0xE0: # UID LSB-first ends in E0
|
||||
return bytes(raw[2:10])[::-1].hex()
|
||||
return None
|
||||
|
||||
|
||||
def name_iso15(dev, uid15: str) -> str:
|
||||
"""Name a 15693 tag from its UID + probes. ``E0 04`` = NXP ICODE. NTAG5 shares ``E0 04 01`` with
|
||||
ICODE DNA, so it is told apart by a functional probe (see below); SLIX2 is distinguished from
|
||||
plain SLIX by READ_SIGNATURE (0xBD). Non-NXP falls back to generic ISO15693."""
|
||||
if not uid15.lower().startswith("e004"):
|
||||
return "ISO15693"
|
||||
# NTAG5 / DNA / SLIX2 all share E0 04 and answer READ_SIGNATURE, so split them with READ_CONFIG
|
||||
# (0xC0), addressed + retried (these tags are finicky). Session register 0xA0 (STATUS_REG)
|
||||
# succeeds only on NTAG5 (its I2C-side registers). DNA has no session registers (0xA0 errors)
|
||||
# but does have config memory (block 0x00 succeeds); SLIX2 has no READ_CONFIG command at all.
|
||||
# Hardware-verified: NTAG5 (E0 04 01 58) 0xA0 -> 00 01400000; DNA (E0 04 01 18) 0xA0 -> 01 0F,
|
||||
# 0x00 -> 00 ...
|
||||
try:
|
||||
uid_wire = bytes.fromhex(uid15)[::-1] # UID LSByte-first for addressing
|
||||
|
||||
def _read_config(block: int):
|
||||
frame = bytes([0x22, 0xC0, 0x04]) + uid_wire + bytes([block, 0x00])
|
||||
for _ in range(6):
|
||||
r = _try(lambda: dev.hf.iso15.raw(frame), None)
|
||||
raw = r.get("raw") if isinstance(r, dict) else None
|
||||
if raw and raw[0] in (0x00, 0x01): # a real ok/error response (not a miss)
|
||||
return raw
|
||||
return None
|
||||
|
||||
a0 = _read_config(0xA0)
|
||||
if a0 and a0[0] == 0x00 and len(a0) >= 5: # valid STATUS_REG -> NTAG5
|
||||
return "NTAG 5"
|
||||
if a0 and a0[0] == 0x01: # 0xA0 errored -> not NTAG5; DNA has
|
||||
cfg0 = _read_config(0x00) # config memory, SLIX2 has no 0xC0
|
||||
if cfg0 and cfg0[0] == 0x00 and len(cfg0) >= 5:
|
||||
return "ICODE DNA"
|
||||
except ValueError:
|
||||
pass
|
||||
# READ_SIGNATURE (0xBD): SLIX2 returns a 32-byte ECC signature (response >= 33 bytes with the
|
||||
# flags byte); plain SLIX ignores the command. Require the full length so a flaky/stale frame
|
||||
# can't false-positive; probe twice in case the real signature frame is dropped.
|
||||
for _ in range(2):
|
||||
sig = _try(lambda: dev.hf.iso15.raw(b"\x02\xbd\x04"), None)
|
||||
raw = sig.get("raw") if isinstance(sig, dict) else None
|
||||
if raw is not None and len(raw) >= 33 and raw[0] == 0x00:
|
||||
return "ICODE SLIX2"
|
||||
return "ICODE SLIX"
|
||||
|
||||
|
||||
def _ident_decode(direction: int, payload: bytes) -> str | None:
|
||||
"""Annotator for the identify probe frames (activation + GET_VERSION)."""
|
||||
if not payload:
|
||||
return None
|
||||
if direction == 0: # reader → tag
|
||||
head = payload[:2]
|
||||
if payload[:1] == b"\x52":
|
||||
return "WUPA"
|
||||
if payload[:1] == b"\x26":
|
||||
return "REQA"
|
||||
if payload[0] in (0x93, 0x95):
|
||||
cl = 1 if payload[0] == 0x93 else 2
|
||||
if len(payload) >= 2 and payload[1] == 0x20:
|
||||
return f"ANTICOLLISION CL{cl}"
|
||||
if len(payload) >= 2 and payload[1] == 0x70:
|
||||
return f"SELECT CL{cl}"
|
||||
if payload[0] == 0x60:
|
||||
return "GET_VERSION"
|
||||
if payload[0] == 0x50:
|
||||
return "HALT"
|
||||
else: # tag → reader
|
||||
if len(payload) >= 8 and payload[0] == 0x00 and payload[1] == 0x04:
|
||||
return "VERSION"
|
||||
return None
|
||||
|
||||
|
||||
def _activation_frames(scan: dict) -> list[tuple[int, bytes]]:
|
||||
"""Reconstruct the ISO 14443-3A activation (WUPA/ATQA/anticollision/SELECT/SAK) from the scan
|
||||
result, so the user sees the select the reader performed."""
|
||||
try:
|
||||
uid = bytes.fromhex(scan.get("uid") or "")
|
||||
atqa = bytes.fromhex(scan.get("atqa") or "")
|
||||
except ValueError:
|
||||
return []
|
||||
sak = scan.get("sak", 0)
|
||||
frames: list[tuple[int, bytes]] = [(0, b"\x52"), (1, atqa)]
|
||||
if len(uid) == 4:
|
||||
bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
|
||||
frames += [(0, b"\x93\x20"), (1, uid + bytes([bcc])),
|
||||
(0, b"\x93\x70" + uid + bytes([bcc])), (1, bytes([sak]))]
|
||||
elif len(uid) == 7:
|
||||
ct = 0x88
|
||||
b1 = ct ^ uid[0] ^ uid[1] ^ uid[2]
|
||||
b2 = uid[3] ^ uid[4] ^ uid[5] ^ uid[6]
|
||||
frames += [(0, b"\x93\x20"), (1, bytes([ct]) + uid[0:3] + bytes([b1])),
|
||||
(0, b"\x93\x70" + bytes([ct]) + uid[0:3] + bytes([b1])), (1, b"\x04"),
|
||||
(0, b"\x95\x20"), (1, uid[3:7] + bytes([b2])),
|
||||
(0, b"\x95\x70" + uid[3:7] + bytes([b2])), (1, bytes([sak]))]
|
||||
return frames
|
||||
|
||||
|
||||
def _try(fn, default):
|
||||
try:
|
||||
return fn()
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def identify(session, emit=None) -> dict:
|
||||
"""Probe the field and update ``session`` (field / protocol / transponder). ``emit`` receives
|
||||
each annotated trace line (default: no trace). Returns a summary dict."""
|
||||
emit = emit or (lambda _line: None)
|
||||
dev = session.device
|
||||
# clear the previous result up front — a new identify must never show stale field/transponder
|
||||
session.field = None
|
||||
session.transponder = None
|
||||
session.protocol = "hf14a"
|
||||
if dev is None:
|
||||
return {"found": False, "error": "no device connected"}
|
||||
|
||||
# ISO 14443-A — retry: the scan can miss before it selects on a flaky link, and a miss must
|
||||
# not fall through to a bogus 15693/LF hit.
|
||||
scan = {"found": False}
|
||||
for _ in range(3):
|
||||
scan = _try(lambda: dev.hf.iso14a.scan(), {"found": False})
|
||||
if scan.get("found"):
|
||||
break
|
||||
if scan.get("found"):
|
||||
session.field, session.protocol = "hf", "hf14a"
|
||||
# always emit ANSI; the app's printer renders it (and strips it off a non-terminal)
|
||||
fmt = TraceFormatter(mode="reader", decoder=_ident_decode, crc_len=0, is_tty=True)
|
||||
for direction, data in _activation_frames(scan):
|
||||
emit(fmt.format(direction, data).strip("\n"))
|
||||
model, version = None, None
|
||||
if scan.get("sak") == 0x00: # UL/NTAG family — ask GET_VERSION
|
||||
model, version = _probe_version(dev, fmt, emit)
|
||||
session.transponder = model or name_14a(scan)
|
||||
return {"found": True, "field": "hf", "protocol": "14a",
|
||||
"transponder": session.transponder, "uid": scan.get("uid"),
|
||||
"version": version.hex() if version else None}
|
||||
|
||||
# ISO 15693 — require a real E0-prefixed UID (inventory, or a direct GET_SYSTEM_INFO fallback
|
||||
# for flaky vicinity anticollision), else a 14a miss reads as a bogus tag.
|
||||
uid15 = _iso15_uid(dev)
|
||||
if isinstance(uid15, str) and uid15.lower().startswith("e0"):
|
||||
session.field, session.protocol = "hf", "hf15"
|
||||
session.transponder = name_iso15(dev, uid15)
|
||||
return {"found": True, "field": "hf", "protocol": "15693",
|
||||
"transponder": session.transponder, "uid": uid15}
|
||||
|
||||
# LF: no HF tag, so demodulate the field. pm3py.lf captures the envelope and decodes it —
|
||||
# what the tag emits (EM4100 ID, …) and, via a block-0 read, whether it's actually a T5577
|
||||
# and what it's programmed to emulate. This is the real DSP path; the firmware only hands
|
||||
# back raw samples, so there's no shortcut (lf.search() can't demodulate).
|
||||
lf = _identify_lf(dev, session, emit)
|
||||
if lf:
|
||||
return lf
|
||||
|
||||
return {"found": False}
|
||||
|
||||
|
||||
def _identify_lf(dev, session, emit):
|
||||
"""Adapter onto :func:`pm3py.lf.identify_lf` — run the LF demod and fold its result into the
|
||||
session + the rawcli result shape. ``label`` is the breadcrumb name (``T5577 (EM4100 /
|
||||
EM4102)`` for an emulator, ``EM4100 <id>`` for a bare credential)."""
|
||||
from pm3py.lf import identify_lf as _lf_identify
|
||||
lf = _try(lambda: _lf_identify(dev, emit), None)
|
||||
if not lf:
|
||||
return None
|
||||
session.field, session.protocol = "lf", "lf"
|
||||
session.transponder = lf["label"]
|
||||
return {"found": True, "field": "lf", "protocol": "lf",
|
||||
"transponder": lf["label"], "config": lf.get("config"),
|
||||
"id": (lf.get("emitted") or {}).get("id_hex")}
|
||||
|
||||
|
||||
def _probe_version(dev, fmt, emit) -> tuple[str | None, bytes | None]:
|
||||
"""Send a real GET_VERSION (0x60 +CRC), trace the exchange, decode the exact model. Retried:
|
||||
a single shot can be lost on a flaky NG link, and a miss drops us to the generic SAK name —
|
||||
which loses the exact-model memory map (page roles, completion). Re-selects between attempts to
|
||||
recover a desynced link."""
|
||||
cmd = b"\x60"
|
||||
emit(fmt.format(0, cmd).strip("\n"))
|
||||
for attempt in range(3):
|
||||
resp = _try(lambda: dev.hf.iso14a.raw(cmd, flags=ISO14A_APPEND_CRC), None)
|
||||
raw = resp.get("raw") if isinstance(resp, dict) else None
|
||||
if raw and len(raw) >= 8:
|
||||
version = bytes(raw[:8])
|
||||
model = decode_version(version)
|
||||
if model:
|
||||
emit(fmt.format(1, bytes(raw)).strip("\n")) # raw() already trims to the RX length
|
||||
emit(f" → {model}")
|
||||
return model, version
|
||||
if attempt < 2:
|
||||
_try(lambda: dev.hf.iso14a.scan(), None) # re-select, then retry the version
|
||||
return None, None
|
||||
|
||||
|
||||
def clear(session) -> None:
|
||||
"""Forget the identified tag (drop the logical connection); the device stays open."""
|
||||
session.field = None
|
||||
session.transponder = None
|
||||
session.protocol = "hf14a"
|
||||
dev = session.device
|
||||
if dev is not None:
|
||||
_try(lambda: dev.hf.dropfield(), None)
|
||||
|
||||
|
||||
def format_summary(result: dict) -> str:
|
||||
if not result.get("found"):
|
||||
return f"no tag found{': ' + result['error'] if result.get('error') else ''}"
|
||||
if result.get("uid"):
|
||||
uid = result["uid"]
|
||||
detail = f"UID {uid.hex().upper() if isinstance(uid, (bytes, bytearray)) else uid}"
|
||||
elif result.get("config"):
|
||||
detail = f"config 0x{result['config']}"
|
||||
elif result.get("id"):
|
||||
detail = f"id {result['id']}"
|
||||
else:
|
||||
detail = ""
|
||||
head = f"{result['transponder']} ({result['field'].upper()} / {result['protocol']})"
|
||||
return f"{head} {detail}".rstrip()
|
||||
201
pm3py/cli/rawcli/memory.py
Normal file
201
pm3py/cli/rawcli/memory.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Per-transponder memory maps.
|
||||
|
||||
The point of rawcli isn't just to send bytes — it's to tell you what they *mean* on the tag in
|
||||
front of you. ``page_role`` maps a page/block number to its role on the identified transponder
|
||||
(user memory, CC, AUTH0, PWD, lock bytes, …), and ``landmark_pages`` lists the notable pages so the
|
||||
completion menu can surface the memory map as you type. All of this shows up in the completion
|
||||
dropdown — never in the bottom bar or command output.
|
||||
|
||||
Layouts mirror the transponder models' page attributes (``_cfg0_page`` etc.); kept static to
|
||||
avoid an import-order circular, with a test guarding against drift.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Page layout per NTAG21x / Ultralight EV1 IC (user range inclusive; keys are identify's names).
|
||||
# The static Type-2 header (pages 0-3) and PWD/PACK/config structure are shared; the family only
|
||||
# differs in page 3 (CC on NTAG, OTP on Ultralight), the ASCII mirror (NTAG only), and the CFG1
|
||||
# NFC-counter bits (NTAG 213/215/216 only). "lock" is the *dynamic* lock page (absent on the
|
||||
# smallest parts); "cnt" flags the CFG1 NFC-counter bits.
|
||||
_LAYOUTS = {
|
||||
"NTAG210": {"user": (0x04, 0x0F), "cfg0": 0x10, "cfg1": 0x11, "pwd": 0x12, "pack": 0x13, "fam": "ntag"},
|
||||
"NTAG212": {"user": (0x04, 0x23), "lock": 0x24, "cfg0": 0x25, "cfg1": 0x26, "pwd": 0x27, "pack": 0x28, "fam": "ntag"},
|
||||
"NTAG213": {"user": (0x04, 0x27), "lock": 0x28, "cfg0": 0x29, "cfg1": 0x2A, "pwd": 0x2B, "pack": 0x2C, "fam": "ntag", "cnt": True},
|
||||
"NTAG215": {"user": (0x04, 0x81), "lock": 0x82, "cfg0": 0x83, "cfg1": 0x84, "pwd": 0x85, "pack": 0x86, "fam": "ntag", "cnt": True},
|
||||
"NTAG216": {"user": (0x04, 0xE1), "lock": 0xE2, "cfg0": 0xE3, "cfg1": 0xE4, "pwd": 0xE5, "pack": 0xE6, "fam": "ntag", "cnt": True},
|
||||
"MIFARE Ultralight EV1 (MF0UL11)": {"user": (0x04, 0x0F), "cfg0": 0x10, "cfg1": 0x11, "pwd": 0x12, "pack": 0x13, "fam": "ul"},
|
||||
"MIFARE Ultralight EV1 (MF0UL21)": {"user": (0x04, 0x23), "lock": 0x24, "cfg0": 0x25, "cfg1": 0x26, "pwd": 0x27, "pack": 0x28, "fam": "ul"},
|
||||
}
|
||||
|
||||
# The pages guaranteed on every Type 2 tag (down to the smallest, NTAG210): the static header and
|
||||
# user memory 0x04-0x0F. Used when only the NTAG/Ultralight *family* is known — GET_VERSION couldn't
|
||||
# complete on a flaky link, or an original Ultralight has no GET_VERSION. Config pages differ by
|
||||
# model, so they're left unnamed rather than guessed; page 3 could be CC or OTP.
|
||||
_GENERIC_TYPE2 = {"user": (0x04, 0x0F)}
|
||||
|
||||
|
||||
def _resolve_layout(transponder: str | None) -> dict | None:
|
||||
"""The page layout for ``transponder`` — the exact per-IC map when identify named the model,
|
||||
else the generic Type 2 map when only the NTAG / Ultralight family is known, else None."""
|
||||
if not transponder:
|
||||
return None
|
||||
layout = _LAYOUTS.get(transponder)
|
||||
if layout is not None:
|
||||
return layout
|
||||
up = transponder.upper()
|
||||
if "NTAG" in up or "ULTRALIGHT" in up:
|
||||
return _GENERIC_TYPE2
|
||||
return None
|
||||
|
||||
|
||||
def _page3_role(layout: dict) -> str:
|
||||
fam = layout.get("fam")
|
||||
if fam == "ul":
|
||||
return "OTP (one-time programmable)"
|
||||
if fam == "ntag":
|
||||
return "Capability Container (CC)"
|
||||
return "Capability Container (CC) / OTP" # generic: family unknown
|
||||
|
||||
|
||||
def _cfg0_role(layout: dict) -> str:
|
||||
# CFG0: AUTH0 (first protected page) + strong-modulation; NTAG adds the ASCII mirror
|
||||
if layout.get("fam") == "ntag":
|
||||
return "CFG0 — AUTH0 (first protected page), MIRROR (mode/page/byte), STRG_MOD_EN"
|
||||
return "CFG0 — AUTH0 (first protected page), STRG_MOD_EN"
|
||||
|
||||
|
||||
def _cfg1_role(layout: dict) -> str:
|
||||
# CFG1 ACCESS byte: read/write protection, config lock, auth-retry limit; NTAG counters add
|
||||
# the NFC-counter enable/protect bits
|
||||
bits = "PROT, CFGLCK, " + ("NFC_CNT_EN, NFC_CNT_PWD_PROT, " if layout.get("cnt") else "") + "AUTHLIM"
|
||||
return f"CFG1 — ACCESS ({bits})"
|
||||
|
||||
|
||||
# ---- MIFARE Classic block map --------------------------------------------------------------
|
||||
# 1K/Mini: 4-block sectors throughout. 4K: sectors 0-31 are 4 blocks, sectors 32-39 are 16 blocks.
|
||||
# The sector trailer (last block of each sector) holds Key A / access bits+GPB / Key B; block 0 is
|
||||
# the manufacturer block. Everything else is a data block.
|
||||
_MFC_MAX_BLOCK = {"mini": 19, "1k": 63, "4k": 255}
|
||||
|
||||
|
||||
def _mfc_size(transponder: str | None) -> str | None:
|
||||
"""'mini' / '1k' / '4k' for a MIFARE Classic name, else None."""
|
||||
if not transponder:
|
||||
return None
|
||||
up = transponder.upper()
|
||||
if "MINI" in up and "MIFARE" in up:
|
||||
return "mini"
|
||||
if "CLASSIC" in up:
|
||||
return "4k" if "4K" in up else "1k"
|
||||
return None
|
||||
|
||||
|
||||
def _mfc_sector(size: str, block: int) -> tuple[int, bool]:
|
||||
"""(sector number, is-trailer) for ``block`` on a Classic of ``size``."""
|
||||
if size == "4k" and block >= 128: # the eight 16-block sectors 32-39
|
||||
sector = 32 + (block - 128) // 16
|
||||
return sector, (block - 128) % 16 == 15
|
||||
sector = block // 4
|
||||
return sector, block % 4 == 3
|
||||
|
||||
|
||||
def _mfc_block_role(transponder: str | None, block: int | None) -> str | None:
|
||||
size = _mfc_size(transponder)
|
||||
if size is None or block is None or not (0 <= block <= _MFC_MAX_BLOCK[size]):
|
||||
return None
|
||||
if block == 0:
|
||||
return "manufacturer block — UID, BCC, SAK, ATQA, vendor data"
|
||||
sector, is_trailer = _mfc_sector(size, block)
|
||||
if is_trailer:
|
||||
return f"sector {sector} trailer — Key A [0-5], access bits [6-8] + GPB [9], Key B [10-15]"
|
||||
return f"data block (sector {sector})"
|
||||
|
||||
|
||||
def _mfc_landmarks(transponder: str | None) -> list[tuple[int, str]]:
|
||||
"""Block 0 + every sector trailer — the Classic memory-map skeleton (data blocks are uniform)."""
|
||||
size = _mfc_size(transponder)
|
||||
if size is None:
|
||||
return []
|
||||
blocks = [0] + [b for b in range(1, _MFC_MAX_BLOCK[size] + 1)
|
||||
if _mfc_sector(size, b)[1]]
|
||||
return [(b, _mfc_block_role(transponder, b)) for b in blocks]
|
||||
|
||||
|
||||
def _t5577_block_role(block: int | None) -> str | None:
|
||||
"""Role of a T5577 block: 0 = config, 7 = password, 1-6 = data (the emulated tag content)."""
|
||||
if block is None:
|
||||
return None
|
||||
if block == 0:
|
||||
return "config block (modulation / bit-rate / block count)"
|
||||
if block == 7:
|
||||
return "password block (32-bit)"
|
||||
if 1 <= block <= 6:
|
||||
return "data block (emulated tag content)"
|
||||
return None
|
||||
|
||||
|
||||
def _is_iso15(transponder: str | None) -> bool:
|
||||
up = (transponder or "").upper()
|
||||
return "SLIX" in up or "ICODE" in up
|
||||
|
||||
|
||||
def _iso15_block_role(block: int | None) -> str | None:
|
||||
"""ISO 15693 / ICODE blocks are uniform 4-byte user-memory blocks; AFI / DSFID / EAS / config
|
||||
live behind commands, not block addresses."""
|
||||
if block is None or not (0 <= block <= 255):
|
||||
return None
|
||||
return "user memory (4-byte block)"
|
||||
|
||||
|
||||
def page_role(transponder: str | None, page: int | None) -> str | None:
|
||||
"""The role of ``page``/``block`` on ``transponder`` — the full Type 2 header (UID / static
|
||||
lock / CC-or-OTP), user memory, dynamic lock, CFG0/CFG1 (family-accurate fields), PWD, PACK —
|
||||
or a T5577 block role, or an ISO15693 block, or None when unknown."""
|
||||
if transponder and "T5577" in transponder.upper():
|
||||
return _t5577_block_role(page)
|
||||
if _mfc_size(transponder):
|
||||
return _mfc_block_role(transponder, page)
|
||||
if _is_iso15(transponder):
|
||||
return _iso15_block_role(page)
|
||||
layout = _resolve_layout(transponder)
|
||||
if layout is None or page is None:
|
||||
return None
|
||||
if 0 <= page <= 1:
|
||||
return "UID / serial number"
|
||||
if page == 2:
|
||||
return "static lock bytes + internal (locks pages 03-0F)"
|
||||
if page == 3:
|
||||
return _page3_role(layout)
|
||||
lo, hi = layout["user"]
|
||||
if lo <= page <= hi:
|
||||
return "user memory"
|
||||
if page == layout.get("lock"):
|
||||
return "dynamic lock bytes"
|
||||
if page == layout.get("cfg0"):
|
||||
return _cfg0_role(layout)
|
||||
if page == layout.get("cfg1"):
|
||||
return _cfg1_role(layout)
|
||||
if page == layout.get("pwd"):
|
||||
return "PWD (32-bit password)"
|
||||
if page == layout.get("pack"):
|
||||
return "PACK (password acknowledge) + RFUI"
|
||||
return None
|
||||
|
||||
|
||||
def landmark_pages(transponder: str | None) -> list[tuple[int, str]]:
|
||||
"""Notable pages/blocks with their datasheet role, for the completion menu's memory map. Every
|
||||
named region of the identified tag — UID, static lock, CC/OTP, user, dynamic lock, CFG0/CFG1,
|
||||
PWD, PACK. Ordered by page; empty when the tag has no known layout."""
|
||||
if transponder and "T5577" in transponder.upper():
|
||||
return [(b, _t5577_block_role(b)) for b in range(8)]
|
||||
if _mfc_size(transponder):
|
||||
return _mfc_landmarks(transponder)
|
||||
if _is_iso15(transponder):
|
||||
return [(0, "user memory (4-byte block)")] # uniform blocks; AFI/DSFID/EAS via commands
|
||||
layout = _resolve_layout(transponder)
|
||||
if layout is None:
|
||||
return []
|
||||
pages = {0, 2, 3, layout["user"][0]}
|
||||
for key in ("lock", "cfg0", "cfg1", "pwd", "pack"):
|
||||
if key in layout:
|
||||
pages.add(layout[key])
|
||||
return [(p, page_role(transponder, p)) for p in sorted(pages) if page_role(transponder, p)]
|
||||
99
pm3py/cli/rawcli/parser.py
Normal file
99
pm3py/cli/rawcli/parser.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""rawcli line parsing.
|
||||
|
||||
Classifies an input line into a **control** command (help/identify/…), a **function-style call**
|
||||
(``READ(4)``), or a **raw** byte payload, and parses loose hex/binary — intermixed ``0x``/``0b``
|
||||
tokens, whitespace-insensitive, defaulting to the session's current entry mode when a token has no
|
||||
prefix.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
HEX = "hex"
|
||||
BIN = "bin"
|
||||
|
||||
#: single-word control verbs the REPL handles directly
|
||||
CONTROL_VERBS = {"help", "identify", "transponder", "close", "tlv", "quit", "exit", "q"}
|
||||
|
||||
_CALL_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)\s*$", re.DOTALL)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Command:
|
||||
"""A parsed rawcli line. ``kind`` is ``"control"``, ``"call"`` or ``"raw"``."""
|
||||
kind: str
|
||||
name: str = ""
|
||||
args: list[str] = field(default_factory=list)
|
||||
payload: bytes = b""
|
||||
|
||||
|
||||
def parse_arg_int(s: str) -> int:
|
||||
"""A function-style command's numeric argument (``READ(4)``, ``WRITE(40, …)``): **base 10 by
|
||||
default**, with a ``0x`` / ``0b`` / ``0o`` prefix selecting the base. This is the opposite
|
||||
default from raw byte entry (which is hex) — a page number reads naturally as decimal, so
|
||||
``READ(40)`` is page 40 and ``READ(0x28)`` is the same page in hex."""
|
||||
s = s.strip()
|
||||
if s[:2].lower() in ("0x", "0b", "0o"):
|
||||
return int(s, 0)
|
||||
return int(s, 10)
|
||||
|
||||
|
||||
def parse_token(token: str, default_mode: str = HEX) -> bytes:
|
||||
"""Parse one hex/binary token to bytes. A ``0x``/``0b`` prefix picks the base; otherwise
|
||||
``default_mode`` is used."""
|
||||
text = token
|
||||
mode = default_mode
|
||||
low = text.lower()
|
||||
if low.startswith("0x"):
|
||||
mode, text = HEX, text[2:]
|
||||
elif low.startswith("0b"):
|
||||
mode, text = BIN, text[2:]
|
||||
if text == "":
|
||||
return b""
|
||||
if mode == HEX:
|
||||
if len(text) % 2:
|
||||
raise ValueError(f"hex token {token!r} has an odd number of digits")
|
||||
try:
|
||||
return bytes.fromhex(text)
|
||||
except ValueError:
|
||||
raise ValueError(f"invalid hex token {token!r}") from None
|
||||
# binary
|
||||
if len(text) % 8:
|
||||
raise ValueError(f"binary token {token!r} is not a whole number of bytes "
|
||||
f"({len(text)} bits)")
|
||||
try:
|
||||
return bytes(int(text[i:i + 8], 2) for i in range(0, len(text), 8))
|
||||
except ValueError:
|
||||
raise ValueError(f"invalid binary token {token!r}") from None
|
||||
|
||||
|
||||
def parse_bytes(text: str, default_mode: str = HEX, bases: list[str] | None = None) -> bytes:
|
||||
"""Parse a whitespace-separated raw payload. Each token is parsed in its own base: an explicit
|
||||
``0x``/``0b`` prefix wins, else ``bases[i]`` (the base that token was typed in, when known),
|
||||
else ``default_mode``. A token that isn't a valid byte raises ``ValueError`` — so a line that
|
||||
mixes a command with raw bytes (``READ 30``) errors here instead of transmitting."""
|
||||
out = bytearray()
|
||||
for i, tok in enumerate(text.split()):
|
||||
mode = bases[i] if bases and i < len(bases) else default_mode
|
||||
out += parse_token(tok, mode)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _split_args(inside: str) -> list[str]:
|
||||
return [a.strip() for a in inside.split(",")] if inside.strip() else []
|
||||
|
||||
|
||||
def parse_line(line: str, default_mode: str = HEX, bases: list[str] | None = None) -> Command:
|
||||
"""Classify and parse a full input line. ``bases`` gives the per-byte base for a raw line
|
||||
(from the live entry tracker); a raw line that isn't pure bytes raises ``ValueError``."""
|
||||
stripped = line.strip()
|
||||
m = _CALL_RE.match(stripped)
|
||||
if m:
|
||||
return Command("call", name=m.group(1), args=_split_args(m.group(2)))
|
||||
first = stripped.split(None, 1)
|
||||
verb = first[0].lower() if first else ""
|
||||
if verb in CONTROL_VERBS:
|
||||
rest = first[1].split() if len(first) > 1 else []
|
||||
return Command("control", name=verb, args=rest)
|
||||
return Command("raw", payload=parse_bytes(stripped, default_mode, bases))
|
||||
69
pm3py/cli/rawcli/session.py
Normal file
69
pm3py/cli/rawcli/session.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""rawcli session state — the connected device, the identified RF field and transponder, and
|
||||
the current numeric entry mode. Kept deliberately small and free of any prompt_toolkit / device
|
||||
detail so it stays unit-testable."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
HEX = "hex"
|
||||
BIN = "bin"
|
||||
|
||||
# ANSI palette aligned with pm3py/trace/format.py so the prompt reads as part of the same UI
|
||||
_DIM = "\033[2m"
|
||||
_CYAN = "\033[36m"
|
||||
_YELLOW = "\033[33m"
|
||||
_MAGENTA = "\033[35m"
|
||||
_BGREEN = "\033[1;32m"
|
||||
_BOLD = "\033[1m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
|
||||
class RawSession:
|
||||
"""Mutable rawcli session state.
|
||||
|
||||
``device`` is the connected client (or ``None`` when offline). ``field`` is the identified RF
|
||||
field (``"hf"``/``"lf"``) and ``transponder`` the identified tag's display name — both set by
|
||||
``identify`` in a later phase. ``entry_mode`` is the numeric entry mode toggled by the user.
|
||||
"""
|
||||
|
||||
def __init__(self, device: Any = None):
|
||||
self.device = device
|
||||
self.field: str | None = None
|
||||
self.transponder: str | None = None
|
||||
self.protocol: str = "hf14a" # wire protocol for raw exchanges (set by identify)
|
||||
self.entry_mode: str = HEX
|
||||
self.byte_bases: list[str] = [] # base of each raw byte typed on the current line
|
||||
self.connected: bool = device is not None
|
||||
|
||||
@property
|
||||
def entry_prefix(self) -> str:
|
||||
return "0x" if self.entry_mode == HEX else "0b"
|
||||
|
||||
def toggle_entry_mode(self) -> str:
|
||||
self.entry_mode = BIN if self.entry_mode == HEX else HEX
|
||||
return self.entry_mode
|
||||
|
||||
def breadcrumb(self) -> str:
|
||||
"""The segmented prompt ``[raw / <field?> / <transponder?> / 0x|0b]`` — field and
|
||||
transponder segments appear only once identified."""
|
||||
segments = ["raw"]
|
||||
if self.field:
|
||||
segments.append(self.field)
|
||||
if self.transponder:
|
||||
segments.append(self.transponder)
|
||||
segments.append(self.entry_prefix)
|
||||
return "[" + " / ".join(segments) + "]"
|
||||
|
||||
def colored_breadcrumb(self) -> str:
|
||||
"""The breadcrumb with ANSI color (same palette as the trace): bold ``raw``, cyan field,
|
||||
bold-green identified tag, and the entry mode in yellow (hex) or magenta (binary) so the
|
||||
current base is obvious at a glance. Stripping the ANSI yields :meth:`breadcrumb`."""
|
||||
segments = [f"{_BOLD}raw{_RESET}"]
|
||||
if self.field:
|
||||
segments.append(f"{_CYAN}{self.field}{_RESET}")
|
||||
if self.transponder:
|
||||
segments.append(f"{_BGREEN}{self.transponder}{_RESET}")
|
||||
mode_color = _YELLOW if self.entry_mode == HEX else _MAGENTA
|
||||
segments.append(f"{mode_color}{self.entry_prefix}{_RESET}")
|
||||
sep = f"{_DIM} / {_RESET}"
|
||||
return f"{_DIM}[{_RESET}" + sep.join(segments) + f"{_DIM}]{_RESET}"
|
||||
99
pm3py/cli/rawcli/tlv.py
Normal file
99
pm3py/cli/rawcli/tlv.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""TLV / NDEF handling for rawcli: parse a Type-2 TLV block into a structured, indented
|
||||
multi-line breakdown, and provide a multi-line editor for composing long TLVs.
|
||||
|
||||
NDEF message values (tag 0x03) are annotated by reusing
|
||||
:func:`pm3py.trace.ndef.decode_ndef_annotation`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pm3py.trace.ndef import decode_ndef_annotation
|
||||
|
||||
_TLV_NAMES = {
|
||||
0x00: "NULL", 0x01: "LOCK CONTROL", 0x02: "MEMORY CONTROL",
|
||||
0x03: "NDEF MESSAGE", 0xFD: "PROPRIETARY", 0xFE: "TERMINATOR",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TLV:
|
||||
tag: int
|
||||
length: int
|
||||
value: bytes
|
||||
|
||||
|
||||
def parse_tlv(data: bytes) -> list[TLV]:
|
||||
"""Walk a Type-2 TLV block (NULL/LOCK/MEMORY/NDEF/PROPRIETARY/TERMINATOR), handling the
|
||||
3-byte 0xFF extended length. Stops at the terminator or when the data runs out."""
|
||||
out: list[TLV] = []
|
||||
i, n = 0, len(data)
|
||||
while i < n:
|
||||
tag = data[i]
|
||||
i += 1
|
||||
if tag == 0x00: # NULL — no length/value
|
||||
out.append(TLV(tag, 0, b""))
|
||||
continue
|
||||
if tag == 0xFE: # terminator
|
||||
out.append(TLV(tag, 0, b""))
|
||||
break
|
||||
if i >= n:
|
||||
break
|
||||
length = data[i]
|
||||
i += 1
|
||||
if length == 0xFF: # 3-byte length
|
||||
if i + 2 > n:
|
||||
break
|
||||
length = (data[i] << 8) | data[i + 1]
|
||||
i += 2
|
||||
value = data[i:i + length]
|
||||
i += length
|
||||
out.append(TLV(tag, length, value))
|
||||
return out
|
||||
|
||||
|
||||
def _ndef_tlv(value: bytes) -> bytes:
|
||||
if len(value) < 0xFF:
|
||||
return bytes([0x03, len(value)]) + value
|
||||
return bytes([0x03, 0xFF, (len(value) >> 8) & 0xFF, len(value) & 0xFF]) + value
|
||||
|
||||
|
||||
def _hex_rows(value: bytes, per_row: int = 16) -> list[str]:
|
||||
return [" ".join(f"{b:02X}" for b in value[i:i + per_row])
|
||||
for i in range(0, len(value), per_row)]
|
||||
|
||||
|
||||
def format_tlv(data: bytes) -> str:
|
||||
"""Structured, indented multi-line breakdown of a TLV block."""
|
||||
tlvs = parse_tlv(data)
|
||||
if not tlvs:
|
||||
return f"(no TLV structure in {len(data)} bytes)"
|
||||
lines = [f"TLV breakdown ({len(data)} bytes):"]
|
||||
for t in tlvs:
|
||||
name = _TLV_NAMES.get(t.tag, f"0x{t.tag:02X}")
|
||||
if t.tag in (0x00, 0xFE):
|
||||
lines.append(f" [{t.tag:02X}] {name}")
|
||||
continue
|
||||
lines.append(f" [{t.tag:02X}] {name} len={t.length}")
|
||||
for row in _hex_rows(t.value):
|
||||
lines.append(f" {row}")
|
||||
if t.tag == 0x03 and t.value:
|
||||
annotation = decode_ndef_annotation(_ndef_tlv(t.value))
|
||||
if annotation:
|
||||
lines.append(f" → {annotation}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def prompt_tlv(session) -> bytes | None:
|
||||
"""Open a multi-line editor to compose a TLV as hex, gracefully spanning lines. Returns the
|
||||
parsed bytes, or None if cancelled. Interactive — exercised in the live REPL."""
|
||||
from prompt_toolkit import prompt
|
||||
from .parser import parse_bytes
|
||||
try:
|
||||
text = prompt("tlv> ", multiline=True) # Esc+Enter (or Meta+Enter) to submit
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
return parse_bytes(text, session.entry_mode)
|
||||
31
pm3py/cli/rawcli/trace_view.py
Normal file
31
pm3py/cli/rawcli/trace_view.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Render a raw reader↔tag exchange as annotated, Proxmark-style trace lines, reusing the
|
||||
existing :class:`pm3py.trace.TraceFormatter` and the per-protocol decoders."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pm3py.trace import TraceFormatter, decode_14443a, decode_15693
|
||||
|
||||
#: protocol key -> (annotation decoder, CRC byte length)
|
||||
_PROTOCOLS = {
|
||||
"hf14a": (decode_14443a, 2),
|
||||
"hf15": (decode_15693, 2),
|
||||
"lf": (None, 0),
|
||||
}
|
||||
|
||||
|
||||
def _as_bytes(value) -> bytes:
|
||||
"""Accept bytes or a hex string (which is what the reader `raw()` returns as `data`)."""
|
||||
if isinstance(value, str):
|
||||
return bytes.fromhex(value.replace(" ", ""))
|
||||
return bytes(value)
|
||||
|
||||
|
||||
def render_exchange(command, response, *, protocol: str = "hf14a", is_tty: bool | None = None) -> str:
|
||||
"""Return the annotated trace for one exchange: a ``Reader → Tag`` line for ``command`` and,
|
||||
if present, a ``Tag → Reader`` line for ``response``. Both accept bytes or a hex string.
|
||||
``is_tty`` forces/suppresses ANSI."""
|
||||
decoder, crc_len = _PROTOCOLS.get(protocol, (None, 0))
|
||||
fmt = TraceFormatter(mode="reader", decoder=decoder, crc_len=crc_len, is_tty=is_tty)
|
||||
lines = [fmt.format(0, _as_bytes(command)).strip("\n")]
|
||||
if response:
|
||||
lines.append(fmt.format(1, _as_bytes(response)).strip("\n"))
|
||||
return "\n".join(lines)
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Main Proxmark3 client with nested API."""
|
||||
import asyncio
|
||||
import functools
|
||||
import glob
|
||||
import inspect
|
||||
import logging
|
||||
@@ -13,6 +14,9 @@ from .hf import HFCommands
|
||||
from .hf_iso14a import HF14ACommands
|
||||
from .hf_iso15 import HF15Commands
|
||||
from .hf_mfc import HFMFCommands
|
||||
from .hf_mfu import HFMFUCommands
|
||||
from .flash import Flasher
|
||||
from .._firmware import FIRMWARE_PIN, matches as _fw_matches
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,6 +29,8 @@ class FirmwareInfo:
|
||||
section_size: int = 0
|
||||
ng_protocol: bool = False
|
||||
compatible: bool = False
|
||||
expected_firmware: str = "" # the fork build this pm3py is pinned to
|
||||
matches_pin: bool = False # device firmware == the pinned build?
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@@ -70,7 +76,9 @@ class Proxmark3:
|
||||
self.hf = HFCommands(self._transport)
|
||||
self.hf.iso14a = HF14ACommands(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.flasher = Flasher(self._transport)
|
||||
self.firmware = FirmwareInfo()
|
||||
|
||||
async def connect(self) -> None:
|
||||
@@ -111,6 +119,10 @@ class Proxmark3:
|
||||
except PM3Error:
|
||||
fw.warnings.append("Could not read firmware version — device may be in bootloader mode")
|
||||
|
||||
# Step 2.5: Compare against the pinned fork firmware (for pm3flash / live-sim)
|
||||
fw.expected_firmware = FIRMWARE_PIN.tag or FIRMWARE_PIN.sha
|
||||
fw.matches_pin = _fw_matches(fw.version_string)
|
||||
|
||||
# Step 3: Assess compatibility
|
||||
fw.compatible = fw.ng_protocol and len(fw.warnings) == 0
|
||||
|
||||
@@ -155,19 +167,27 @@ class _SyncProxy:
|
||||
def __getattr__(self, name):
|
||||
attr = getattr(self._target, name)
|
||||
if isinstance(attr, (HWCommands, LFCommands, HFCommands,
|
||||
HF14ACommands, HF15Commands, HFMFCommands)):
|
||||
HF14ACommands, HF15Commands, HFMFCommands,
|
||||
HFMFUCommands)):
|
||||
return _SyncProxy(attr, self._loop)
|
||||
# Sub-modules attached dynamically (e.g. hf.iso14a, hf.mfc)
|
||||
if hasattr(attr, '_t'):
|
||||
return _SyncProxy(attr, self._loop)
|
||||
if callable(attr) and inspect.iscoroutinefunction(attr):
|
||||
# functools.wraps copies __doc__/__name__ AND sets __wrapped__, so
|
||||
# inspect.signature() and help() report the real params (block, uid=None)
|
||||
# instead of the wrapper's (*args, **kwargs).
|
||||
@functools.wraps(attr)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
return self._loop.run_until_complete(attr(*args, **kwargs))
|
||||
sync_wrapper.__name__ = name
|
||||
sync_wrapper.__doc__ = attr.__doc__
|
||||
return sync_wrapper
|
||||
return attr
|
||||
|
||||
def __dir__(self):
|
||||
# Surface the wrapped target's command methods and sub-modules so
|
||||
# dir()/REPL tab-completion and help() discovery work through the proxy.
|
||||
return sorted(set(object.__dir__(self)) | set(dir(self._target)))
|
||||
|
||||
def close(self):
|
||||
"""Disconnect and close the event loop."""
|
||||
self._loop.run_until_complete(self._target.disconnect())
|
||||
|
||||
353
pm3py/core/flash.py
Normal file
353
pm3py/core/flash.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""Pure-Python Proxmark3 firmware flasher.
|
||||
|
||||
Drives the whole flash flow over the existing USB-serial link — enter bootloader,
|
||||
arm the write window, stream the image block by block, reset — with no external
|
||||
`pm3-flash`/DFU/JTAG tooling.
|
||||
|
||||
Flashing uses the legacy fixed-size OLD frame (not the NG frames the rest of the
|
||||
client speaks); the OLD-frame plumbing lives in transport.py. See
|
||||
docs/plans for the protocol map.
|
||||
|
||||
Safety: fullimage-only by default. The bootrom region ([0x100000, 0x102000)) is
|
||||
never written unless ``allow_bootrom=True`` is passed explicitly — a botched
|
||||
bootrom write bricks the device to JTAG/SAM-BA recovery, and fork updates only
|
||||
ever change the OS image anyway.
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .protocol import (
|
||||
Cmd,
|
||||
OLD_FRAME_SIZE,
|
||||
DEVICE_INFO_FLAG_BOOTROM_PRESENT,
|
||||
DEVICE_INFO_FLAG_OSIMAGE_PRESENT,
|
||||
DEVICE_INFO_FLAG_CURRENT_MODE_BOOTROM,
|
||||
DEVICE_INFO_FLAG_CURRENT_MODE_OS,
|
||||
START_FLASH_MAGIC,
|
||||
FLASH_START, BOOTLOADER_END, FLASH_END_256K, FLASH_END_512K,
|
||||
FLASH_BLOCK_SIZE, BL_VERSION_1_0_0,
|
||||
)
|
||||
from .transport import PM3Transport, PM3Response, PM3Error
|
||||
|
||||
# ELF constants
|
||||
_ELF_MAGIC = b"\x7fELF"
|
||||
_ELFCLASS32 = 1
|
||||
_ELFDATA2LSB = 1
|
||||
_ET_EXEC = 2
|
||||
_EM_ARM = 40
|
||||
_PT_LOAD = 1
|
||||
|
||||
# Where to look for a locally-built image when none is given (dev checkout).
|
||||
_DEFAULT_IMAGE_CANDIDATES = (
|
||||
"firmware/armsrc/obj/fullimage.elf",
|
||||
)
|
||||
|
||||
# Repo root in a source checkout (…/pm3py/core/flash.py → …/). Used to find the
|
||||
# firmware submodule for --build; an installed wheel has no submodule here.
|
||||
_PACKAGE_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class FlashError(PM3Error):
|
||||
"""Firmware flashing failed."""
|
||||
|
||||
|
||||
def parse_elf_segments(image: bytes) -> list[tuple[int, bytes]]:
|
||||
"""Extract loadable segments from a PM3 firmware ELF.
|
||||
|
||||
Returns a list of (physical_address, data) for every PT_LOAD program header
|
||||
with a non-zero file size, sorted by address. Validates that the file is a
|
||||
little-endian 32-bit ARM executable, matching the C flasher's gate.
|
||||
"""
|
||||
if image[:4] != _ELF_MAGIC:
|
||||
raise FlashError("Not an ELF file (bad magic)")
|
||||
if image[4] != _ELFCLASS32:
|
||||
raise FlashError("Not a 32-bit ELF (ELFCLASS32 expected)")
|
||||
if image[5] != _ELFDATA2LSB:
|
||||
raise FlashError("Not a little-endian ELF (ELFDATA2LSB expected)")
|
||||
|
||||
e_type, e_machine = struct.unpack_from("<HH", image, 16)
|
||||
if e_type != _ET_EXEC:
|
||||
raise FlashError("ELF is not ET_EXEC")
|
||||
if e_machine != _EM_ARM:
|
||||
raise FlashError("ELF is not EM_ARM")
|
||||
|
||||
e_phoff = struct.unpack_from("<I", image, 28)[0]
|
||||
e_phentsize, e_phnum = struct.unpack_from("<HH", image, 42)
|
||||
|
||||
segments: list[tuple[int, bytes]] = []
|
||||
for i in range(e_phnum):
|
||||
off = e_phoff + i * e_phentsize
|
||||
(p_type, p_offset, p_vaddr, p_paddr,
|
||||
p_filesz, p_memsz, p_flags, p_align) = struct.unpack_from("<IIIIIIII", image, off)
|
||||
if p_type != _PT_LOAD or p_filesz == 0:
|
||||
continue
|
||||
segments.append((p_paddr, image[p_offset:p_offset + p_filesz]))
|
||||
|
||||
if not segments:
|
||||
raise FlashError("No loadable (PT_LOAD) segments in ELF")
|
||||
segments.sort()
|
||||
return segments
|
||||
|
||||
|
||||
def _align_down(x: int, a: int) -> int:
|
||||
return x & ~(a - 1)
|
||||
|
||||
|
||||
def _align_up(x: int, a: int) -> int:
|
||||
return (x + a - 1) & ~(a - 1)
|
||||
|
||||
|
||||
def build_blocks(segments: list[tuple[int, bytes]],
|
||||
block_size: int = FLASH_BLOCK_SIZE) -> list[tuple[int, bytes]]:
|
||||
"""Turn segments into a list of (address, block) writes.
|
||||
|
||||
Segments that fall in the same or overlapping flash blocks are merged into a
|
||||
single buffer first, so a page shared by two adjacent segments is written
|
||||
once with both segments' real data. Writing such a page twice would be a bug:
|
||||
the SAM7 erase-programs each page atomically, so a second write of the shared
|
||||
page (with 0xFF padding where the other segment's data lives) would wipe the
|
||||
first segment's bytes. Gaps within a merged run and the aligned outer edges
|
||||
are filled with 0xFF (erased flash).
|
||||
"""
|
||||
if not segments:
|
||||
return []
|
||||
segs = sorted(segments)
|
||||
|
||||
# Group consecutive segments whose block-aligned spans touch or overlap.
|
||||
groups: list[list[tuple[int, bytes]]] = [[segs[0]]]
|
||||
for paddr, data in segs[1:]:
|
||||
g = groups[-1]
|
||||
g_end = max(a + len(d) for a, d in g)
|
||||
if paddr < _align_up(g_end, block_size):
|
||||
g.append((paddr, data))
|
||||
else:
|
||||
groups.append([(paddr, data)])
|
||||
|
||||
blocks: list[tuple[int, bytes]] = []
|
||||
for g in groups:
|
||||
g_start = g[0][0]
|
||||
g_end = max(a + len(d) for a, d in g)
|
||||
astart = _align_down(g_start, block_size)
|
||||
aend = _align_up(g_end, block_size)
|
||||
blob = bytearray(b"\xFF" * (aend - astart))
|
||||
for paddr, data in g:
|
||||
off = paddr - astart
|
||||
blob[off:off + len(data)] = data
|
||||
for i in range(0, len(blob), block_size):
|
||||
blocks.append((astart + i, bytes(blob[i:i + block_size])))
|
||||
return blocks
|
||||
|
||||
|
||||
def resolve_image(image=None) -> bytes:
|
||||
"""Resolve a firmware image to raw ELF bytes.
|
||||
|
||||
Accepts raw bytes, a path, or ``None``. With ``None`` it searches for a
|
||||
locally-built image: the ``PM3PY_FIRMWARE`` env var, then conventional build
|
||||
output paths relative to the working directory. (Auto-download of the pinned
|
||||
release is a separate, later layer that also feeds this function.)
|
||||
"""
|
||||
if isinstance(image, (bytes, bytearray)):
|
||||
return bytes(image)
|
||||
if image is not None:
|
||||
p = Path(image)
|
||||
if not p.exists():
|
||||
raise FlashError(f"Firmware image not found: {p}")
|
||||
return p.read_bytes()
|
||||
|
||||
candidates = []
|
||||
env = os.environ.get("PM3PY_FIRMWARE")
|
||||
if env:
|
||||
candidates.append(Path(env))
|
||||
candidates.extend(Path(c) for c in _DEFAULT_IMAGE_CANDIDATES)
|
||||
for p in candidates:
|
||||
if p.exists():
|
||||
return p.read_bytes()
|
||||
raise FlashError(
|
||||
"No firmware image given and no local build found. Build the firmware "
|
||||
"(make) and/or set PM3PY_FIRMWARE, or pass image=<path to fullimage.elf>."
|
||||
)
|
||||
|
||||
|
||||
def find_firmware_src(firmware_dir=None) -> Path | None:
|
||||
"""Locate the firmware source tree (the proxmark3-pm3py submodule) to build.
|
||||
|
||||
Checks the explicit arg, then ``$PM3PY_FIRMWARE_SRC``, then the repo root
|
||||
relative to this package. Returns None if no buildable tree is found — e.g. an
|
||||
installed wheel carries no submodule. Deliberately does NOT search the current
|
||||
working directory: build_firmware runs ``make`` on the result, and picking up a
|
||||
stray ``./firmware`` would execute an untrusted Makefile.
|
||||
"""
|
||||
candidates = []
|
||||
if firmware_dir:
|
||||
candidates.append(Path(firmware_dir))
|
||||
env = os.environ.get("PM3PY_FIRMWARE_SRC")
|
||||
if env:
|
||||
candidates.append(Path(env))
|
||||
candidates.append(_PACKAGE_ROOT / "firmware")
|
||||
for c in candidates:
|
||||
if (c / "Makefile").exists() and (c / "armsrc" / "Makefile").exists():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def build_firmware(platform: str, firmware_dir=None) -> Path:
|
||||
"""Cross-compile the fork's fullimage.elf for a PM3 platform and return its path.
|
||||
|
||||
``platform`` is the PM3 build platform and must be given explicitly
|
||||
(``PM3GENERIC`` for the Easy/generic target, ``PM3RDV4`` for the RDV4, ...).
|
||||
Never infer it from a connected device: the running firmware's ``is_rdv4``
|
||||
flag reflects the firmware it was built for, not the physical board.
|
||||
"""
|
||||
if not platform or not platform.replace("_", "").isalnum():
|
||||
raise FlashError(f"Invalid PLATFORM {platform!r} "
|
||||
f"(expected e.g. PM3GENERIC or PM3RDV4)")
|
||||
src = find_firmware_src(firmware_dir)
|
||||
if src is None:
|
||||
raise FlashError(
|
||||
"Firmware source not found — --build needs a source checkout with the "
|
||||
"firmware/ submodule (an installed wheel has none). Set "
|
||||
"PM3PY_FIRMWARE_SRC, run from the repo, or use --image with a prebuilt "
|
||||
"fullimage.elf.")
|
||||
cmd = ["make", "-C", str(src), f"PLATFORM={platform}", "armsrc/all"]
|
||||
try:
|
||||
proc = subprocess.run(cmd) # inherits stdio so the build streams to the user
|
||||
except OSError as e: # make missing / not executable
|
||||
raise FlashError(f"could not run make: {e}")
|
||||
if proc.returncode != 0:
|
||||
raise FlashError(f"firmware build failed (make exited {proc.returncode})")
|
||||
elf = src / "armsrc" / "obj" / "fullimage.elf"
|
||||
if not elf.exists():
|
||||
raise FlashError(f"build reported success but {elf} is missing")
|
||||
return elf
|
||||
|
||||
|
||||
class Flasher:
|
||||
"""Flash Proxmark3 firmware over the wire (OLD-frame bootloader protocol)."""
|
||||
|
||||
def __init__(self, transport: PM3Transport):
|
||||
self._t = transport
|
||||
|
||||
@staticmethod
|
||||
def _is(resp: PM3Response, cmd: int) -> bool:
|
||||
"""True if an OLD reply carries the given command (low 16 bits)."""
|
||||
return (resp.cmd & 0xFFFF) == cmd
|
||||
|
||||
async def device_info(self) -> dict:
|
||||
"""Probe device mode/capabilities via CMD_DEVICE_INFO."""
|
||||
resp = await self._t.send_old(Cmd.DEVICE_INFO)
|
||||
flags = resp.oldarg[0] if resp.oldarg else 0
|
||||
return {
|
||||
"flags": flags,
|
||||
"in_bootrom": bool(flags & DEVICE_INFO_FLAG_CURRENT_MODE_BOOTROM),
|
||||
"in_os": bool(flags & DEVICE_INFO_FLAG_CURRENT_MODE_OS),
|
||||
"bootrom_present": bool(flags & DEVICE_INFO_FLAG_BOOTROM_PRESENT),
|
||||
"osimage_present": bool(flags & DEVICE_INFO_FLAG_OSIMAGE_PRESENT),
|
||||
}
|
||||
|
||||
async def bl_version(self) -> int:
|
||||
"""Read the bootloader version code (packed BL_MAKE_VERSION)."""
|
||||
resp = await self._t.send_old(Cmd.BL_VERSION)
|
||||
return resp.oldarg[0] if resp.oldarg else 0
|
||||
|
||||
async def enter_bootloader(self, reopen: bool = True) -> dict:
|
||||
"""Put the device into the bootloader, ready to accept flash writes.
|
||||
|
||||
If already in the bootrom, returns immediately. From the OS this issues
|
||||
the automatic CMD_START_FLASH handover (no button press) and re-opens the
|
||||
port after the device re-enumerates.
|
||||
"""
|
||||
info = await self.device_info()
|
||||
if info["in_bootrom"]:
|
||||
return info
|
||||
if info["in_os"] and not info["bootrom_present"]:
|
||||
raise FlashError(
|
||||
"Device is in the OS but reports no bootrom — cannot auto-enter "
|
||||
"the bootloader; hold the button while replugging to flash."
|
||||
)
|
||||
# Modern handover: CMD_START_FLASH with zero args -> reset into bootrom.
|
||||
await self._t.send_old_no_response(Cmd.START_FLASH)
|
||||
if reopen:
|
||||
await self._t.reopen()
|
||||
info = await self.device_info()
|
||||
if not info["in_bootrom"]:
|
||||
raise FlashError("Device did not enter the bootloader after handover")
|
||||
return info
|
||||
|
||||
async def flash_image(self, image, *, allow_bootrom: bool = False,
|
||||
on_progress=None) -> dict:
|
||||
"""Write an image to flash. Device must already be in the bootloader.
|
||||
|
||||
Returns {'success', 'blocks', 'bytes', 'flash_end'}.
|
||||
"""
|
||||
raw = resolve_image(image)
|
||||
segments = parse_elf_segments(raw)
|
||||
|
||||
# Safety gate: never touch the bootrom region unless explicitly allowed.
|
||||
for paddr, data in segments:
|
||||
if paddr < BOOTLOADER_END and not allow_bootrom:
|
||||
raise FlashError(
|
||||
f"Image writes the bootrom region (0x{paddr:08X}); refusing. "
|
||||
f"Pass allow_bootrom=True only if you really mean to reflash "
|
||||
f"the bootloader (brick risk)."
|
||||
)
|
||||
|
||||
blocks = build_blocks(segments)
|
||||
|
||||
# Choose the write window. Only reach into the second bank (>256 KB) on a
|
||||
# bootloader new enough to drive it.
|
||||
blv = await self.bl_version()
|
||||
flash_end = FLASH_END_512K if blv >= BL_VERSION_1_0_0 else FLASH_END_256K
|
||||
|
||||
low = FLASH_START if allow_bootrom else BOOTLOADER_END
|
||||
magic = START_FLASH_MAGIC if allow_bootrom else 0
|
||||
for addr, block in blocks:
|
||||
if addr < low or addr + len(block) > flash_end:
|
||||
raise FlashError(
|
||||
f"Block at 0x{addr:08X} is outside the flash window "
|
||||
f"[0x{low:08X}, 0x{flash_end:08X}); wrong image for this device?"
|
||||
)
|
||||
|
||||
# Arm the write window.
|
||||
resp = await self._t.send_old(Cmd.START_FLASH, low, flash_end, magic)
|
||||
if not self._is(resp, Cmd.ACK):
|
||||
raise FlashError(f"START_FLASH not acknowledged (reply cmd=0x{resp.cmd:X})")
|
||||
|
||||
written = 0
|
||||
total = len(blocks)
|
||||
for i, (addr, block) in enumerate(blocks):
|
||||
resp = await self._t.send_old(Cmd.FINISH_WRITE, addr, 0, 0, block)
|
||||
if self._is(resp, Cmd.NACK):
|
||||
sr = resp.oldarg[0] if resp.oldarg else 0
|
||||
raise FlashError(
|
||||
f"Write failed at 0x{addr:08X} (EFC status 0x{sr:X})")
|
||||
if not self._is(resp, Cmd.ACK):
|
||||
raise FlashError(
|
||||
f"Unexpected reply writing 0x{addr:08X} (cmd=0x{resp.cmd:X})")
|
||||
written += len(block)
|
||||
if on_progress:
|
||||
on_progress({"block": i + 1, "total": total,
|
||||
"addr": addr, "bytes": written})
|
||||
|
||||
return {"success": True, "blocks": total, "bytes": written,
|
||||
"flash_end": flash_end}
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Reboot the device (bootrom then jumps into the OS image)."""
|
||||
await self._t.send_old_no_response(Cmd.HARDWARE_RESET)
|
||||
|
||||
async def flash(self, image=None, *, allow_bootrom: bool = False,
|
||||
on_progress=None, reset: bool = True) -> dict:
|
||||
"""End-to-end: enter bootloader, write the image, reboot.
|
||||
|
||||
``image`` may be raw ELF bytes, a path to a ``fullimage.elf``, or ``None``
|
||||
to use a locally-built image (see resolve_image).
|
||||
"""
|
||||
raw = resolve_image(image)
|
||||
await self.enter_bootloader()
|
||||
result = await self.flash_image(raw, allow_bootrom=allow_bootrom,
|
||||
on_progress=on_progress)
|
||||
if reset:
|
||||
await self.reset()
|
||||
return result
|
||||
@@ -13,12 +13,21 @@ class HFCommands:
|
||||
# Sub-modules attached by client.py
|
||||
self.iso14a = 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:
|
||||
"""Measure HF antenna voltage."""
|
||||
resp = await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_HF, timeout=10.0)
|
||||
voltage = struct.unpack_from("<I", resp.data, 0)[0] if len(resp.data) >= 4 else 0
|
||||
"""Measure HF antenna voltage.
|
||||
|
||||
CMD_MEASURE_ANTENNA_TUNING_HF is a START/MEASURE/STOP state machine that
|
||||
requires a 1-byte mode arg (firmware rejects length!=1 with EINVARG):
|
||||
mode 1 = START (spins up the HF FPGA), mode 2 = MEASURE (returns uint16 mV),
|
||||
mode 3 = STOP. Voltage is a uint16_t, not uint32_t.
|
||||
"""
|
||||
await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_HF, bytes([1]), timeout=10.0) # START
|
||||
resp = await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_HF, bytes([2]), timeout=10.0) # MEASURE
|
||||
voltage = struct.unpack_from("<H", resp.data, 0)[0] if len(resp.data) >= 2 else 0
|
||||
await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_HF, bytes([3]), timeout=10.0) # STOP
|
||||
result = {
|
||||
"voltage_mV": voltage,
|
||||
"voltage_V": round(voltage / 1000.0, 3),
|
||||
@@ -62,14 +71,30 @@ class HFCommands:
|
||||
results["found"] = len(results) > 0
|
||||
return results
|
||||
|
||||
async def sniff(self, samples: int = 0, skip: int = 0,
|
||||
async def sniff(self, samples_to_skip: int = 0, triggers_to_skip: int = 0,
|
||||
skip_mode: int = 0, skip_ratio: int = 0,
|
||||
on_progress: ProgressCallback = None) -> dict:
|
||||
"""Sniff HF traffic."""
|
||||
payload = struct.pack("<II", samples, skip) if samples else b""
|
||||
"""Sniff HF traffic.
|
||||
|
||||
Firmware reads a 10-byte PACKED struct
|
||||
``{uint32 samplesToSkip; uint32 triggersToSkip; uint8 skipMode;
|
||||
uint8 skipRatio}`` and, when the capture ends, replies with a
|
||||
``{uint16 len}`` sample count. The struct must always be sent whole
|
||||
(matching the C client's ``sizeof(params)``); under-sending leaves
|
||||
the firmware reading uninitialised BigBuf bytes.
|
||||
"""
|
||||
payload = struct.pack("<IIBB", samples_to_skip, triggers_to_skip,
|
||||
skip_mode, skip_ratio)
|
||||
resp = await self._t.send_ng(Cmd.HF_SNIFF, payload, timeout=30.0)
|
||||
return {"status": resp.status, "data": resp.data.hex()}
|
||||
samples = struct.unpack_from("<H", resp.data, 0)[0] if len(resp.data) >= 2 else 0
|
||||
return {"status": resp.status, "samples": samples}
|
||||
|
||||
async def dropfield(self) -> dict:
|
||||
"""Turn off HF field."""
|
||||
resp = await self._t.send_ng(Cmd.HF_DROPFIELD)
|
||||
return {"status": resp.status}
|
||||
"""Turn off HF field.
|
||||
|
||||
The firmware handler is a bare ``hf_field_off(); break;`` with no
|
||||
``reply_ng``, so waiting for a response just blocks until timeout.
|
||||
Fire-and-forget instead.
|
||||
"""
|
||||
await self._t.send_ng_no_response(Cmd.HF_DROPFIELD)
|
||||
return {"status": 0}
|
||||
|
||||
@@ -63,34 +63,72 @@ class HF14ACommands:
|
||||
payload=data,
|
||||
timeout=5.0,
|
||||
)
|
||||
# The firmware replies with its whole receive buffer; oldarg[0] is the number of bytes
|
||||
# actually received (0 = the tag didn't answer). Trim to it so a no-response is empty,
|
||||
# not a buffer of zeros.
|
||||
received = resp.oldarg[0] if resp.oldarg else 0
|
||||
payload = resp.data[:received] if resp.data else b""
|
||||
return {
|
||||
"status": resp.status,
|
||||
"data": resp.data.hex() if resp.data else None,
|
||||
"raw": resp.data,
|
||||
"received": received,
|
||||
"data": payload.hex() if payload else None,
|
||||
"raw": payload,
|
||||
}
|
||||
|
||||
async def sniff(self, param: int = 0) -> dict:
|
||||
"""Sniff ISO14443A traffic."""
|
||||
resp = await self._t.send_mix(
|
||||
Cmd.HF_ISO14443A_SNIFF, arg0=param, timeout=30.0,
|
||||
resp = await self._t.send_ng(
|
||||
Cmd.HF_ISO14443A_SNIFF, payload=bytes([param]), timeout=30.0,
|
||||
)
|
||||
return {"status": resp.status}
|
||||
|
||||
async def sim(self, uid: bytes, sak: int = 0x08, atqa: bytes = b"\x04\x00",
|
||||
flags: int = 0) -> dict:
|
||||
"""Simulate ISO14443A card."""
|
||||
async def sim(self, tagtype: int = 1, uid: bytes = b"", exit_after: int = 0,
|
||||
flags: int = 0, rats: bytes = b"",
|
||||
ulauth_1a1: bytes = b"", ulauth_1a2: bytes = b"",
|
||||
ulauth_1a2_mirror: bool = False) -> dict:
|
||||
"""Simulate an ISO14443A tag.
|
||||
|
||||
tagtype selects the emulated tag: 1=MIFARE Classic 1k, 2=Ultralight,
|
||||
3=DESFire, 4=ISO14443-4, 5=Tnp3xxx, 6=MF Mini, 7=MFU EV1/NTAG215,
|
||||
8=MFC 4k, 9=FM11RF005SH, 10=ST25TA, 11=JCOP, 12=Seos, 13=ULC,
|
||||
14=ULAES, 15=MF Plus.
|
||||
|
||||
uid: 4, 7, or 10 raw bytes. Empty = take UID from emulator memory.
|
||||
exit_after: stop after N reader reads (0 = infinite).
|
||||
ulauth_1a1/ulauth_1a2: ULC (8 byte) / ULAES (16 byte) auth replies.
|
||||
"""
|
||||
# Fold the UID length into flags per FLAG_SET_UID_IN_DATA (pm3_cmd.h).
|
||||
if uid:
|
||||
if len(uid) == 4:
|
||||
uid_flag = 0x0010
|
||||
uid_flag = 0x0010 # FLAG_4B_UID_IN_DATA
|
||||
elif len(uid) == 7:
|
||||
uid_flag = 0x0020
|
||||
uid_flag = 0x0020 # FLAG_7B_UID_IN_DATA
|
||||
elif len(uid) == 10:
|
||||
uid_flag = 0x0030
|
||||
uid_flag = 0x0030 # FLAG_10B_UID_IN_DATA
|
||||
else:
|
||||
raise ValueError("UID must be 4, 7, or 10 bytes")
|
||||
else:
|
||||
uid_flag = 0x0000 # FLAG_UID_IN_EMUL
|
||||
|
||||
flag_val = flags | uid_flag | 0x0001 # FLAG_INTERACTIVE
|
||||
payload = atqa + bytes([sak]) + uid
|
||||
resp = await self._t.send_mix(
|
||||
Cmd.HF_ISO14443A_SIMULATE, arg0=flag_val, timeout=60.0, payload=payload,
|
||||
flag_val = (flags & ~0x0030) | uid_flag # FLAG_MASK_UID = 0x0030
|
||||
|
||||
# struct { u8 tagtype; u16 flags; u8 uid[10]; u8 exitAfter;
|
||||
# u8 rats[20]; u8 ulauth_1a1_len; u8 ulauth_1a2_len;
|
||||
# u8 ulauth_1a1[16]; u8 ulauth_1a2[16]; bool mirror; } PACKED
|
||||
payload = struct.pack(
|
||||
"<BH10sB20sBB16s16sB",
|
||||
tagtype & 0xFF,
|
||||
flag_val & 0xFFFF,
|
||||
uid.ljust(10, b"\x00")[:10],
|
||||
exit_after & 0xFF,
|
||||
rats.ljust(20, b"\x00")[:20],
|
||||
len(ulauth_1a1),
|
||||
len(ulauth_1a2),
|
||||
ulauth_1a1.ljust(16, b"\x00")[:16],
|
||||
ulauth_1a2.ljust(16, b"\x00")[:16],
|
||||
1 if ulauth_1a2_mirror else 0,
|
||||
)
|
||||
resp = await self._t.send_ng(
|
||||
Cmd.HF_ISO14443A_SIMULATE, payload=payload, timeout=60.0,
|
||||
)
|
||||
return {"status": resp.status}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""ISO 15693 commands: hf.iso15.*"""
|
||||
import struct
|
||||
from .protocol import Cmd
|
||||
from .protocol import Cmd, crc15, crc15_bytes
|
||||
from .transport import PM3Transport, PM3Error
|
||||
|
||||
# Re-export trace symbols for backward compat with existing test imports
|
||||
@@ -11,12 +11,57 @@ from ..trace import (
|
||||
format_sniff_line,
|
||||
)
|
||||
|
||||
# ISO15 PM3 flags
|
||||
# ISO15 PM3 transport flags — values MUST match firmware include/iso15.h
|
||||
ISO15_CONNECT = 0x01
|
||||
ISO15_NO_DISCONNECT = 0x02
|
||||
ISO15_RAW = 0x04
|
||||
ISO15_APPEND_CRC = 0x08
|
||||
ISO15_READ_RESPONSE = 0x10
|
||||
ISO15_APPEND_CRC = 0x08 # legacy: ARM firmware ignores it — CRC is added host-side
|
||||
ISO15_HIGH_SPEED = 0x10
|
||||
ISO15_READ_RESPONSE = 0x20
|
||||
ISO15_LONG_WAIT = 0x40
|
||||
|
||||
|
||||
# Default transport flags for read/inventory (C client CmdHF15Info/getUID).
|
||||
_READ_FLAGS = ISO15_CONNECT | ISO15_HIGH_SPEED | ISO15_READ_RESPONSE # 0x31
|
||||
# Write needs the long (~22ms) tag-ack timeout and does not set HIGH_SPEED
|
||||
# (mirrors the C client's hf_15_write_blk).
|
||||
_WRITE_FLAGS = ISO15_CONNECT | ISO15_READ_RESPONSE | ISO15_LONG_WAIT # 0x61
|
||||
|
||||
# Command bytes that program the tag and so need the long tag-ack window (raw() auto-detects these
|
||||
# from iso_cmd[1]): std write/lock, EAS writes, password/protect/destroy/privacy.
|
||||
_WRITE_CMDS = frozenset({0x21, 0x22, 0x24, 0x27, 0x28, 0x29, 0x2A,
|
||||
0xA2, 0xA3, 0xA4, 0xA6, 0xA7,
|
||||
0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB9, 0xBA})
|
||||
|
||||
|
||||
def _iso15_payload(iso_cmd: bytes, flags: int = _READ_FLAGS) -> bytes:
|
||||
"""Build a HF_ISO15693_COMMAND payload from a raw ISO15693 command.
|
||||
|
||||
Appends the ISO15693 CRC host-side (the ARM firmware ignores ISO15_APPEND_CRC —
|
||||
the client must add it, like the C client's AddCrc15) and prefixes the PM3 transport
|
||||
flags. READ_RESPONSE (0x20) makes the firmware actually listen for the tag's answer;
|
||||
without it the firmware passes a NULL rx buffer and replies empty.
|
||||
"""
|
||||
raw = iso_cmd + crc15_bytes(iso_cmd)
|
||||
return struct.pack("<BH", flags, len(raw)) + raw
|
||||
|
||||
|
||||
# ISO15693 request-flags byte (raw[0]) — the protocol flags, NOT the PM3 transport flags
|
||||
REQ_DATARATE_HIGH = 0x02
|
||||
REQ_INVENTORY = 0x04
|
||||
REQINV_AFI = 0x10 # an AFI byte follows the INVENTORY command
|
||||
REQINV_SLOT1 = 0x20 # set = 1 slot, clear = 16-slot anti-collision
|
||||
|
||||
|
||||
def _parse_inv_record(rec: bytes) -> dict | None:
|
||||
"""Parse one inventory response ``[resp_flags][dsfid][uid(8)][crc(2)]``.
|
||||
|
||||
Returns ``{'uid', 'dsfid', 'flags'}`` (uid as display-order hex), or None if
|
||||
the record is too short or the CRC is bad (i.e. a collision/garbled slot).
|
||||
"""
|
||||
if len(rec) < 12 or crc15(rec[:-2]) != (rec[-2] | (rec[-1] << 8)):
|
||||
return None
|
||||
return {"uid": rec[2:10][::-1].hex(), "dsfid": rec[1], "flags": rec[0]}
|
||||
|
||||
|
||||
class HF15Commands:
|
||||
@@ -27,10 +72,13 @@ class HF15Commands:
|
||||
|
||||
async def scan(self) -> dict:
|
||||
"""Scan for ISO15693 tags using inventory command."""
|
||||
# ISO15693 inventory: flags=0x06 (high data rate + inventory), cmd=0x01
|
||||
iso_cmd = bytes([0x06, 0x01, 0x00]) # flags, inventory cmd, mask_len=0
|
||||
flags = ISO15_CONNECT | ISO15_READ_RESPONSE | ISO15_APPEND_CRC
|
||||
payload = struct.pack("<BH", flags, len(iso_cmd)) + iso_cmd
|
||||
# ISO15693 inventory: flags=0x26 (high data rate + inventory + 1-slot), cmd=0x01.
|
||||
# SLOT1 (0x20) is required: without it the firmware runs a 16-slot anti-collision
|
||||
# round and replies with an iso15_slot_result_t struct, not [flags][dsfid][uid],
|
||||
# so this parser would read the struct header as a zeroed UID. 1-slot gives the
|
||||
# simple single-tag response this method expects. Use hf 15 reader --all for many.
|
||||
iso_cmd = bytes([0x26, 0x01, 0x00]) # flags, inventory cmd, mask_len=0
|
||||
payload = _iso15_payload(iso_cmd)
|
||||
resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=5.0)
|
||||
|
||||
if resp.status != 0 or len(resp.data) < 10:
|
||||
@@ -47,19 +95,87 @@ class HF15Commands:
|
||||
"response_flags": resp_flags,
|
||||
}
|
||||
|
||||
async def raw_inventory(self, inv_flags: int = 0x26) -> dict:
|
||||
"""Diagnostic: send a raw ISO15693 inventory with a caller-chosen request-flags
|
||||
byte and return the raw device response (no parsing). Use to isolate 1-slot vs
|
||||
16-slot behavior: inv_flags=0x26 → 1 slot (SLOT1 set), 0x06 → 16 slots.
|
||||
"""
|
||||
iso_cmd = bytes([inv_flags, 0x01, 0x00]) # inv flags, INVENTORY cmd, mask_len=0
|
||||
payload = _iso15_payload(iso_cmd)
|
||||
resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=5.0)
|
||||
return {"inv_flags": hex(inv_flags), "status": resp.status,
|
||||
"len": len(resp.data), "data": resp.data.hex()}
|
||||
|
||||
async def inventory(self, slots: int = 16, afi: int | None = None,
|
||||
dsfid: int | None = None):
|
||||
"""Perform an ISO15693 inventory (anti-collision).
|
||||
|
||||
slots=16 (default): one 16-slot anti-collision round → **list** of tag
|
||||
dicts (empty list if none). Handles up to 16 non-colliding tags per
|
||||
round; slots that collide are dropped (CRC fail).
|
||||
slots=1: single-slot → **one** tag dict, or None if no/garbled response.
|
||||
afi: request-level Application Family Identifier — only tags with this AFI
|
||||
answer (sent in the inventory command).
|
||||
dsfid: client-side filter — keep only tags whose DSFID equals this.
|
||||
|
||||
Each tag: ``{'uid': str, 'dsfid': int, 'flags': int[, 'slot': int]}``.
|
||||
(`scan()` remains the quick "is one tag present" convenience.)
|
||||
"""
|
||||
req = REQ_DATARATE_HIGH | REQ_INVENTORY
|
||||
if slots == 1:
|
||||
req |= REQINV_SLOT1
|
||||
if afi is not None:
|
||||
req |= REQINV_AFI
|
||||
iso_cmd = bytes([req, 0x01]) # request flags, INVENTORY command
|
||||
if afi is not None:
|
||||
iso_cmd += bytes([afi & 0xFF]) # AFI byte precedes the mask
|
||||
iso_cmd += bytes([0x00]) # mask length = 0
|
||||
resp = await self._t.send_ng(
|
||||
Cmd.HF_ISO15693_COMMAND, _iso15_payload(iso_cmd), timeout=5.0)
|
||||
|
||||
def _keep(tag):
|
||||
return tag is not None and (dsfid is None or tag["dsfid"] == dsfid)
|
||||
|
||||
if slots == 1:
|
||||
if resp.status != 0:
|
||||
return None
|
||||
tag = _parse_inv_record(bytes(resp.data))
|
||||
return tag if _keep(tag) else None
|
||||
|
||||
# 16-slot: iso15_inventory_response_t = [slot_count][16x{status,len}][data]
|
||||
tags = []
|
||||
data = bytes(resp.data)
|
||||
if resp.status != 0 or len(data) < 1 + 16 * 2:
|
||||
return tags
|
||||
slot_count = data[0]
|
||||
off = 1 + slot_count * 2 # concatenated slot data starts here
|
||||
for s in range(slot_count):
|
||||
status = data[1 + s * 2]
|
||||
rlen = data[1 + s * 2 + 1]
|
||||
rec = data[off:off + rlen]
|
||||
off += rlen
|
||||
if status != 1 or rlen == 0: # 0=empty, 2=collision
|
||||
continue
|
||||
tag = _parse_inv_record(rec)
|
||||
if _keep(tag):
|
||||
tag["slot"] = s
|
||||
tags.append(tag)
|
||||
return tags
|
||||
|
||||
async def rdbl(self, block: int, uid: str | None = None) -> dict:
|
||||
"""Read a single block."""
|
||||
# OPTION (0x40) makes the tag return the block-security (lock) byte this parser
|
||||
# expects: addressed 0x22|0x40=0x62, unaddressed 0x02|0x40=0x42.
|
||||
if uid:
|
||||
uid_bytes = bytes.fromhex(uid)[::-1] # reverse for wire
|
||||
iso_cmd = bytes([0x22, 0x20]) + uid_bytes + bytes([block])
|
||||
iso_cmd = bytes([0x62, 0x20]) + uid_bytes + bytes([block])
|
||||
else:
|
||||
iso_cmd = bytes([0x02, 0x20, block]) # addressed without UID
|
||||
iso_cmd = bytes([0x42, 0x20, block]) # unaddressed
|
||||
|
||||
flags = ISO15_CONNECT | ISO15_READ_RESPONSE | ISO15_APPEND_CRC
|
||||
payload = struct.pack("<BH", flags, len(iso_cmd)) + iso_cmd
|
||||
payload = _iso15_payload(iso_cmd)
|
||||
resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=5.0)
|
||||
|
||||
if resp.status != 0 or len(resp.data) < 2:
|
||||
if resp.status != 0 or len(resp.data) < 4:
|
||||
return {"success": False, "error": resp.status}
|
||||
|
||||
resp_flags = resp.data[0]
|
||||
@@ -67,7 +183,7 @@ class HF15Commands:
|
||||
return {"success": False, "error_flag": resp_flags}
|
||||
|
||||
lock_status = resp.data[1]
|
||||
block_data = resp.data[2:]
|
||||
block_data = resp.data[2:-2] # strip the 2-byte ISO15693 CRC the firmware relays
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -88,13 +204,30 @@ class HF15Commands:
|
||||
else:
|
||||
iso_cmd = bytes([0x02, 0x21, block]) + data
|
||||
|
||||
flags = ISO15_CONNECT | ISO15_READ_RESPONSE | ISO15_APPEND_CRC
|
||||
payload = struct.pack("<BH", flags, len(iso_cmd)) + iso_cmd
|
||||
payload = _iso15_payload(iso_cmd, flags=_WRITE_FLAGS) # LONG_WAIT for the ~22ms write ack
|
||||
resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=5.0)
|
||||
|
||||
success = resp.status == 0 and len(resp.data) > 0 and resp.data[0] == 0
|
||||
return {"success": success, "block": block}
|
||||
|
||||
async def raw(self, iso_cmd: bytes | str, long_wait: bool | None = None,
|
||||
timeout: float = 5.0) -> dict:
|
||||
"""Send a raw ISO15693 request frame and return the tag's response.
|
||||
|
||||
``iso_cmd`` is the request **without** the CRC (flags | command | [mfg] | [UID] | params);
|
||||
the ISO15693 CRC is appended host-side. ``long_wait`` uses the write-length tag-ack timeout
|
||||
(needed for WRITE/LOCK-class commands); ``None`` (default) auto-selects it from the command
|
||||
byte. The returned ``raw`` is the firmware-relayed tag response (response-flags + data +
|
||||
its CRC)."""
|
||||
if isinstance(iso_cmd, str):
|
||||
iso_cmd = bytes.fromhex(iso_cmd.replace(" ", ""))
|
||||
if long_wait is None:
|
||||
long_wait = (len(iso_cmd) > 1 and iso_cmd[1] in _WRITE_CMDS)
|
||||
payload = _iso15_payload(iso_cmd, flags=_WRITE_FLAGS if long_wait else _READ_FLAGS)
|
||||
resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=timeout)
|
||||
data = bytes(resp.data)
|
||||
return {"status": resp.status, "raw": data, "data": data.hex()}
|
||||
|
||||
async def sniff(self, timeout: float = 60.0) -> dict:
|
||||
"""Start ISO15693 sniff. Blocks until button press or timeout.
|
||||
|
||||
|
||||
@@ -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."""
|
||||
@@ -49,8 +59,9 @@ class HFMFCommands:
|
||||
raise ValueError(f"Block data must be {MFBLOCK_SIZE} bytes")
|
||||
|
||||
# MIX format: arg0=blockno, arg1=keytype
|
||||
# payload = key(6) + reserved(10) + data(16) = 32 bytes
|
||||
payload = key_bytes + b"\x00" * 10 + data
|
||||
# firmware reads key at asBytes[0], block_data at asBytes[10]
|
||||
# payload = key(6) + reserved(4) + data(16) = 26 bytes
|
||||
payload = key_bytes + b"\x00" * 4 + data
|
||||
resp = await self._t.send_mix(
|
||||
Cmd.HF_MIFARE_WRITEBL, arg0=block, arg1=key_type,
|
||||
payload=payload, timeout=5.0,
|
||||
@@ -59,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("<i", cmddata, 11, value)
|
||||
cmddata[27:33] = transfer_key_bytes
|
||||
if _sector_of(commit_block) != _sector_of(block):
|
||||
cmddata[33] = 1
|
||||
|
||||
resp = await self._t.send_mix(
|
||||
Cmd.HF_MIFARE_VALUE, arg0=block, arg1=key_type, arg2=transfer_key_type,
|
||||
payload=bytes(cmddata), timeout=5.0,
|
||||
)
|
||||
success = resp.oldarg[0] > 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."""
|
||||
@@ -96,14 +146,24 @@ class HFMFCommands:
|
||||
continue
|
||||
return {"found": False}
|
||||
|
||||
async def sniff(self) -> dict:
|
||||
"""Sniff MIFARE traffic."""
|
||||
resp = await self._t.send_ng(Cmd.HF_MIFARE_SNIFF, timeout=30.0)
|
||||
async def sniff(self, param: int = 0) -> dict:
|
||||
"""Sniff MIFARE (ISO14443-A) traffic.
|
||||
|
||||
Firmware has no dedicated MIFARE sniff handler; the C client routes
|
||||
``hf mf sniff`` through the ISO14443-A sniffer.
|
||||
"""
|
||||
resp = await self._t.send_mix(
|
||||
Cmd.HF_ISO14443A_SNIFF, arg0=param, timeout=30.0,
|
||||
)
|
||||
return {"status": resp.status}
|
||||
|
||||
async def sim(self, uid: str | bytes, size: str = "1k") -> dict:
|
||||
async def sim(self, uid: str | bytes, size: str = "1k",
|
||||
atqa: str | bytes | None = None, sak: int | None = None,
|
||||
exit_after: int = 0, interactive: bool = False) -> dict:
|
||||
"""Simulate MIFARE Classic card.
|
||||
size: "mini", "1k", "2k", "4k"
|
||||
|
||||
size: "mini", "1k", "2k", "4k". Optional atqa (2 bytes) / sak (1 byte)
|
||||
override the defaults; exit_after N stops after N auth attempts (0=never).
|
||||
"""
|
||||
if isinstance(uid, str):
|
||||
uid = bytes.fromhex(uid)
|
||||
@@ -116,23 +176,70 @@ class HFMFCommands:
|
||||
if uid_flag is None:
|
||||
raise ValueError("UID must be 4, 7, or 10 bytes")
|
||||
|
||||
flags = 0x0001 | uid_flag | size_flags[size] # FLAG_INTERACTIVE
|
||||
resp = await self._t.send_mix(
|
||||
Cmd.HF_MIFARE_SIMULATE, arg0=flags, payload=uid, timeout=60.0,
|
||||
flags = uid_flag | size_flags[size]
|
||||
if interactive:
|
||||
flags |= 0x0001 # FLAG_INTERACTIVE
|
||||
|
||||
atqa_val = 0
|
||||
if atqa is not None:
|
||||
if isinstance(atqa, str):
|
||||
atqa = bytes.fromhex(atqa)
|
||||
if len(atqa) != 2:
|
||||
raise ValueError("ATQA must be 2 bytes")
|
||||
atqa_val = (atqa[1] << 8) | atqa[0]
|
||||
flags |= 0x0002 # FLAG_ATQA_IN_DATA
|
||||
|
||||
sak_val = 0
|
||||
if sak is not None:
|
||||
sak_val = sak & 0xFF
|
||||
flags |= 0x0004 # FLAG_SAK_IN_DATA
|
||||
|
||||
# struct { u16 flags; u8 exitAfter; u8 uid[10]; u16 atqa; u8 sak }
|
||||
payload = struct.pack("<HB", flags, exit_after & 0xFF)
|
||||
payload += uid + b"\x00" * (10 - len(uid))
|
||||
payload += struct.pack("<HB", atqa_val, sak_val)
|
||||
resp = await self._t.send_ng(
|
||||
Cmd.HF_MIFARE_SIMULATE, payload, timeout=60.0,
|
||||
)
|
||||
return {"status": resp.status}
|
||||
|
||||
async def nested(self, block: int, key: str | bytes = "FFFFFFFFFFFF",
|
||||
key_type: int = MF_KEY_A,
|
||||
target_block: int = 0, target_key_type: int = MF_KEY_A) -> dict:
|
||||
target_block: int = 0, target_key_type: int = MF_KEY_A,
|
||||
calibrate: bool = True) -> dict:
|
||||
"""Run nested attack to recover keys."""
|
||||
key_bytes = self._parse_key(key)
|
||||
payload = struct.pack("<BB", block, key_type) + key_bytes
|
||||
payload += struct.pack("<BB", target_block, target_key_type)
|
||||
# struct { block, keytype, target_block, target_keytype, bool calibrate, key[6] }
|
||||
payload = struct.pack("<BBBBB", block, key_type, target_block,
|
||||
target_key_type, 1 if calibrate else 0) + key_bytes
|
||||
resp = await self._t.send_ng(Cmd.HF_MIFARE_NESTED, payload, timeout=30.0)
|
||||
return {"status": resp.status, "data": resp.data.hex()}
|
||||
|
||||
async def cident(self) -> dict:
|
||||
"""Identify magic (gen1/gen2) card type."""
|
||||
resp = await self._t.send_ng(Cmd.HF_MIFARE_CIDENT, timeout=5.0)
|
||||
return {"status": resp.status, "data": resp.data.hex() if resp.data else None}
|
||||
# response struct { int16 isOK; u8 block; u8 keytype; u8 cuid[4];
|
||||
# u8 nt_a[4]; u8 ks_a[4]; u8 nt_b[4]; u8 ks_b[4] }
|
||||
if len(resp.data) < 24:
|
||||
return {"success": False, "status": resp.status}
|
||||
is_ok, r_block, r_keytype = struct.unpack_from("<hBB", resp.data, 0)
|
||||
return {
|
||||
"success": is_ok == 0,
|
||||
"is_ok": is_ok,
|
||||
"block": r_block,
|
||||
"key_type": r_keytype,
|
||||
"cuid": resp.data[4:8].hex(),
|
||||
"nt_a": resp.data[8:12].hex(),
|
||||
"ks_a": resp.data[12:16].hex(),
|
||||
"nt_b": resp.data[16:20].hex(),
|
||||
"ks_b": resp.data[20:24].hex(),
|
||||
}
|
||||
|
||||
async def cident(self, is_mfc: bool = True, key_type: int = MF_KEY_A,
|
||||
key: str | bytes = "FFFFFFFFFFFF") -> dict:
|
||||
"""Identify magic (gen1/gen2/...) card type."""
|
||||
key_bytes = self._parse_key(key)
|
||||
# struct { uint8 is_mfc; uint8 keytype; uint8 key[6] }
|
||||
payload = bytes([1 if is_mfc else 0, key_type & 0xFF]) + key_bytes
|
||||
resp = await self._t.send_ng(Cmd.HF_MIFARE_CIDENT, payload, timeout=5.0)
|
||||
|
||||
magic = 0
|
||||
if resp.status == 0 and len(resp.data) >= 2:
|
||||
magic = struct.unpack_from("<H", resp.data, 0)[0]
|
||||
return {"status": resp.status, "magic": magic, "is_magic": magic != 0}
|
||||
|
||||
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)}
|
||||
@@ -198,10 +198,22 @@ class HWCommands:
|
||||
"""Send break to stop long-running operations."""
|
||||
await self._t.send_ng_no_response(Cmd.BREAK_LOOP)
|
||||
|
||||
async def standalone(self) -> dict:
|
||||
"""Start standalone mode."""
|
||||
resp = await self._t.send_ng(Cmd.STANDALONE, timeout=5.0)
|
||||
return {"status": resp.status}
|
||||
async def standalone(self, arg: int = 1, mode: str | bytes = b"") -> dict:
|
||||
"""Start standalone mode.
|
||||
|
||||
Firmware casts the payload to a PACKED ``{uint8 arg; uint8 mlen;
|
||||
uint8 mode[10]}``. With ``mlen == 0`` it stashes ``arg`` at
|
||||
``BigBuf_get_EM_addr()[0]``; otherwise it copies ``mode`` (e.g.
|
||||
``"14a"``, ``"15"``, ``"iclass"`` for UniSniff). It then calls
|
||||
``RunMod()`` and never replies, so this is fire-and-forget.
|
||||
"""
|
||||
if isinstance(mode, str):
|
||||
mode = mode.encode("ascii")
|
||||
if len(mode) > 10:
|
||||
raise ValueError("standalone mode string max 10 bytes")
|
||||
payload = struct.pack("<BB", arg & 0xFF, len(mode)) + mode + bytes(10 - len(mode))
|
||||
await self._t.send_ng_no_response(Cmd.STANDALONE, payload)
|
||||
return {"status": 0}
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Reset the device."""
|
||||
|
||||
148
pm3py/core/lf.py
148
pm3py/core/lf.py
@@ -25,35 +25,73 @@ class T55xxCommands:
|
||||
}
|
||||
|
||||
async def writebl(self, block: int, data: int, page: int = 0,
|
||||
password: int | None = None, downlink_mode: int = 0) -> dict:
|
||||
"""Write a T55xx block."""
|
||||
password: int | None = None, downlink_mode: int = 0,
|
||||
test: bool = False) -> dict:
|
||||
"""Write a T55xx block.
|
||||
|
||||
``test`` uses the test-mode write (downlink opcode 01) instead of the standard opcode 10.
|
||||
Test mode is the datasheet's reconfiguration path (§5.10.3) — it can rewrite a tag that a
|
||||
normal write can't (a corrupted/locked config), provided the master key still permits it
|
||||
(allowed for key 9, denied once key 6 is set). Use it for recovery, not routine writes.
|
||||
"""
|
||||
pwd = password if password is not None else 0
|
||||
pwd_mode = 1 if password is not None else 0
|
||||
payload = struct.pack("<IIBBBB", data, pwd, block, page, pwd_mode, downlink_mode)
|
||||
# Firmware t55xx_write_block_t: data(4)+pwd(4)+blockno(1)+flags(1).
|
||||
# flags: 0x01 PwdMode, 0x02 Page, 0x04 test, 0x18 downlink_mode<<3.
|
||||
flags = 0
|
||||
if password is not None:
|
||||
flags |= 0x01
|
||||
if page:
|
||||
flags |= 0x02
|
||||
if test:
|
||||
flags |= 0x04
|
||||
flags |= (downlink_mode & 0x03) << 3
|
||||
payload = struct.pack("<IIBB", data, pwd, block, flags)
|
||||
resp = await self._t.send_ng(Cmd.LF_T55XX_WRITEBL, payload, timeout=5.0)
|
||||
return {"status": resp.status}
|
||||
|
||||
async def wakeup(self, password: int = 0) -> dict:
|
||||
async def wakeup(self, password: int = 0, downlink_mode: int = 0) -> dict:
|
||||
"""Wake up T55xx with password."""
|
||||
payload = struct.pack("<I", password)
|
||||
# Firmware struct {uint32 password; uint8 flags}; flags = downlink_mode<<3.
|
||||
flags = (downlink_mode & 0x03) << 3
|
||||
payload = struct.pack("<IB", password, flags)
|
||||
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("<B", flags)
|
||||
resp = await self._t.send_ng(Cmd.LF_T55XX_RESET_READ, payload)
|
||||
return {"status": resp.status}
|
||||
|
||||
# Default T55xx downlink-mode timing table (generic/PM3 Easy build), stored
|
||||
# as raw struct values (field-clocks x 8). Mirrors T55xx_Timing in
|
||||
# armsrc/lfops.c. Firmware exposes no GET, and CMD_LF_T55XX_SET_CONFIG is
|
||||
# write-only (never replies), so this config is maintained client-side.
|
||||
_DEFAULT_MODES = (
|
||||
{"start_gap": 248, "write_gap": 160, "write_0": 144, "write_1": 400,
|
||||
"read_gap": 120, "write_2": 0, "write_3": 0}, # Fixed
|
||||
{"start_gap": 248, "write_gap": 160, "write_0": 144, "write_1": 400,
|
||||
"read_gap": 120, "write_2": 0, "write_3": 0}, # Long Leading Ref.
|
||||
{"start_gap": 248, "write_gap": 160, "write_0": 144, "write_1": 320,
|
||||
"read_gap": 120, "write_2": 0, "write_3": 0}, # Leading 0
|
||||
{"start_gap": 248, "write_gap": 160, "write_0": 144, "write_1": 272,
|
||||
"read_gap": 120, "write_2": 400, "write_3": 528}, # 1 of 4
|
||||
)
|
||||
|
||||
async def config(self) -> dict:
|
||||
"""Get T55xx timing configuration."""
|
||||
resp = await self._t.send_ng(Cmd.LF_T55XX_SET_CONFIG)
|
||||
# Response is t55xx_configurations_t: 4 modes x 7 uint16_t each
|
||||
modes = []
|
||||
for i in range(4):
|
||||
offset = i * 14
|
||||
if offset + 14 <= len(resp.data):
|
||||
fields = struct.unpack_from("<HHHHHHH", resp.data, offset)
|
||||
modes.append({
|
||||
"start_gap": fields[0], "write_gap": fields[1],
|
||||
"write_0": fields[2], "write_1": fields[3],
|
||||
"read_gap": fields[4], "write_2": fields[5], "write_3": fields[6],
|
||||
})
|
||||
return {"modes": modes}
|
||||
"""Return T55xx downlink timing configuration.
|
||||
|
||||
The firmware exposes no GET for the T55xx timing table, and
|
||||
CMD_LF_T55XX_SET_CONFIG is write-only (never replies), so the default
|
||||
table is maintained client-side.
|
||||
"""
|
||||
return {"modes": [dict(m) for m in self._DEFAULT_MODES]}
|
||||
|
||||
|
||||
class LFCommands:
|
||||
@@ -69,12 +107,12 @@ class LFCommands:
|
||||
divisor: LF frequency divisor (19-255). 95=125kHz, 88=134kHz.
|
||||
Freq = 12000/(divisor+1) kHz.
|
||||
"""
|
||||
# Send init
|
||||
payload = struct.pack("<BBB", 1, divisor, 0)
|
||||
# Send init — firmware requires length == 2 ({mode, divisor})
|
||||
payload = struct.pack("<BB", 1, divisor)
|
||||
await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_LF, payload, timeout=5.0)
|
||||
|
||||
# Poll for result
|
||||
payload = struct.pack("<BBB", 2, divisor, 0)
|
||||
# Measure — reply carries the antenna voltage as a uint32 (mV)
|
||||
payload = struct.pack("<BB", 2, divisor)
|
||||
resp = await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_LF, payload, timeout=5.0)
|
||||
|
||||
voltage = struct.unpack_from("<I", resp.data, 0)[0] if len(resp.data) >= 4 else 0
|
||||
@@ -94,23 +132,32 @@ class LFCommands:
|
||||
|
||||
return result
|
||||
|
||||
async def read(self, samples: int = 12288, verbose: bool = False,
|
||||
on_progress: ProgressCallback = None) -> dict:
|
||||
"""Read LF ADC samples into device buffer."""
|
||||
val = (samples & 0x3FFFFFFF)
|
||||
async def _acquire(self, cmd: int, samples: int, verbose: bool = False) -> dict:
|
||||
"""Capture LF ADC samples into BigBuf. Shared by read (reader field ON) and sniff
|
||||
(field OFF). Payload is lf_sample_payload_t — a PACKED bitfield: samples:30, realtime:1,
|
||||
verbose:1, cotag:1 (5 bytes)."""
|
||||
word = (samples & 0x3FFFFFFF)
|
||||
if verbose:
|
||||
val |= (1 << 31)
|
||||
payload = struct.pack("<I", val)
|
||||
resp = await self._t.send_ng(Cmd.LF_SNIFF_RAW_ADC, payload, timeout=10.0)
|
||||
word |= (1 << 31) # bit 31 = verbose; bit 30 = realtime (0)
|
||||
payload = struct.pack("<IB", word, 0) # trailing byte carries cotag (0)
|
||||
resp = await self._t.send_ng(cmd, payload, timeout=10.0)
|
||||
return {
|
||||
"status": resp.status,
|
||||
"samples": struct.unpack_from("<I", resp.data, 0)[0] if len(resp.data) >= 4 else 0,
|
||||
}
|
||||
|
||||
async def read(self, samples: int = 12288, verbose: bool = False,
|
||||
on_progress: ProgressCallback = None) -> dict:
|
||||
"""Reader-mode LF acquisition: turn the reader field ON and sample the tag's load
|
||||
modulation (this is ``lf read`` — CMD_LF_ACQ_RAW_ADC). Use this before download_samples
|
||||
to read an EM4100/T5577/HID. (SNIFF, field-off, captures nothing from a tag.)"""
|
||||
return await self._acquire(Cmd.LF_ACQ_RAW_ADC, samples, verbose)
|
||||
|
||||
async def sniff(self, samples: int = 12288,
|
||||
on_progress: ProgressCallback = None) -> dict:
|
||||
"""Sniff LF signal."""
|
||||
return await self.read(samples=samples, on_progress=on_progress)
|
||||
"""Sniff LF: reader field OFF, capture an external reader<->tag exchange
|
||||
(CMD_LF_SNIFF_RAW_ADC)."""
|
||||
return await self._acquire(Cmd.LF_SNIFF_RAW_ADC, samples)
|
||||
|
||||
async def sim(self, gap: int = 0, data: bytes = b"",
|
||||
on_progress: ProgressCallback = None) -> dict:
|
||||
@@ -155,41 +202,26 @@ class LFCommands:
|
||||
trigger_threshold if trigger_threshold is not None else current.get("trigger_threshold", 0),
|
||||
samples_to_skip if samples_to_skip is not None else current.get("samples_to_skip", 0),
|
||||
0)
|
||||
resp = await self._t.send_ng(Cmd.LF_SAMPLING_SET_CONFIG, payload)
|
||||
return {"status": resp.status, **await self.config()}
|
||||
# Firmware never replies to LF_SAMPLING_SET_CONFIG — fire-and-forget
|
||||
await self._t.send_ng_no_response(Cmd.LF_SAMPLING_SET_CONFIG, payload)
|
||||
return {"status": 0, **await self.config()}
|
||||
|
||||
async def download_samples(self, start: int = 0, count: int = 30000,
|
||||
on_progress: ProgressCallback = None) -> bytes:
|
||||
"""Download raw ADC samples from the device's BigBuf.
|
||||
|
||||
Must call read() or search() first to capture samples.
|
||||
Returns raw bytes (1 byte per sample, unsigned 0-255).
|
||||
Must call read() or search() first to capture samples. Returns raw bytes (1 byte per
|
||||
sample, unsigned 0-255). The device streams the data as OLD-format CMD_DOWNLOADED_BIGBUF
|
||||
frames then a final ACK — handled by the transport's bulk-download reader.
|
||||
"""
|
||||
buf = bytearray()
|
||||
remaining = count
|
||||
offset = start
|
||||
|
||||
while remaining > 0:
|
||||
chunk = min(remaining, PM3_CMD_DATA_SIZE)
|
||||
resp = await self._t.send_mix(
|
||||
Cmd.DOWNLOAD_BIGBUF, offset, chunk, 0, timeout=5.0,
|
||||
data = await self._t.download_bulk(
|
||||
Cmd.DOWNLOAD_BIGBUF, start, count, Cmd.DOWNLOADED_BIGBUF, Cmd.ACK, timeout=5.0,
|
||||
)
|
||||
# Device responds with CMD_DOWNLOADED_BIGBUF containing the data
|
||||
dl_resp = await self._t.read_response(timeout=5.0)
|
||||
if dl_resp.data:
|
||||
buf.extend(dl_resp.data)
|
||||
|
||||
if on_progress:
|
||||
ret = on_progress({
|
||||
"type": "download", "downloaded": len(buf), "total": count,
|
||||
})
|
||||
ret = on_progress({"type": "download", "downloaded": len(data), "total": count})
|
||||
if asyncio.iscoroutine(ret):
|
||||
await ret
|
||||
|
||||
offset += chunk
|
||||
remaining -= chunk
|
||||
|
||||
return bytes(buf[:count])
|
||||
return data
|
||||
|
||||
async def search(self, on_progress: ProgressCallback = None) -> "LFSearchResult":
|
||||
"""Search for LF tags. Captures samples and returns a result with callable next steps.
|
||||
|
||||
@@ -25,6 +25,42 @@ RESP_POSTAMBLE_SIZE = 2
|
||||
MIX_ARGS_FMT = "<QQQ"
|
||||
MIX_ARGS_SIZE = 24
|
||||
|
||||
# --- Bootloader / flashing: legacy OLD frame ---
|
||||
# PacketCommandOLD / PacketResponseOLD: uint64 cmd + uint64 arg[3] + uint8 d[512].
|
||||
# No preamble magic, no CRC, no length field — always this fixed size on the wire.
|
||||
OLD_FRAME_FMT = "<QQQQ" # cmd + arg0 + arg1 + arg2 (32 bytes), then 512 data bytes
|
||||
OLD_FRAME_HDR_SIZE = 32
|
||||
OLD_FRAME_SIZE = OLD_FRAME_HDR_SIZE + PM3_CMD_DATA_SIZE # 544
|
||||
|
||||
# CMD_DEVICE_INFO reply flags (carried in arg[0]), from pm3_cmd.h
|
||||
DEVICE_INFO_FLAG_BOOTROM_PRESENT = 1 << 0
|
||||
DEVICE_INFO_FLAG_OSIMAGE_PRESENT = 1 << 1
|
||||
DEVICE_INFO_FLAG_CURRENT_MODE_BOOTROM = 1 << 2
|
||||
DEVICE_INFO_FLAG_CURRENT_MODE_OS = 1 << 3
|
||||
DEVICE_INFO_FLAG_UNDERSTANDS_START_FLASH = 1 << 4
|
||||
DEVICE_INFO_FLAG_UNDERSTANDS_CHIP_INFO = 1 << 5
|
||||
DEVICE_INFO_FLAG_UNDERSTANDS_VERSION = 1 << 6
|
||||
DEVICE_INFO_FLAG_UNDERSTANDS_READ_MEM = 1 << 7
|
||||
|
||||
# Required in CMD_START_FLASH arg[2] to unlock writes to the bootrom region ("DOIT")
|
||||
START_FLASH_MAGIC = 0x54494F44
|
||||
|
||||
# AT91SAM7 flash geometry / memory map (bytes)
|
||||
FLASH_START = 0x00100000 # start of internal flash
|
||||
BOOTLOADER_END = 0x00102000 # end of bootrom / start of OS image (osimage origin)
|
||||
FLASH_END_256K = 0x00140000 # window end on a 256 KB part
|
||||
FLASH_END_512K = 0x00180000 # window end on a 512 KB part
|
||||
FLASH_PAGE_SIZE = 256 # SAM7 flash page
|
||||
FLASH_BLOCK_SIZE = 512 # one CMD_FINISH_WRITE programs 2 pages = one 512-byte block
|
||||
|
||||
|
||||
def bl_version_code(major: int, minor: int, patch: int) -> int:
|
||||
"""Pack a bootloader version the way the firmware does (BL_MAKE_VERSION)."""
|
||||
return (major << 22) | (minor << 12) | patch
|
||||
|
||||
|
||||
BL_VERSION_1_0_0 = bl_version_code(1, 0, 0)
|
||||
|
||||
# --- CRC-16/A (ISO 14443-3) ---
|
||||
_CRC_TABLE: list[int] = []
|
||||
|
||||
@@ -68,6 +104,23 @@ def crc16_a(data: bytes) -> int:
|
||||
return crc
|
||||
|
||||
|
||||
def crc15(data: bytes) -> int:
|
||||
"""ISO 15693 / NFC Type 5 CRC (CRC-16/X-25): poly=0x8408 (reflected), init=0xFFFF,
|
||||
refin/refout=true, xorout=0xFFFF. Check value crc15(b"123456789") == 0x906E."""
|
||||
crc = 0xFFFF
|
||||
for b in data:
|
||||
crc ^= b
|
||||
for _ in range(8):
|
||||
crc = (crc >> 1) ^ 0x8408 if (crc & 1) else (crc >> 1)
|
||||
return (~crc) & 0xFFFF
|
||||
|
||||
|
||||
def crc15_bytes(data: bytes) -> bytes:
|
||||
"""ISO 15693 CRC as appended on the wire (LSB byte first)."""
|
||||
c = crc15(data)
|
||||
return bytes([c & 0xFF, (c >> 8) & 0xFF])
|
||||
|
||||
|
||||
# --- Error codes ---
|
||||
class PM3Status(IntEnum):
|
||||
SUCCESS = 0
|
||||
@@ -140,7 +193,8 @@ class Cmd(IntEnum):
|
||||
TIA = 0x0117
|
||||
BREAK_LOOP = 0x0118
|
||||
SET_TEAROFF = 0x0119
|
||||
LED_CONTROL = 0x011A
|
||||
SET_HF_FIELD_TIMEOUT = 0x011A
|
||||
LED_CONTROL = 0x011B # our firmware fork (0x011A is upstream's SET_HF_FIELD_TIMEOUT)
|
||||
GET_DBGMODE = 0x0120
|
||||
|
||||
# FPGA
|
||||
@@ -204,6 +258,7 @@ class Cmd(IntEnum):
|
||||
|
||||
HF_SNIFF_STREAM = 0x0337
|
||||
HF_SNIFF_STATUS = 0x0338
|
||||
HF_ISO14443A_SIM_TRACE = 0x0339 # FW->Host: live 14a sim trace (fire-and-forget)
|
||||
|
||||
# Sim table commands (firmware response table)
|
||||
SIM_TABLE_UPLOAD = 0x0900
|
||||
|
||||
@@ -11,8 +11,31 @@ from .protocol import (
|
||||
CMD_PREAMBLE_SIZE, CMD_POSTAMBLE_SIZE,
|
||||
RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE,
|
||||
PM3_CMD_DATA_SIZE, MIX_ARGS_SIZE,
|
||||
OLD_FRAME_FMT, OLD_FRAME_HDR_SIZE, OLD_FRAME_SIZE,
|
||||
)
|
||||
|
||||
# Frames the device emits asynchronously (debug output), independent of any command. They can
|
||||
# arrive in the middle of a command's response stream on debug-heavy firmware, so the response
|
||||
# readers skip them to return the real reply (mirrors the C client routing debug to its logger).
|
||||
_ASYNC_FRAME_CMDS = frozenset({
|
||||
Cmd.DEBUG_PRINT_STRING, Cmd.DEBUG_PRINT_INTEGERS, Cmd.DEBUG_PRINT_BYTES,
|
||||
})
|
||||
_MAX_ASYNC_SKIP = 64 # bound the skip loop so a chatty/looping device can't hang a read
|
||||
|
||||
|
||||
def _wtx_ms(resp) -> int | None:
|
||||
"""Waiting-Time-eXtension value (ms) if ``resp`` is a CMD_WTX control frame, else ``None``.
|
||||
|
||||
The firmware emits CMD_WTX to ask the host to *keep waiting* — notably on FPGA bitstream
|
||||
reload (``send_wtx(FPGA_LOAD_WAIT_TIME)``, 1500ms), which the first HF command after connect
|
||||
triggers. It is NOT a command reply. The C client extends its response timeout by this value
|
||||
and reads on (comms.c). pm3py must do the same: otherwise the WTX frame is returned as the
|
||||
reply, and — because every later read then grabs the *previous* command's response — one WTX
|
||||
permanently desyncs the whole session (the bug behind the '15693 garbage/shift' reports)."""
|
||||
if resp.cmd == Cmd.WTX and len(resp.data) >= 2:
|
||||
return struct.unpack_from("<H", resp.data)[0]
|
||||
return None
|
||||
|
||||
|
||||
def _crc_to_wire(crc: int) -> int:
|
||||
"""Byte-swap CRC to match PM3 wire format: (low << 8) | high."""
|
||||
@@ -93,6 +116,30 @@ def decode_response_frame(data: bytes) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def encode_old_frame(cmd: int, arg0: int = 0, arg1: int = 0, arg2: int = 0,
|
||||
data: bytes = b"") -> bytes:
|
||||
"""Encode a legacy OLD command frame (used by the bootloader / flasher).
|
||||
|
||||
Fixed 544 bytes: uint64 cmd + uint64 arg[3] + uint8 d[512]. No magic, no CRC.
|
||||
"""
|
||||
if len(data) > PM3_CMD_DATA_SIZE:
|
||||
raise ValueError(f"OLD frame data too large: {len(data)} > {PM3_CMD_DATA_SIZE}")
|
||||
header = struct.pack(OLD_FRAME_FMT, cmd, arg0, arg1, arg2)
|
||||
return header + data + bytes(PM3_CMD_DATA_SIZE - len(data))
|
||||
|
||||
|
||||
def decode_old_frame(raw: bytes) -> dict:
|
||||
"""Decode a 544-byte OLD response frame into a dict (cmd, oldarg, data)."""
|
||||
if len(raw) < OLD_FRAME_SIZE:
|
||||
raise ValueError(f"OLD frame too short: {len(raw)} < {OLD_FRAME_SIZE}")
|
||||
cmd, arg0, arg1, arg2 = struct.unpack_from(OLD_FRAME_FMT, raw, 0)
|
||||
return {
|
||||
"cmd": cmd,
|
||||
"oldarg": [arg0, arg1, arg2],
|
||||
"data": raw[OLD_FRAME_HDR_SIZE:OLD_FRAME_SIZE],
|
||||
}
|
||||
|
||||
|
||||
class PM3Error(Exception):
|
||||
"""Proxmark3 communication error."""
|
||||
def __init__(self, message: str, status: int = 0):
|
||||
@@ -130,6 +177,10 @@ class PM3Transport:
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
# set when a read times out: the command's response may still be in flight and would be
|
||||
# misread as the *next* command's reply (the "04 BB = stale UID" desync). The next send
|
||||
# drains it first. Cheap: only fires after a timeout, which is rare on a healthy link.
|
||||
self._resync_needed = False
|
||||
|
||||
async def connect(self) -> None:
|
||||
import serial_asyncio
|
||||
@@ -159,23 +210,40 @@ class PM3Transport:
|
||||
async def send_frame(self, frame: bytes) -> None:
|
||||
if not self._writer:
|
||||
raise PM3Error("Not connected")
|
||||
if self._resync_needed: # a prior command timed out; clear its late response first
|
||||
await self._drain_input()
|
||||
self._resync_needed = False
|
||||
self._writer.write(frame)
|
||||
await self._writer.drain()
|
||||
|
||||
async def read_response(self, timeout: float = 2.0) -> PM3Response:
|
||||
"""Read and decode one response frame from the device."""
|
||||
"""Read one command reply, skipping async debug-print frames the device interleaves.
|
||||
|
||||
Debug-heavy firmware emits DEBUG_PRINT_* frames asynchronously, which can land in the
|
||||
middle of a command's response stream. The C client routes those to its logger and keeps
|
||||
reading for the real reply; pm3py must do the same, or it returns a debug line misparsed
|
||||
as the reply — the cause of garbage sample counts and desynced downloads."""
|
||||
if not self._reader:
|
||||
raise PM3Error("Not connected")
|
||||
|
||||
extra = 0.0 # accumulated WTX wait-extension (seconds)
|
||||
for _ in range(_MAX_ASYNC_SKIP):
|
||||
try:
|
||||
# Read until we find response magic
|
||||
raw = await asyncio.wait_for(
|
||||
self._read_response_frame(), timeout=timeout,
|
||||
)
|
||||
d = decode_response_frame(raw)
|
||||
return PM3Response.from_dict(d)
|
||||
raw = await asyncio.wait_for(self._read_response_frame(), timeout=timeout + extra)
|
||||
except asyncio.TimeoutError:
|
||||
self._resync_needed = True # late response would desync the next read — drain it
|
||||
raise PM3Error("Response timeout", status=-4)
|
||||
try:
|
||||
resp = PM3Response.from_dict(decode_response_frame(raw))
|
||||
except ValueError:
|
||||
continue # malformed frame — resync on the next magic
|
||||
wtx = _wtx_ms(resp)
|
||||
if wtx is not None:
|
||||
extra += wtx / 1000.0 # device asked to wait longer — keep reading
|
||||
continue
|
||||
if resp.cmd in _ASYNC_FRAME_CMDS:
|
||||
continue # device debug output — not our reply
|
||||
return resp
|
||||
raise PM3Error("Too many async frames without a reply", status=-4)
|
||||
|
||||
async def _read_response_frame(self) -> bytes:
|
||||
"""Read a complete response frame, scanning for magic bytes."""
|
||||
@@ -234,6 +302,157 @@ class PM3Transport:
|
||||
frame = encode_ng_frame(cmd, payload)
|
||||
await self.send_frame(frame)
|
||||
|
||||
async def send_old(self, cmd: int, arg0: int = 0, arg1: int = 0, arg2: int = 0,
|
||||
data: bytes = b"", timeout: float = 5.0) -> PM3Response:
|
||||
"""Send a legacy OLD frame (bootloader/flasher) and read one reply.
|
||||
|
||||
The reply may be an OLD frame (from the bootrom) or an NG frame (from the
|
||||
OS) — the reader branches on the leading magic.
|
||||
"""
|
||||
async with self._lock:
|
||||
frame = encode_old_frame(cmd, arg0, arg1, arg2, data)
|
||||
await self.send_frame(frame)
|
||||
return await self._read_any_response(timeout=timeout)
|
||||
|
||||
async def send_old_no_response(self, cmd: int, arg0: int = 0, arg1: int = 0,
|
||||
arg2: int = 0, data: bytes = b"") -> None:
|
||||
"""Send an OLD frame without waiting (e.g. START_FLASH handover, RESET)."""
|
||||
async with self._lock:
|
||||
frame = encode_old_frame(cmd, arg0, arg1, arg2, data)
|
||||
await self.send_frame(frame)
|
||||
|
||||
async def _drain_input(self, quiet: float = 0.08) -> int:
|
||||
"""Discard buffered/stale bytes (a prior partial transaction, or another process that
|
||||
touched the port) so the next request/response starts byte-aligned. OLD-format frames
|
||||
carry no magic to resync on, so a bulk download MUST begin on a clean stream. Returns the
|
||||
number of bytes dropped."""
|
||||
if not self._reader:
|
||||
return 0
|
||||
try: # clear the OS/pyserial buffer too
|
||||
tr = self._writer.transport
|
||||
if hasattr(tr, "serial"):
|
||||
tr.serial.reset_input_buffer()
|
||||
except Exception:
|
||||
pass
|
||||
dropped = 0
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(self._reader.read(4096), timeout=quiet)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
dropped += len(chunk)
|
||||
return dropped
|
||||
|
||||
async def download_bulk(self, cmd: int, start: int, count: int, data_cmd: int,
|
||||
ack_cmd: int, timeout: float = 5.0, on_bytes=None) -> bytes:
|
||||
"""Run a bulk BigBuf/EML/SPIFFS download and return the concatenated bytes.
|
||||
|
||||
The firmware answers one download request (a MIX frame) by streaming ``count`` bytes as
|
||||
a run of *OLD*-format ``data_cmd`` frames (no magic — that's why the plain NG reader
|
||||
can't see them and the naive download hangs), then a final ``ack_cmd`` frame. We flush
|
||||
any stale input first (OLD frames can't resync mid-stream), then read with the NG/OLD-
|
||||
agnostic reader until the ACK, or until we've collected ``count`` bytes (draining the
|
||||
trailing ACK best-effort so it can't poison the next command)."""
|
||||
async with self._lock:
|
||||
await self._drain_input() # start byte-aligned — no stale frames
|
||||
await self.send_frame(encode_mix_frame(cmd, start, count, 0))
|
||||
buf = bytearray()
|
||||
while True:
|
||||
try:
|
||||
resp = await self._read_any_response(timeout=timeout)
|
||||
except PM3Error:
|
||||
break # timeout (e.g. ACK lost) — return what we have
|
||||
if resp.cmd == ack_cmd:
|
||||
break
|
||||
if resp.cmd == data_cmd and resp.data:
|
||||
length = resp.oldarg[1] if resp.oldarg and len(resp.oldarg) > 1 else len(resp.data)
|
||||
buf.extend(resp.data[:max(0, length)])
|
||||
if on_bytes is not None:
|
||||
on_bytes(len(buf))
|
||||
if count and len(buf) >= count:
|
||||
try: # got all the data; consume the trailing ACK
|
||||
await self._read_any_response(timeout=1.0)
|
||||
except PM3Error:
|
||||
pass
|
||||
break
|
||||
return bytes(buf[:count]) if count else bytes(buf)
|
||||
|
||||
async def _read_any_response(self, timeout: float = 5.0) -> PM3Response:
|
||||
"""Read one NG-or-OLD frame, skipping async debug-print frames (as read_response does)."""
|
||||
if not self._reader:
|
||||
raise PM3Error("Not connected")
|
||||
extra = 0.0 # accumulated WTX wait-extension (seconds)
|
||||
for _ in range(_MAX_ASYNC_SKIP):
|
||||
try:
|
||||
raw = await asyncio.wait_for(self._read_any_frame(), timeout=timeout + extra)
|
||||
except asyncio.TimeoutError:
|
||||
self._resync_needed = True # late response would desync the next read — drain it
|
||||
raise PM3Error("Response timeout", status=-4)
|
||||
magic = struct.unpack_from("<I", raw, 0)[0]
|
||||
try:
|
||||
if magic == RESP_PREAMBLE_MAGIC:
|
||||
resp = PM3Response.from_dict(decode_response_frame(raw))
|
||||
else:
|
||||
d = decode_old_frame(raw)
|
||||
resp = PM3Response(cmd=d["cmd"], status=0, reason=0, ng=False,
|
||||
data=d["data"], oldarg=d["oldarg"])
|
||||
except ValueError:
|
||||
continue
|
||||
wtx = _wtx_ms(resp)
|
||||
if wtx is not None:
|
||||
extra += wtx / 1000.0 # device asked to wait longer — keep reading
|
||||
continue
|
||||
if resp.cmd in _ASYNC_FRAME_CMDS:
|
||||
continue # device debug output — not our reply
|
||||
return resp
|
||||
raise PM3Error("Too many async frames without a reply", status=-4)
|
||||
|
||||
async def _read_any_frame(self) -> bytes:
|
||||
"""Read a complete frame, branching NG vs OLD on the leading 4-byte magic.
|
||||
|
||||
Unlike _read_response_frame this does NOT scan/discard — the bootloader
|
||||
is queried on a freshly (re)opened port with no in-flight junk.
|
||||
"""
|
||||
assert self._reader is not None
|
||||
head = await self._reader.readexactly(4)
|
||||
magic = struct.unpack_from("<I", head, 0)[0]
|
||||
if magic == RESP_PREAMBLE_MAGIC:
|
||||
buf = bytearray(head)
|
||||
buf.extend(await self._reader.readexactly(RESP_PREAMBLE_SIZE - 4))
|
||||
payload_len = struct.unpack_from("<H", buf, 4)[0] & 0x7FFF
|
||||
buf.extend(await self._reader.readexactly(payload_len + RESP_POSTAMBLE_SIZE))
|
||||
return bytes(buf)
|
||||
# OLD frame — fixed 544 bytes total, no magic/CRC/length
|
||||
rest = await self._reader.readexactly(OLD_FRAME_SIZE - 4)
|
||||
return head + rest
|
||||
|
||||
async def reopen(self, wait: float = 1.0, retries: int = 60,
|
||||
retry_delay: float = 1.0, port: str | None = None) -> None:
|
||||
"""Close and reopen the serial port, tolerating USB re-enumeration.
|
||||
|
||||
The device drops off the bus during the OS->bootloader handover and after
|
||||
HARDWARE_RESET, then reappears (VID/PID or path may shift). Mirrors the C
|
||||
client's close / wait / poll-reopen sequence.
|
||||
"""
|
||||
try:
|
||||
await self.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
if port is not None:
|
||||
self.port = port
|
||||
await asyncio.sleep(wait)
|
||||
last_err: Exception | None = None
|
||||
for _ in range(retries):
|
||||
try:
|
||||
await self.connect()
|
||||
return
|
||||
except Exception as e: # port not back yet
|
||||
last_err = e
|
||||
await asyncio.sleep(retry_delay)
|
||||
raise PM3Error(f"Device did not reappear after reset: {last_err}")
|
||||
|
||||
async def download_bigbuf(self, offset: int = 0, length: int = PM3_CMD_DATA_SIZE,
|
||||
timeout: float = 4.0) -> tuple[bytes, int]:
|
||||
"""Download data from device BigBuf.
|
||||
|
||||
129
pm3py/flash_cli.py
Normal file
129
pm3py/flash_cli.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""``pm3flash`` — check the device firmware and flash the pinned fork build.
|
||||
|
||||
Auto-detects the Proxmark3, compares its firmware against the build this pm3py is
|
||||
pinned to, and (with confirmation) flashes the matching ``fullimage.elf``. By
|
||||
default it only flashes when the device does not already match the pin.
|
||||
|
||||
pm3flash # flash the pinned firmware if the device differs
|
||||
pm3flash --image fw.elf # flash a specific image
|
||||
pm3flash --force # flash even if already up to date
|
||||
pm3flash --yes # no confirmation prompt
|
||||
|
||||
Image source (in order): --image, then $PM3PY_FIRMWARE, then a local build
|
||||
(firmware/armsrc/obj/fullimage.elf). Auto-download of the pinned release is a
|
||||
later layer that also feeds this same resolution.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from .core.client import Proxmark3
|
||||
from .core.transport import PM3Error
|
||||
from .core.flash import FlashError, resolve_image, build_firmware
|
||||
from ._firmware import matches
|
||||
|
||||
|
||||
def _print_progress(ev: dict) -> None:
|
||||
pct = 100 * ev["block"] // ev["total"]
|
||||
sys.stdout.write(f"\r flashing block {ev['block']}/{ev['total']} ({pct}%)")
|
||||
sys.stdout.flush()
|
||||
if ev["block"] == ev["total"]:
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
async def _run(args) -> int:
|
||||
# Connect and read the current firmware.
|
||||
try:
|
||||
pm3 = Proxmark3(args.port)
|
||||
await pm3.connect()
|
||||
except PM3Error as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
fw = pm3.firmware
|
||||
print(f"device firmware: {fw.version_string or '(unknown — bootloader mode?)'}")
|
||||
print(f"pinned firmware: {fw.expected_firmware}")
|
||||
|
||||
if fw.matches_pin and not args.force:
|
||||
print("device already runs the pinned firmware — nothing to do.")
|
||||
await pm3.disconnect()
|
||||
return 0
|
||||
|
||||
# Resolve the image up front so a missing file fails before we prompt/flash.
|
||||
try:
|
||||
image = resolve_image(args.image)
|
||||
except FlashError as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
await pm3.disconnect()
|
||||
return 2
|
||||
|
||||
if not args.yes and sys.stdin.isatty():
|
||||
reason = "forced" if fw.matches_pin else "firmware differs from pin"
|
||||
ans = input(f"Flash {fw.expected_firmware} onto {pm3._transport.port} "
|
||||
f"({reason})? [y/N] ").strip().lower()
|
||||
if ans not in ("y", "yes"):
|
||||
print("aborted.")
|
||||
await pm3.disconnect()
|
||||
return 1
|
||||
|
||||
try:
|
||||
result = await pm3.flasher.flash(
|
||||
image, allow_bootrom=args.allow_bootrom, on_progress=_print_progress)
|
||||
except (PM3Error, FlashError) as e:
|
||||
print(f"\nflash failed: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(f"wrote {result['bytes']} bytes in {result['blocks']} blocks; device reset.")
|
||||
|
||||
# Verify: the device rebooted into the new OS — reopen and re-read the version.
|
||||
try:
|
||||
await pm3._transport.reopen()
|
||||
ver = await pm3.hw.version()
|
||||
vstr = ver.get("version_string", "")
|
||||
if matches(vstr):
|
||||
print(f"verified: now running {vstr}")
|
||||
rc = 0
|
||||
else:
|
||||
print(f"warning: post-flash firmware '{vstr}' does not match the pin",
|
||||
file=sys.stderr)
|
||||
rc = 3
|
||||
except PM3Error as e:
|
||||
print(f"note: flashed OK but could not re-read the version ({e})")
|
||||
rc = 0
|
||||
await pm3.disconnect()
|
||||
return rc
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="pm3flash",
|
||||
description="Flash the pinned Proxmark3 fork firmware.")
|
||||
p.add_argument("--port", help="serial port (default: auto-detect)")
|
||||
src = p.add_mutually_exclusive_group()
|
||||
src.add_argument("--image", help="path to fullimage.elf "
|
||||
"(default: $PM3PY_FIRMWARE or local build)")
|
||||
src.add_argument("--build", metavar="PLATFORM",
|
||||
help="build the firmware for PLATFORM (e.g. PM3GENERIC for "
|
||||
"Easy, PM3RDV4) from the submodule, then flash it")
|
||||
p.add_argument("--force", action="store_true",
|
||||
help="flash even if the device already matches the pin")
|
||||
p.add_argument("--allow-bootrom", action="store_true",
|
||||
help="permit writing the bootrom region (brick risk)")
|
||||
p.add_argument("-y", "--yes", action="store_true",
|
||||
help="do not prompt for confirmation")
|
||||
args = p.parse_args(argv)
|
||||
if args.build:
|
||||
try:
|
||||
args.image = str(build_firmware(args.build))
|
||||
except FlashError as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 2
|
||||
# --build is an explicit "put what I just built on the device" — flash it
|
||||
# regardless of the pin. A dirty/iterated build keeps the same base SHA, so
|
||||
# matches_pin can't tell the new image apart from the old firmware.
|
||||
args.force = True
|
||||
return asyncio.run(_run(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
22
pm3py/lf/__init__.py
Normal file
22
pm3py/lf/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Low-frequency signal demodulation.
|
||||
|
||||
The Proxmark firmware returns *raw ADC envelope samples* for LF reads — the actual
|
||||
demodulation (ASK/FSK/PSK, Manchester/biphase, protocol framing) is the client's job. The C
|
||||
client has a large DSP stack for this; pm3py historically had none, so LF ``identify`` could
|
||||
never see a tag. This package is that missing stack, in Python.
|
||||
|
||||
Layers:
|
||||
|
||||
* :mod:`pm3py.lf.dsp` — modulation-agnostic primitives (threshold, clock recovery, ASK
|
||||
binarize, Manchester/biphase decode) that turn an envelope into a bit stream.
|
||||
* :mod:`pm3py.lf.protocols` — per-tag framing (EM4100, T55xx config, HID, ...) that turns a
|
||||
bit stream into a decoded credential, reusing the transponder models' own validators.
|
||||
* :mod:`pm3py.lf.capture` — pull an envelope off a live device (or accept one for tests) and
|
||||
run every decoder, returning the best match.
|
||||
|
||||
Everything is testable without hardware: the LF transponder models already *encode* these
|
||||
protocols, so a synthetic envelope built from an encoder round-trips through the demod.
|
||||
"""
|
||||
from .capture import demod_samples, demod_raw, read_config, identify_lf
|
||||
|
||||
__all__ = ["demod_samples", "demod_raw", "read_config", "identify_lf"]
|
||||
167
pm3py/lf/ask.py
Normal file
167
pm3py/lf/ask.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""ASK demodulation — a faithful port of the Proxmark ASK stack (firmware/common/lfdemod.c).
|
||||
|
||||
Real LF envelopes are asymmetric: the tag load-modulation gives a sharp fall but the antenna
|
||||
recovers on a slow RC ramp, and heavily-coupled tags saturate the rails. A naive threshold +
|
||||
fixed-step resample drifts and bit-slips on that. The C client instead:
|
||||
|
||||
1. computeSignalProperties -> high/low/mean (robust middle mean).
|
||||
2. getHiLo(75) -> clip thresholds 75% of the way mean->rail (25% clip), giving hysteresis.
|
||||
3. cleanAskRawDemod -> walk rail-to-rail waves, quantising each to a full- or half-clock symbol
|
||||
(so the ramp between rails never triggers a false edge, and every wave re-syncs the clock).
|
||||
4. manrawdecode -> Manchester the raw stream to data bits.
|
||||
|
||||
This module ports 1-4. Framing (EM4100 header/parity, T55xx config) stays in protocols.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import dsp
|
||||
|
||||
_CLOCKS = (8, 16, 32, 40, 50, 64, 100, 128)
|
||||
|
||||
|
||||
def trim_to_signal(samples, win: int = 256, thresh: int = 40):
|
||||
"""Drop the leading field-settle transient (low-swing) and trailing unfilled zeros — the
|
||||
parts of a live capture that carry no modulation and would poison thresholding/clocking."""
|
||||
n = len(samples)
|
||||
start = 0
|
||||
for i in range(0, max(1, n - win), 32):
|
||||
w = samples[i:i + win]
|
||||
if w and max(w) - min(w) > thresh:
|
||||
start = i
|
||||
break
|
||||
end = n
|
||||
while end > start and samples[end - 1] == 0:
|
||||
end -= 1
|
||||
return samples[start:end]
|
||||
|
||||
|
||||
def signal_props(samples):
|
||||
"""Port of computeSignalProperties: (high, low, mean) where high/low are the rails and mean
|
||||
is the robust mean of the middle 80% (ignoring the top/bottom 10% so rails don't skew it)."""
|
||||
if not samples:
|
||||
return 0, 0, 0.0
|
||||
s = sorted(samples)
|
||||
n = len(s)
|
||||
lo10 = s[int(n * 0.1)]
|
||||
hi90 = s[int(n * 0.9)]
|
||||
mid = [v for v in samples if lo10 <= v <= hi90]
|
||||
mean = (sum(mid) / len(mid)) if mid else (sum(samples) / n)
|
||||
return max(samples), min(samples), mean
|
||||
|
||||
|
||||
def get_hilo(high, low, mean, pct: int = 75):
|
||||
"""Port of getHiLo: clip the thresholds ``pct``% of the way from the mean to each rail (the
|
||||
default 25% clip), so partially-unclipped waves still register as high/low."""
|
||||
clip_hi = mean + (high - mean) * pct / 100.0
|
||||
clip_lo = mean - (mean - low) * pct / 100.0
|
||||
return clip_hi, clip_lo
|
||||
|
||||
|
||||
def detect_clk(samples) -> int:
|
||||
"""Full-bit clock (samples per data bit), snapped to the T55xx rate set. ``dsp.detect_clock``
|
||||
finds the shortest symbol (a Manchester half-bit); the data-bit clock is twice that."""
|
||||
half = dsp.detect_clock(dsp.binarize(samples))
|
||||
if not half:
|
||||
return 0
|
||||
return min(_CLOCKS, key=lambda c: abs(c - 2 * half))
|
||||
|
||||
|
||||
def clean_ask_raw_demod(samples, clk: int, high: float, low: float, invert: int = 0):
|
||||
"""Port of ``cleanAskRawDemod``: envelope -> raw half-bit stream by measuring rail-to-rail
|
||||
wave widths (full clock -> 2 like symbols, half clock -> 1), ignoring the ramp between rails.
|
||||
Phase errors (over-long waves) become ``7`` markers."""
|
||||
out: list[int] = []
|
||||
n = len(samples)
|
||||
cl_4, cl_2 = clk // 4, clk // 2
|
||||
wave_high = True
|
||||
pos = 0
|
||||
while pos < n and samples[pos] < high: # getNextHigh
|
||||
pos += 1
|
||||
smpl = 1
|
||||
if cl_2 - cl_4 - 1 < pos <= clk + cl_4 + 1: # do not skip the first transition
|
||||
out.append(invert ^ 1)
|
||||
for i in range(pos, n):
|
||||
v = samples[i]
|
||||
if v >= high and wave_high:
|
||||
smpl += 1
|
||||
elif v <= low and not wave_high:
|
||||
smpl += 1
|
||||
elif (v >= high and not wave_high) or (v <= low and wave_high):
|
||||
if smpl > clk - cl_4 - 1: # full-clock wave -> two like symbols
|
||||
if smpl > clk + cl_4 + 1:
|
||||
out.append(7)
|
||||
elif wave_high:
|
||||
out += [invert, invert]
|
||||
else:
|
||||
out += [invert ^ 1, invert ^ 1]
|
||||
wave_high = not wave_high
|
||||
smpl = 0
|
||||
elif smpl > cl_2 - cl_4 - 1: # half-clock wave -> one symbol
|
||||
if smpl > cl_2 + cl_4 + 1:
|
||||
out.append(7)
|
||||
out.append(invert if wave_high else invert ^ 1)
|
||||
wave_high = not wave_high
|
||||
smpl = 0
|
||||
else:
|
||||
smpl += 1
|
||||
else:
|
||||
smpl += 1 # in the ramp — haven't reached a rail yet
|
||||
return out
|
||||
|
||||
|
||||
def manchester_bits(samples):
|
||||
"""Yield candidate data-bit streams for an ASK/Manchester envelope, over every raw-invert and
|
||||
Manchester phase/polarity alignment — the caller keeps whichever frames + validates cleanly.
|
||||
Empty if the capture is noise or won't clock."""
|
||||
core = trim_to_signal(samples)
|
||||
if len(core) < 64 or dsp.is_flat(core):
|
||||
return
|
||||
high, low, mean = signal_props(core)
|
||||
clip_hi, clip_lo = get_hilo(high, low, mean)
|
||||
clk = detect_clk(core)
|
||||
if clk < 8:
|
||||
return
|
||||
for cinv in (0, 1):
|
||||
raw = clean_ask_raw_demod(core, clk, clip_hi, clip_lo, cinv)
|
||||
raw = [b for b in raw if b != 7] # drop phase-error markers before pairing
|
||||
for phase in (0, 1):
|
||||
for minv in (False, True):
|
||||
yield dsp.manchester_decode(raw, phase, minv)
|
||||
|
||||
|
||||
def nrz_bits(samples):
|
||||
"""Yield candidate raw NRZ / direct bit streams. In NRZ (the T5577 "direct" line code) the
|
||||
data bit is output with no coding — the envelope sampled once per bit *is* the data — so the
|
||||
bit clock is the shortest run itself, not twice a half-bit as in Manchester. Yields both
|
||||
polarities over a few phase offsets; the caller keeps whichever repeats cleanly."""
|
||||
core = trim_to_signal(samples)
|
||||
if len(core) < 64 or dsp.is_flat(core):
|
||||
return
|
||||
binary = dsp.binarize(core)
|
||||
half = dsp.detect_clock(binary)
|
||||
if not half or half < 8:
|
||||
return
|
||||
clk = min(_CLOCKS, key=lambda c: abs(c - half)) # NRZ: one bit = the shortest run
|
||||
for offset in (0.5, 0.0, 0.25, 0.75):
|
||||
raw = dsp.resample(binary, clk, offset)
|
||||
if len(raw) < 32:
|
||||
continue
|
||||
yield raw
|
||||
yield [b ^ 1 for b in raw]
|
||||
|
||||
|
||||
def biphase_bits(samples):
|
||||
"""Yield candidate raw bit streams for a biphase / differential-Manchester ASK envelope — the
|
||||
FDX-B line code (also Indala, Gallagher, …). Resample to half-bit resolution and run the
|
||||
biphase decoder for both polarities; phase-error markers are dropped."""
|
||||
core = trim_to_signal(samples)
|
||||
if len(core) < 64 or dsp.is_flat(core):
|
||||
return
|
||||
binary = dsp.binarize(core)
|
||||
half = dsp.detect_clock(binary)
|
||||
if not half or half < 4:
|
||||
return
|
||||
raw = dsp.resample(binary, half)
|
||||
for invert in (0, 1):
|
||||
bits, _err = dsp.biphase_raw_decode(raw, 0, invert)
|
||||
yield [b for b in bits if b != 7]
|
||||
273
pm3py/lf/capture.py
Normal file
273
pm3py/lf/capture.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Live-device LF capture + identify.
|
||||
|
||||
``demod_samples`` is the hardware-free heart: an envelope in, a decoded credential out — every
|
||||
protocol decoder tried in turn. ``identify_lf`` drives a real device: it captures what the tag
|
||||
*emits* (a plain read) and, separately, probes for a T55xx by reading config block 0, then
|
||||
combines the two into "is it a T5577, and what is it (emulating)?".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
from . import ask
|
||||
from . import dsp
|
||||
from . import protocols
|
||||
|
||||
# how many envelope samples to pull from BigBuf after a capture (a T55xx/EM frame is a few
|
||||
# thousand samples at RF/64; this spans several repeats for a clean lock)
|
||||
_SAMPLE_COUNT = 15000
|
||||
|
||||
# every emitted-credential decoder, tried in order (headered/most-specific first)
|
||||
_DECODERS = (
|
||||
protocols.decode_em4100,
|
||||
protocols.decode_hid,
|
||||
protocols.decode_awid,
|
||||
protocols.decode_fdxb,
|
||||
)
|
||||
|
||||
|
||||
def credential_label(c: dict) -> str:
|
||||
"""One-line name for a decoded credential, per protocol (each decoder returns its own fields)."""
|
||||
p = c.get("protocol", "?")
|
||||
if p == "EM4100":
|
||||
return f"EM4100 {c['id_hex']}"
|
||||
if p == "HID Prox":
|
||||
return f"HID Prox {c['format']} FC {c['facility_code']} Card {c['card_number']}"
|
||||
if p == "AWID":
|
||||
return f"{c['format']} FC {c['facility_code']} Card {c['card_number']}"
|
||||
if p == "FDX-B":
|
||||
return f"FDX-B {c['id']}" + (" (animal)" if c.get("animal") else "")
|
||||
return p
|
||||
|
||||
|
||||
def demod_samples(samples) -> dict | None:
|
||||
"""Decode a raw LF envelope to a credential by trying each protocol demod in turn.
|
||||
|
||||
Hardware-free and the single point every decoder funnels through — feed it a synthetic
|
||||
envelope from a transponder encoder in tests, or a BigBuf download at runtime."""
|
||||
if not samples:
|
||||
return None
|
||||
for decode in _DECODERS:
|
||||
result = decode(samples)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def _transition_density(bits) -> float:
|
||||
return sum(1 for i in range(1, len(bits)) if bits[i] != bits[i - 1]) / max(1, len(bits) - 1)
|
||||
|
||||
|
||||
def _frame_period(bits, min_p: int = 16) -> int | None:
|
||||
"""The smallest bit-period the stream repeats at (its frame length), or None.
|
||||
|
||||
Guards against the failure mode where the wrong line code decodes a signal into a mostly
|
||||
*constant* run (e.g. biphase-decoding a non-biphase tag emits a short prefix then all-1s):
|
||||
such a stream trivially "repeats" at any period. So we reject a near-constant stream up front,
|
||||
require room for a few real repeats, and reject a frame that is itself near-constant or
|
||||
trivially sub-periodic (an alternating 0101 fill)."""
|
||||
n = len(bits)
|
||||
if n < 3 * min_p:
|
||||
return None
|
||||
if _transition_density(bits) < 0.10: # mostly constant -> not a real frame
|
||||
return None
|
||||
# find the *smallest true* period, from 2 up — so a trivial fill (0101 -> period 2, 0011 ->
|
||||
# period 4) is caught as tiny and rejected, instead of being reported at the first p >= min_p.
|
||||
for p in range(2, n // 3 + 1):
|
||||
span = n - p
|
||||
if sum(1 for i in range(span) if bits[i] == bits[i + p]) >= span * 0.97:
|
||||
return p if p >= min_p else None # real tag frame is >= min_p bits
|
||||
return None
|
||||
|
||||
|
||||
def demod_raw(samples) -> dict | None:
|
||||
"""Physical-layer fallback for an ASK tag no named decoder recognises: recover the raw
|
||||
repeating frame under the NRZ and biphase line codes so the tag still yields its bits.
|
||||
|
||||
Returns whichever encodings produced a clean repeating frame (bit string + hex + period in
|
||||
bits) plus the recovered bit clock, or None if nothing repeats cleanly. This is deliberately
|
||||
*below* the credential decoders — it names no format, it just hands back the bits an expert
|
||||
(or a future format decoder) can read."""
|
||||
core = ask.trim_to_signal(samples)
|
||||
if len(core) < 64 or dsp.is_flat(core):
|
||||
return None
|
||||
half = dsp.detect_clock(dsp.binarize(core))
|
||||
if not half:
|
||||
return None
|
||||
encs: dict = {}
|
||||
for name, gen in (("nrz", ask.nrz_bits), ("biphase", ask.biphase_bits)):
|
||||
best = None
|
||||
for bits in gen(samples):
|
||||
p = _frame_period(bits)
|
||||
if p and (best is None or p < best[0]):
|
||||
best = (p, bits[:p])
|
||||
if best:
|
||||
p, frame = best
|
||||
s = "".join(str(b) for b in frame)
|
||||
encs[name] = {"period": p, "bits": s, "hex": f"{int(s, 2):0{-(-p // 4)}X}"}
|
||||
if not encs:
|
||||
return None
|
||||
clk = min(ask._CLOCKS, key=lambda c: abs(c - half))
|
||||
return {"protocol": "raw", "shortest_run": round(half, 1), "rf": f"RF/{clk}",
|
||||
"encodings": encs}
|
||||
|
||||
|
||||
def raw_label(raw: dict) -> str:
|
||||
"""One-line name for a raw physical-layer dump (no credential decoded)."""
|
||||
parts = [f"{k} {v['hex']} ({v['period']}b)" for k, v in raw["encodings"].items()]
|
||||
return f"unknown LF ({raw['rf']}) — " + "; ".join(parts)
|
||||
|
||||
|
||||
# download a fixed, safe span rather than trusting lf.read()'s reported count — on a flaky NG
|
||||
# link that count is sometimes garbage, and over-reading spills into BigBuf memory past the
|
||||
# samples. 12000 is under a normal acquisition, enough frames for any LF frame to repeat.
|
||||
_DOWNLOAD_BYTES = 12000
|
||||
|
||||
# markers that a "sample" buffer is actually desynced BigBuf memory, not an envelope
|
||||
_POISON = b"\xef\xbe\xad\xde" # 0xDEADBEEF — firmware stack canary
|
||||
_RESP_MAGIC = b"\x50\x4d\x33\x62" # 0x62334d50 — a PM3 response frame sitting in the buffer
|
||||
|
||||
|
||||
def _try(fn, default):
|
||||
try:
|
||||
return fn()
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _looks_like_samples(data: bytes) -> bool:
|
||||
"""Reject a download that is clearly desynced BigBuf memory rather than an LF envelope: the
|
||||
DEADBEEF stack canary, an embedded PM3 response magic, or a mostly-zero (unwritten) buffer."""
|
||||
if len(data) < 256:
|
||||
return False
|
||||
if _POISON in data or _RESP_MAGIC in data:
|
||||
return False
|
||||
return data.count(0) < len(data) // 2
|
||||
|
||||
|
||||
def _download(device, count: int = _DOWNLOAD_BYTES) -> bytes:
|
||||
"""Pull the last capture's envelope out of the device BigBuf (best-effort -> b'')."""
|
||||
try:
|
||||
return device.lf.download_samples(count=count) or b""
|
||||
except Exception:
|
||||
return b""
|
||||
|
||||
|
||||
def _capture(device, do_read, count: int = _DOWNLOAD_BYTES, tries: int = 3) -> bytes:
|
||||
"""Read + download a clean LF envelope, retrying past the intermittent desyncs a flaky NG
|
||||
link produces. Returns validated sample bytes, or ``b''`` if every attempt came back as
|
||||
memory (device likely needs a power-cycle / lower debug level)."""
|
||||
for _ in range(tries):
|
||||
if do_read() is None:
|
||||
continue
|
||||
data = _download(device, count=count)
|
||||
if _looks_like_samples(data):
|
||||
return data
|
||||
return b""
|
||||
|
||||
|
||||
def read_emitted(device) -> dict | None:
|
||||
"""Capture and decode whatever the tag is emitting in the field (no downlink) — the EM4100
|
||||
ID a tag (native or T5577-emulated) streams continuously."""
|
||||
data = _capture(device, lambda: _try(lambda: device.lf.read(samples=15000), None))
|
||||
return demod_samples(data) if data else 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.
|
||||
``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, 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. ``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):
|
||||
clean = [b for b in bits if b is not None]
|
||||
for word in protocols._repeating_words(clean, 32):
|
||||
return word
|
||||
return None
|
||||
|
||||
|
||||
def identify_lf(device, emit=None) -> dict | None:
|
||||
"""Full LF identify against a live device. Returns a result dict (``found``/``field``/
|
||||
``label``/``chip``/``config``/``emulating``/``emitted``) or ``None`` if the device is
|
||||
unusable. ``emit`` streams human-readable progress lines to the caller's trace."""
|
||||
emit = emit or (lambda _l: None)
|
||||
if device is None:
|
||||
return None
|
||||
|
||||
# capture drives the device *synchronously* (as Proxmark3.sync()'s proxy exposes it). A raw
|
||||
# async Proxmark3() returns un-awaited coroutines here — fail with an actionable message
|
||||
# instead of a cryptic "coroutine has no len()" deep in the demod.
|
||||
if inspect.iscoroutinefunction(getattr(getattr(device, "lf", None), "read", None)):
|
||||
raise TypeError(
|
||||
"identify_lf needs a synchronous device — use Proxmark3.sync(), not Proxmark3(). "
|
||||
"(A bare Proxmark3() is the async API and isn't connected.)"
|
||||
)
|
||||
|
||||
# Force the device debug level to NONE for the duration: on debug-heavy firmware a raised
|
||||
# g_dbglevel makes LF acquisition emit Dbprintf frames over USB, which interleave with the
|
||||
# sample download and desync it. This is the device-side knob (not the C client's g_debugMode
|
||||
# spam), and pm3py's own reads are what matter here.
|
||||
_try(lambda: device.hw.dbg(0), None)
|
||||
|
||||
config = read_config(device) # T55xx? -> chip + emulation
|
||||
emitted = read_emitted(device) # what's on the air -> EM4100 ID (etc.)
|
||||
|
||||
# nothing named decoded off the air -> try the physical-layer fallback (NRZ/biphase) so a tag
|
||||
# no credential decoder handles still yields its raw bits rather than reading as "nothing".
|
||||
raw = None
|
||||
if emitted is None:
|
||||
data = _capture(device, lambda: _try(lambda: device.lf.read(samples=_SAMPLE_COUNT), None))
|
||||
raw = demod_raw(data) if data else None
|
||||
|
||||
if config is None and emitted is None and raw is None:
|
||||
return None
|
||||
|
||||
chip = None
|
||||
emulating = None
|
||||
if config is not None:
|
||||
chip = "T5577"
|
||||
emulating = config["emulating"]
|
||||
emit(f" T55xx block 0 = 0x{config['config_hex']} "
|
||||
f"({config['modulation']}, RF/{config['data_bit_rate']}, {config['max_block']} blocks)"
|
||||
f" -> {emulating}")
|
||||
if emitted is not None:
|
||||
emit(f" emitted: {credential_label(emitted)}")
|
||||
elif raw is not None:
|
||||
emit(f" emitted (raw, no format decoded): {raw_label(raw)}")
|
||||
|
||||
# label: name the chip and what it's actually emitting (the reliable, decoded credential);
|
||||
# fall back to the raw physical dump, then the config's emulation guess.
|
||||
if chip and emitted:
|
||||
label = f"{chip} ({credential_label(emitted)})"
|
||||
elif chip and raw:
|
||||
label = f"{chip} ({raw_label(raw)})"
|
||||
elif chip:
|
||||
label = f"{chip} ({emulating})"
|
||||
elif emitted:
|
||||
label = credential_label(emitted)
|
||||
else:
|
||||
label = raw_label(raw)
|
||||
|
||||
return {
|
||||
"found": True,
|
||||
"field": "lf",
|
||||
"protocol": "lf",
|
||||
"chip": chip,
|
||||
"config": config["config_hex"] if config else None,
|
||||
"emulating": emulating,
|
||||
"emitted": emitted,
|
||||
"raw": raw,
|
||||
"label": label,
|
||||
}
|
||||
168
pm3py/lf/dsp.py
Normal file
168
pm3py/lf/dsp.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Modulation-agnostic LF DSP primitives.
|
||||
|
||||
Input everywhere is a Proxmark LF *envelope*: one unsigned byte (0-255) per carrier period
|
||||
(one "field clock"), as returned by ``lf.download_samples``. So a tag clocked at RF/64 spans
|
||||
64 samples per data bit, RF/32 spans 32, and so on — the RF/n divider *is* the samples-per-bit.
|
||||
|
||||
The functions here recover a bit stream from that envelope without knowing the protocol:
|
||||
threshold to binary, detect the bit clock, then slice/decode (Manchester or biphase). Framing
|
||||
(headers, parity, credential fields) lives in :mod:`pm3py.lf.protocols`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# T55xx standard data-bit-rate set (RF/n) — samples-per-bit candidates when the clock is
|
||||
# unknown. x-mode allows any even n; the reader still recovers those via the generic detector.
|
||||
RF_RATES = (8, 16, 32, 40, 50, 64, 100, 128)
|
||||
|
||||
|
||||
def _percentile(sorted_vals: list[int], frac: float) -> float:
|
||||
"""Value at ``frac`` (0..1) through an already-sorted list; robust to spikes."""
|
||||
if not sorted_vals:
|
||||
return 0.0
|
||||
idx = min(len(sorted_vals) - 1, max(0, round(frac * (len(sorted_vals) - 1))))
|
||||
return float(sorted_vals[idx])
|
||||
|
||||
|
||||
def threshold(samples) -> float:
|
||||
"""A robust high/low decision level for an ASK/OOK envelope.
|
||||
|
||||
The midpoint between the 5th and 95th percentiles, so a few saturated or dropped samples
|
||||
don't drag it (a plain min/max would, and the duty cycle isn't reliably 50% so the mean
|
||||
can bias). Returns 0.0 for an empty/flat capture."""
|
||||
if not samples:
|
||||
return 0.0
|
||||
s = sorted(samples)
|
||||
lo, hi = _percentile(s, 0.05), _percentile(s, 0.95)
|
||||
return (lo + hi) / 2.0
|
||||
|
||||
|
||||
def is_flat(samples, min_swing: int = 16) -> bool:
|
||||
"""True if the capture has too little dynamic range to carry data (no tag / dead field)."""
|
||||
if len(samples) < 8:
|
||||
return True
|
||||
s = sorted(samples)
|
||||
return (_percentile(s, 0.95) - _percentile(s, 0.05)) < min_swing
|
||||
|
||||
|
||||
def binarize(samples, level: float | None = None) -> list[int]:
|
||||
"""Envelope -> list of 0/1 by thresholding (ASK/OOK)."""
|
||||
if level is None:
|
||||
level = threshold(samples)
|
||||
return [1 if v > level else 0 for v in samples]
|
||||
|
||||
|
||||
def _edges(binary: list[int]) -> list[int]:
|
||||
return [i for i in range(1, len(binary)) if binary[i] != binary[i - 1]]
|
||||
|
||||
|
||||
def detect_clock(binary: list[int], min_run: int = 3, support: float = 0.2) -> float | None:
|
||||
"""Estimate the shortest symbol width (samples) from the edge-interval distribution.
|
||||
|
||||
For Manchester this is the *half-bit* (there is always a mid-bit transition, so runs are
|
||||
one or two half-bits); for plain OOK it is the bit itself. Runs shorter than ``min_run``
|
||||
are treated as noise/jitter and ignored. Returns the mean of the shortest interval cluster,
|
||||
or ``None`` when there aren't enough clean edges to decide.
|
||||
|
||||
The base symbol is the shortest interval with *real support* — a count at least ``support``×
|
||||
the most-common interval's count — not the raw minimum. A real ADC capture always carries a
|
||||
few stray sub-bit edges; keying off the bare minimum let a single 3-sample outlier collapse a
|
||||
clean RF/64 tag's estimate from 32 to ~3 (hardware-observed on a strongly-coupled EM4100),
|
||||
snapping the data clock to 8 and killing the demod. The dominant interval is robust to that."""
|
||||
edges = _edges(binary)
|
||||
if len(edges) < 4:
|
||||
return None
|
||||
intervals = [edges[i] - edges[i - 1] for i in range(1, len(edges)) if edges[i] - edges[i - 1] >= min_run]
|
||||
if len(intervals) < 3:
|
||||
return None
|
||||
counts: dict[int, int] = {}
|
||||
for d in intervals:
|
||||
counts[d] = counts.get(d, 0) + 1
|
||||
floor = max(counts.values()) * support
|
||||
base = min(d for d, n in counts.items() if n >= floor) # shortest well-supported interval
|
||||
# average the cluster around the base (0.75x–1.5x) -> the fundamental symbol width
|
||||
cluster = [d for d in intervals if base * 0.75 <= d <= base * 1.5]
|
||||
return sum(cluster) / len(cluster)
|
||||
|
||||
|
||||
def resample(binary: list[int], symbol: float, offset: float = 0.5) -> list[int]:
|
||||
"""Sample ``binary`` once per ``symbol``-wide window (majority vote), starting at
|
||||
``offset`` symbols in. Turns a per-sample stream into a per-symbol (e.g. per-half-bit)
|
||||
stream at the recovered clock."""
|
||||
if symbol <= 0:
|
||||
return []
|
||||
out: list[int] = []
|
||||
n = len(binary)
|
||||
pos = offset * symbol
|
||||
while pos < n:
|
||||
lo = max(0, int(pos - symbol / 4))
|
||||
hi = min(n, int(pos + symbol / 4) + 1)
|
||||
window = binary[lo:hi] or [binary[min(int(pos), n - 1)]]
|
||||
out.append(1 if sum(window) * 2 >= len(window) else 0)
|
||||
pos += symbol
|
||||
return out
|
||||
|
||||
|
||||
def manchester_decode(halfbits: list[int], phase: int = 0, invert: bool = False):
|
||||
"""Decode a half-bit stream as Manchester: each data bit is a pair of opposite half-bits.
|
||||
|
||||
``phase`` (0/1) picks the pairing alignment; ``invert`` swaps the polarity convention
|
||||
(``10`` -> 1 vs ``01`` -> 1). Pairs that are ``00``/``11`` are clock slips — returned as
|
||||
``None`` markers so callers can resync. Returns the decoded bit list (with ``None`` gaps).
|
||||
Callers typically try both phases/polarities and keep the alignment that frames cleanly."""
|
||||
bits: list[int | None] = []
|
||||
for i in range(phase, len(halfbits) - 1, 2):
|
||||
a, b = halfbits[i], halfbits[i + 1]
|
||||
if a == b:
|
||||
bits.append(None) # not a valid Manchester symbol -> clock slip
|
||||
else:
|
||||
bit = 1 if a == 1 else 0 # (1,0) -> 1, (0,1) -> 0
|
||||
bits.append(bit ^ 1 if invert else bit)
|
||||
return bits
|
||||
|
||||
|
||||
def biphase_raw_decode(bits: list[int], offset: int = 0, invert: int = 0):
|
||||
"""Faithful port of ``lfdemod.c`` ``BiphaseRawDecode`` — decode biphase / differential
|
||||
Manchester (FDX-B, Indala). Consumes the half-bit stream in pairs: ``01``/``10`` -> ``1``,
|
||||
``00``/``11`` -> ``0`` (xor ``invert``); a missing bit-boundary transition marks a phase
|
||||
error (emitted as ``7``). Auto-nudges the pairing offset by one when the first 48 samples
|
||||
show the other phase is clean. Returns ``(databits, err_count)``."""
|
||||
n = len(bits)
|
||||
if n < 51:
|
||||
return [], -1
|
||||
if offset < 0:
|
||||
offset = 0
|
||||
|
||||
# phase-fault check: if pairing at offset is faulty but offset+1 is clean, shift by one
|
||||
offset_a = offset_b = True
|
||||
i = offset
|
||||
while i < offset + 48 and i + 3 < n:
|
||||
if bits[i + 1] == bits[i + 2]:
|
||||
offset_a = False
|
||||
if bits[i + 2] == bits[i + 3]:
|
||||
offset_b = False
|
||||
i += 2
|
||||
if not offset_a and offset_b:
|
||||
offset += 1
|
||||
|
||||
out: list[int] = []
|
||||
err = 0
|
||||
i = offset
|
||||
while i < n - 1:
|
||||
if i + 2 < n and bits[i + 1] == bits[i + 2]:
|
||||
out.append(7) # missing boundary transition -> phase error
|
||||
err += 1
|
||||
a, b = bits[i], bits[i + 1]
|
||||
if a != b:
|
||||
out.append(1 ^ invert)
|
||||
else:
|
||||
out.append(invert)
|
||||
i += 2
|
||||
return out, err
|
||||
|
||||
|
||||
def bits_to_int(bits: list[int]) -> int:
|
||||
"""MSB-first bit list -> integer."""
|
||||
v = 0
|
||||
for b in bits:
|
||||
v = (v << 1) | (b & 1)
|
||||
return v
|
||||
207
pm3py/lf/fsk.py
Normal file
207
pm3py/lf/fsk.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""FSK demodulation — a faithful Python port of the Proxmark FSK stack.
|
||||
|
||||
Ported function-for-function from ``firmware/common/lfdemod.c`` (``fsk_wave_demod`` +
|
||||
``aggregate_bits`` = ``fskdemod``, ``preambleSearch``, ``HIDdemodFSK``, ``detectAWID``) so it
|
||||
tracks the C reference — and therefore real-tag output — rather than being a fresh reinvention.
|
||||
The C thresholds against the signal mean and counts samples between 0->1 transitions: a short
|
||||
wave (~fc/8) is one symbol, a long wave (~fc/10) the other; ``aggregate_bits`` then collapses
|
||||
runs of like waves into clock-resolution bits. HID/AWID are FSK2a (fc/10 high, fc/8 low, RF/50).
|
||||
|
||||
Everything downstream (Wiegand fields, parity) is validated by the shipped ``HIDProxTag`` model.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# lfdemod.h NOISE_AMPLITUDE_THRESHOLD — below this peak-to-mean swing the capture is "noise".
|
||||
# Conservative; a real hardware capture tunes this alongside the ASK path.
|
||||
NOISE_AMPLITUDE_THRESHOLD = 8
|
||||
|
||||
|
||||
def _mean(samples) -> float:
|
||||
return sum(samples) / len(samples) if samples else 0.0
|
||||
|
||||
|
||||
def is_noise(samples) -> bool:
|
||||
"""Port of signalprop.isnoise: too little amplitude above the mean to carry data."""
|
||||
if len(samples) < 64:
|
||||
return True
|
||||
return (max(samples) - _mean(samples)) < NOISE_AMPLITUDE_THRESHOLD
|
||||
|
||||
|
||||
def find_mod_start(src, mean: float, exp_wave: int) -> int:
|
||||
"""Port of ``findModStart`` — skip leading noise/slow chip start-up to the first clean wave."""
|
||||
n = len(src)
|
||||
if n < 21:
|
||||
return 0
|
||||
i = 1
|
||||
wave_cnt = 0
|
||||
thresh_cnt = 0
|
||||
above = src[0] >= mean
|
||||
while i < n - 20:
|
||||
if src[i] < mean and above:
|
||||
thresh_cnt += 1
|
||||
if thresh_cnt > 2 and wave_cnt < exp_wave + 1:
|
||||
break
|
||||
above = False
|
||||
wave_cnt = 0
|
||||
elif src[i] >= mean and not above:
|
||||
thresh_cnt += 1
|
||||
if thresh_cnt > 2 and wave_cnt < exp_wave + 1:
|
||||
break
|
||||
above = True
|
||||
wave_cnt = 0
|
||||
else:
|
||||
wave_cnt += 1
|
||||
if thresh_cnt > 10:
|
||||
break
|
||||
i += 1
|
||||
return i
|
||||
|
||||
|
||||
def fsk_wave_demod(samples, fchigh: int = 10, fclow: int = 8):
|
||||
"""Port of ``fsk_wave_demod``: envelope -> one bit per FSK wave (1 = short/fc-low wave,
|
||||
0 = long/fc-high wave), counting samples between consecutive 0->1 transitions. Returns
|
||||
``(bits, start_idx)``."""
|
||||
n = len(samples)
|
||||
if n < 1024:
|
||||
return [], 0
|
||||
mean = _mean(samples)
|
||||
thr = [0 if s < mean else 1 for s in samples]
|
||||
|
||||
idx = find_mod_start(samples, mean, fchigh)
|
||||
last_transition = idx
|
||||
idx += 1
|
||||
out: list[int] = []
|
||||
start_idx = 0
|
||||
pre_last = last = curr = 0
|
||||
|
||||
while idx < n - 20:
|
||||
if thr[idx - 1] < thr[idx]: # 0 -> 1 transition
|
||||
pre_last, last, curr = last, curr, idx - last_transition
|
||||
if curr < fclow - 2:
|
||||
pass # garbage noise
|
||||
elif curr < fchigh - 1: # short wave -> 1
|
||||
if len(out) > 1 and last > fchigh - 2 and pre_last < fchigh - 1:
|
||||
out[-1] = 1 # fix a 9 surrounded by 8s
|
||||
out.append(1)
|
||||
if out and start_idx == 0:
|
||||
start_idx = idx - fclow
|
||||
elif curr > fchigh + 1 and len(out) < 3:
|
||||
out = [] # leading garbage -> reset
|
||||
elif curr == fclow + 1 and last == fclow - 1: # 7 then 9 -> two 8s
|
||||
out.append(1)
|
||||
if out and start_idx == 0:
|
||||
start_idx = idx - fclow
|
||||
else: # long wave -> 0
|
||||
out.append(0)
|
||||
if out and start_idx == 0:
|
||||
start_idx = idx - fchigh
|
||||
last_transition = idx
|
||||
idx += 1
|
||||
return out, start_idx
|
||||
|
||||
|
||||
def aggregate_bits(bits, clk: int, invert: int, fchigh: int, fclow: int):
|
||||
"""Port of ``aggregate_bits``: collapse runs of like waves into clock-resolution data bits
|
||||
(a run of n waves at field-clock fc spans n*fc/clk bits)."""
|
||||
if not bits:
|
||||
return []
|
||||
out: list[int] = []
|
||||
lastval = bits[0]
|
||||
n = 1
|
||||
hclk = clk // 2
|
||||
i = 1
|
||||
for i in range(1, len(bits)):
|
||||
n += 1
|
||||
if bits[i] == lastval:
|
||||
continue
|
||||
if bits[i - 1] == 1:
|
||||
n = (n * fclow + hclk) // clk
|
||||
else:
|
||||
n = (n * fchigh + hclk) // clk
|
||||
if n == 0:
|
||||
n = 1
|
||||
out.extend([bits[i - 1] ^ invert] * n)
|
||||
n = 0
|
||||
lastval = bits[i]
|
||||
if n > clk // fchigh and len(bits) >= 2: # trailing same-frequency run
|
||||
if bits[i - 1] == 1:
|
||||
n = (n * fclow + hclk) // clk
|
||||
else:
|
||||
n = (n * fchigh + hclk) // clk
|
||||
out.extend([bits[-1] ^ invert] * n)
|
||||
return out
|
||||
|
||||
|
||||
def fskdemod(samples, rflen: int = 50, invert: int = 1, fchigh: int = 10, fclow: int = 8):
|
||||
"""Full FSK demod: envelope -> decoded 1s/0s (``fsk_wave_demod`` + ``aggregate_bits``)."""
|
||||
if is_noise(samples):
|
||||
return []
|
||||
waves, _ = fsk_wave_demod(list(samples), fchigh, fclow)
|
||||
return aggregate_bits(waves, rflen, invert, fchigh, fclow)
|
||||
|
||||
|
||||
def preamble_search(bits, preamble):
|
||||
"""Port of ``preambleSearch``: find ``preamble`` in ``bits``. Returns ``(found, start_idx,
|
||||
size)`` where ``size`` is the gap to the second occurrence (the frame length) when there is
|
||||
one, else the remaining length."""
|
||||
plen = len(preamble)
|
||||
size = len(bits)
|
||||
if size <= plen:
|
||||
return (False, 0, size)
|
||||
found = 0
|
||||
start = 0
|
||||
for idx in range(size - plen):
|
||||
if bits[idx:idx + plen] == preamble:
|
||||
found += 1
|
||||
if found == 1:
|
||||
start = idx
|
||||
elif found == 2:
|
||||
return (True, start, idx - start)
|
||||
return (found > 0, start, size)
|
||||
|
||||
|
||||
# HID: 00011101 start-of-frame, then Manchester (10 -> 1, 01 -> 0)
|
||||
HID_PREAMBLE = [0, 0, 0, 1, 1, 1, 0, 1]
|
||||
# AWID: 00000001 preamble, 96-bit frame
|
||||
AWID_PREAMBLE = [0, 0, 0, 0, 0, 0, 0, 1]
|
||||
|
||||
_M32 = 0xFFFFFFFF
|
||||
|
||||
|
||||
def hid_demod_fsk(samples):
|
||||
"""Port of ``HIDdemodFSK``: FSK2a demod + preamble + Manchester -> ``(hi2, hi, lo, nbits)``
|
||||
96-bit shift register plus the number of data bits Manchester-decoded (needed to tell the
|
||||
header-less 37-bit format from the 26-bit one), or ``None``. Field extraction is the caller's."""
|
||||
bits = fskdemod(samples, 50, 1, 10, 8)
|
||||
if len(bits) < 96 * 2:
|
||||
return None
|
||||
found, start, size = preamble_search(bits, HID_PREAMBLE)
|
||||
if not found:
|
||||
return None
|
||||
num_start = start + len(HID_PREAMBLE)
|
||||
hi2 = hi = lo = 0
|
||||
nbits = 0
|
||||
idx = num_start
|
||||
while (idx - num_start) < size - len(HID_PREAMBLE) and idx + 1 < len(bits):
|
||||
a, b = bits[idx], bits[idx + 1]
|
||||
if a == b:
|
||||
break # not Manchester -> end of frame
|
||||
hi2 = ((hi2 << 1) | (hi >> 31)) & _M32
|
||||
hi = ((hi << 1) | (lo >> 31)) & _M32
|
||||
lo = ((lo << 1) | (1 if (a and not b) else 0)) & _M32
|
||||
nbits += 1
|
||||
idx += 2
|
||||
if hi == 0 and lo == 0:
|
||||
return None
|
||||
return (hi2, hi, lo, nbits)
|
||||
|
||||
|
||||
def awid_demod_fsk(samples):
|
||||
"""Port of ``detectAWID``: FSK2a demod + 00000001 preamble -> the 96 frame bits, or ``None``."""
|
||||
bits = fskdemod(samples, 50, 1, 10, 8)
|
||||
if len(bits) < 96:
|
||||
return None
|
||||
found, start, size = preamble_search(bits, AWID_PREAMBLE)
|
||||
if not found or size != 96:
|
||||
return None
|
||||
return bits[start:start + 96]
|
||||
301
pm3py/lf/protocols.py
Normal file
301
pm3py/lf/protocols.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Per-tag LF framing: bit stream -> decoded credential.
|
||||
|
||||
Each decoder takes a raw envelope, runs the generic :mod:`pm3py.lf.dsp` pipeline (trying both
|
||||
Manchester phase/polarity alignments), and validates candidate frames with the *transponder
|
||||
model's own* checker — ``EM4100Code.valid``, ``T5577Config`` sanity — so framing rules live in
|
||||
exactly one place. A decoder returns a structured dict on a clean, validated frame, else ``None``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import dsp
|
||||
from . import fsk
|
||||
from . import ask
|
||||
# Import the models through the ``pm3py.sim`` package (not their deep module paths): sim's
|
||||
# __init__ re-exports them, and going through it forces sim to fully initialize first, avoiding
|
||||
# a circular import (em4100 -> pm3py.sim.frame -> sim.__init__ -> em4100) when pm3py.lf is the
|
||||
# first thing loaded.
|
||||
from pm3py.sim import EM4100Code, T5577Config, HIDProxTag
|
||||
|
||||
# config word -> preset name (invert T5577Config._PRESETS) for "emulating ..." labelling
|
||||
_PRESET_BY_WORD = {word: name for name, word in T5577Config._PRESETS.items()}
|
||||
|
||||
|
||||
def _manchester_bits(samples):
|
||||
"""Yield candidate data-bit streams for an ASK/Manchester envelope. Delegates to the faithful
|
||||
C ASK port (:mod:`pm3py.lf.ask`) — hysteresis rail-to-rail demod that survives the asymmetric,
|
||||
ramped, sometimes-saturated shape of real LF captures, where a naive resample bit-slips."""
|
||||
yield from ask.manchester_bits(samples)
|
||||
|
||||
|
||||
def decode_em4100(samples) -> dict | None:
|
||||
"""Demodulate an EM4100/EM4102 envelope (ASK + Manchester) to its 40-bit ID.
|
||||
|
||||
Searches the demodulated stream for a 64-bit frame whose header, ten row parities, four
|
||||
column parities and stop bit all check out (``EM4100Code.valid``) — the frame repeats, so a
|
||||
clean copy is found even if the capture starts mid-frame."""
|
||||
for bits in _manchester_bits(samples):
|
||||
code = _find_em4100(bits)
|
||||
if code is not None:
|
||||
return {
|
||||
"protocol": "EM4100",
|
||||
"tag_id": code.tag_id,
|
||||
"id_hex": f"{code.tag_id:010X}",
|
||||
"customer_id": code.customer_id,
|
||||
"card_number": code.card_number,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _find_em4100(bits) -> EM4100Code | None:
|
||||
n = len(bits)
|
||||
for start in range(max(0, n - 63)):
|
||||
window = bits[start:start + 64]
|
||||
if len(window) < 64 or None in window:
|
||||
continue
|
||||
code = EM4100Code(dsp.bits_to_int(window))
|
||||
if code.valid:
|
||||
return code
|
||||
return None
|
||||
|
||||
|
||||
def _hid_field(full: int, fmt: str, nbits: int) -> dict | None:
|
||||
"""Extract the low ``nbits`` as a ``fmt`` Wiegand credential, accepting only if re-encoding
|
||||
the decoded facility+card reproduces the bits exactly (proves both parities)."""
|
||||
wiegand = [(full >> (nbits - 1 - i)) & 1 for i in range(nbits)]
|
||||
dec = HIDProxTag.decode_wiegand(wiegand, fmt)
|
||||
fc, cn = dec["facility_code"], dec["card_number"]
|
||||
if HIDProxTag._encode_wiegand(fc, cn, fmt) != wiegand:
|
||||
return None
|
||||
return {
|
||||
"protocol": "HID Prox", "format": fmt, "facility_code": fc, "card_number": cn,
|
||||
"wiegand": f"{full & ((1 << nbits) - 1):0{(nbits + 3) // 4}X}",
|
||||
}
|
||||
|
||||
|
||||
def decode_hid(samples) -> dict | None:
|
||||
"""Demodulate an HID Prox envelope (FSK2a + Manchester) to its Wiegand credential.
|
||||
|
||||
Format is told by frame *structure*, not parity alone (2 parity bits can't separate 26- from
|
||||
37-bit): the 26-bit H10301 wire frame carries a 0x20 header at bit 37 and a sentinel 1 at
|
||||
bit 26 with the Wiegand below; the 37-bit H10304 frame is header-less, so a 37-bit Manchester
|
||||
count with no 0x20 marks it. Each candidate is still parity-validated by re-encoding through
|
||||
the shipped ``HIDProxTag``."""
|
||||
r = fsk.hid_demod_fsk(samples)
|
||||
if r is None:
|
||||
return None
|
||||
hi2, hi, lo, nbits = r
|
||||
full = (hi << 32) | lo # low 64 bits cover 26- and 37-bit formats
|
||||
if (hi & 0x20) and (lo & (1 << 26)): # 26-bit standard header + sentinel
|
||||
got = _hid_field(full, "H10301", 26)
|
||||
if got:
|
||||
return got
|
||||
if nbits == 37 and not (hi & 0x20): # header-less 37-bit frame
|
||||
got = _hid_field(full, "H10304", 37)
|
||||
if got:
|
||||
return got
|
||||
return None
|
||||
|
||||
|
||||
# AWID format length -> (facility offset/width, card offset/width) in the 66 de-parity'd bits
|
||||
# (cmdlfawid.c index map). fmtLen is bits[0:8]; bit 8 is a Wiegand parity.
|
||||
_AWID_FORMATS = {
|
||||
26: ((9, 8), (17, 16)),
|
||||
34: ((9, 8), (17, 24)),
|
||||
36: ((14, 11), (25, 18)),
|
||||
37: ((9, 13), (22, 18)),
|
||||
50: ((9, 16), (25, 32)),
|
||||
}
|
||||
|
||||
|
||||
def _strip_parity(bits, group: int = 4, want_odd: bool = True):
|
||||
"""Port of ``removeParity`` for AWID: each ``group`` bits are (group-1) data + 1 parity;
|
||||
verify the parity and keep the data. Returns the compacted bits, or ``None`` on a parity
|
||||
fault (the check that makes AWID trustworthy without a CRC)."""
|
||||
out = []
|
||||
for i in range(0, len(bits) - group + 1, group):
|
||||
g = bits[i:i + group]
|
||||
if (sum(g) & 1) != (1 if want_odd else 0):
|
||||
return None
|
||||
out.extend(g[:group - 1])
|
||||
return out
|
||||
|
||||
|
||||
def decode_awid(samples) -> dict | None:
|
||||
"""Demodulate an AWID envelope (FSK2a, no Manchester) to its Wiegand credential.
|
||||
|
||||
``detectAWID`` gives the 96 frame bits; the 88 after the preamble are 22 groups of 3 data +
|
||||
1 odd-parity bit, so stripping parity yields 66 bits whose first byte is the format length
|
||||
(26/34/36/37/50). The 22 parity checks stand in for AWID's missing CRC — a bad frame fails
|
||||
them — so this doesn't false-positive."""
|
||||
frame = fsk.awid_demod_fsk(samples)
|
||||
if frame is None:
|
||||
return None
|
||||
data = _strip_parity(frame[8:8 + 88], group=4, want_odd=True)
|
||||
if data is None or len(data) != 66:
|
||||
return None
|
||||
fmt_len = dsp.bits_to_int(data[0:8])
|
||||
spec = _AWID_FORMATS.get(fmt_len)
|
||||
if spec is None:
|
||||
return None
|
||||
(fc_off, fc_w), (cn_off, cn_w) = spec
|
||||
fc = dsp.bits_to_int(data[fc_off:fc_off + fc_w])
|
||||
cardnum = dsp.bits_to_int(data[cn_off:cn_off + cn_w])
|
||||
return {
|
||||
"protocol": "AWID",
|
||||
"format": f"AWID-{fmt_len}",
|
||||
"facility_code": fc,
|
||||
"card_number": cardnum,
|
||||
}
|
||||
|
||||
|
||||
def _reflect(v: int, width: int) -> int:
|
||||
r = 0
|
||||
for i in range(width):
|
||||
if v & (1 << i):
|
||||
r |= 1 << (width - 1 - i)
|
||||
return r
|
||||
|
||||
|
||||
def crc16(data: bytes, init: int = 0, poly: int = 0x1021, refin: bool = False,
|
||||
refout: bool = False) -> int:
|
||||
"""Parametric bitwise CRC-16 (Rocksoft model), matching the firmware ``crc16_fast``."""
|
||||
crc = init
|
||||
for byte in data:
|
||||
b = _reflect(byte, 8) if refin else byte
|
||||
crc ^= b << 8
|
||||
for _ in range(8):
|
||||
crc = ((crc << 1) ^ poly) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
|
||||
return _reflect(crc, 16) if refout else crc
|
||||
|
||||
|
||||
def crc16_fdxb(data: bytes) -> int:
|
||||
"""FDX-B (ISO 11784/85) CRC: poly 0x1021, init 0, refin=false, refout=true."""
|
||||
return crc16(data, 0x0000, 0x1021, refin=False, refout=True)
|
||||
|
||||
|
||||
_FDXB_PREAMBLE = [0] * 10 + [1] # ten 0x00 then 0x01
|
||||
|
||||
|
||||
def decode_fdxb(samples) -> dict | None:
|
||||
"""Demodulate an FDX-B (ISO 11784/85) envelope — ASK + biphase, 128-bit frame.
|
||||
|
||||
After biphase decode we find the ``00000000001`` preamble, de-interleave the 8-bit data
|
||||
groups (every 9th bit is a control 1), and reassemble the 10-bit country + 38-bit national
|
||||
code. Accepted only if the CRC-16 over the eight data bytes matches the stored one — a strong
|
||||
check, so FDX-B never false-positives. Tries both biphase polarities."""
|
||||
if dsp.is_flat(samples):
|
||||
return None
|
||||
binary = dsp.binarize(samples)
|
||||
half = dsp.detect_clock(binary)
|
||||
if not half:
|
||||
return None
|
||||
raw = dsp.resample(binary, half) # half-bit-resolution ASK bits
|
||||
for invert in (0, 1):
|
||||
bits, _err = dsp.biphase_raw_decode(raw, 0, invert)
|
||||
got = _fdxb_frame(bits)
|
||||
if got is not None:
|
||||
return got
|
||||
return None
|
||||
|
||||
|
||||
def _fdxb_frame(bits) -> dict | None:
|
||||
n = len(bits)
|
||||
for start in range(max(0, n - 128)):
|
||||
if bits[start:start + 11] != _FDXB_PREAMBLE:
|
||||
continue
|
||||
frame = bits[start:start + 128]
|
||||
if len(frame) < 100 or 7 in frame[:100]: # need the data+CRC region clean
|
||||
continue
|
||||
got = _fdxb_parse(frame)
|
||||
if got is not None:
|
||||
return got
|
||||
return None
|
||||
|
||||
|
||||
def _group(frame, i: int) -> int | None:
|
||||
"""The i-th 8-bit data group (LSB-first), skipping the control bit before each group."""
|
||||
seg = frame[11 + i * 9: 11 + i * 9 + 8]
|
||||
if len(seg) < 8 or 7 in seg:
|
||||
return None
|
||||
return sum(bit << k for k, bit in enumerate(seg))
|
||||
|
||||
|
||||
def _fdxb_parse(frame) -> dict | None:
|
||||
groups = [_group(frame, i) for i in range(10)] # 0-7 data, 8-9 CRC
|
||||
if any(g is None for g in groups):
|
||||
return None
|
||||
national = (groups[0] | groups[1] << 8 | groups[2] << 16 | groups[3] << 24
|
||||
| (groups[4] & 0x3F) << 32)
|
||||
country = ((groups[4] >> 6) | (groups[5] << 2)) & 0x3FF
|
||||
stored_crc = groups[8] | (groups[9] << 8)
|
||||
if stored_crc != crc16_fdxb(bytes(groups[0:8])):
|
||||
return None # CRC mismatch -> not a valid FDX-B frame
|
||||
return {
|
||||
"protocol": "FDX-B",
|
||||
"country_code": country,
|
||||
"national_code": national,
|
||||
"id": f"{country:03d}-{national:012d}",
|
||||
"animal": bool(frame[81]),
|
||||
}
|
||||
|
||||
|
||||
def decode_t55xx_config(samples) -> dict | None:
|
||||
"""Recover a T55xx block-0 config from a block-0 read response (ASK/Manchester modes).
|
||||
|
||||
A block read streams the 32-bit config with no header, so the demod recovers it at *some* bit
|
||||
rotation. We collect every 32-bit word that repeats one block-period later and accept one only
|
||||
when a bit-rotation of it exactly matches a known config preset (EM4100, HID, Indala, FDX-B,
|
||||
Viking, default). That both resolves the rotation ambiguity (a real capture came back as
|
||||
0x000A4020 = the EM4100 word 0x00148040 rotated by one) and rejects the spurious repeats
|
||||
(all-0, all-1, a rotation of the tag's own emission) that a bare "looks like a sane config"
|
||||
score waved through as false positives. Returns ``None`` when nothing rotates to a preset —
|
||||
the emitted-stream decode carries the result instead."""
|
||||
for bits in _manchester_bits(samples):
|
||||
clean = [b for b in bits if b is not None]
|
||||
for word in _repeating_words(clean, 32):
|
||||
for rot in range(32):
|
||||
rv = ((word << rot) | (word >> (32 - rot))) & 0xFFFFFFFF
|
||||
if rv in _PRESET_BY_WORD:
|
||||
cfg = T5577Config(rv)
|
||||
return {
|
||||
"protocol": "T5577",
|
||||
"config": rv,
|
||||
"config_hex": f"{rv:08X}",
|
||||
"modulation": cfg.modulation if isinstance(cfg.modulation, str)
|
||||
else f"0x{cfg.modulation:02X}",
|
||||
"data_bit_rate": cfg.data_bit_rate,
|
||||
"max_block": cfg.max_block,
|
||||
"emulating": emulation_name(rv, cfg),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _repeating_words(bits, width: int):
|
||||
"""Yield the distinct ``width``-bit windows that equal the next ``width`` bits (a block-read
|
||||
candidate repeating one block-period later)."""
|
||||
n = len(bits)
|
||||
seen = set()
|
||||
for start in range(max(0, n - 2 * width)):
|
||||
w = bits[start:start + width]
|
||||
if w == bits[start + width:start + 2 * width]:
|
||||
val = dsp.bits_to_int(w)
|
||||
if val not in seen:
|
||||
seen.add(val)
|
||||
yield val
|
||||
|
||||
|
||||
def emulation_name(word: int, cfg: T5577Config | None = None) -> str:
|
||||
"""Human label for what a T5577 config emulates: an exact preset match if we have one
|
||||
(EM4100, HID, Indala, FDX-B, Viking, default), else the modulation/rate family."""
|
||||
exact = _PRESET_BY_WORD.get(word)
|
||||
if exact:
|
||||
return {"em4100": "EM4100 / EM4102", "hid": "HID Prox", "indala": "Indala",
|
||||
"fdxb": "FDX-B", "viking": "Viking", "default": "raw / default"}.get(exact, exact)
|
||||
cfg = cfg or T5577Config(word)
|
||||
mod = cfg.modulation if isinstance(cfg.modulation, str) else "?"
|
||||
family = ("EM/ASK" if mod == "ASK"
|
||||
else "HID/AWID" if mod.startswith("FSK")
|
||||
else "Indala/PSK" if mod.startswith("PSK")
|
||||
else "FDX-B/Nedap" if "biphase" in mod
|
||||
else mod)
|
||||
return f"{family} · {mod} RF/{cfg.data_bit_rate}"
|
||||
206
pm3py/pyws_plugin.py
Normal file
206
pm3py/pyws_plugin.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""pyws workspace plugin for pm3py.
|
||||
|
||||
Turns pm3py into a pyws workspace: connects the Proxmark3 (reader-mode) or opens a live
|
||||
``SimSession`` (sim-mode), and injects the transponder / sim classes into the session
|
||||
namespace. Discovered by the pyws engine via the ``pyws.plugins`` entry point (see
|
||||
pyproject.toml). pyws must be installed alongside pm3py.
|
||||
|
||||
A workspace uses one connection mode — ``reader`` OR ``sim`` — never both, since they contend
|
||||
on the serial port. The sim is armed explicitly (``sim.start(tag)``); opening the workspace
|
||||
only opens the port. See docs/plans/2026-07-05-pm3py-pyws-plugin-plan.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyws import Resource, ResourceStatus, WorkspacePlugin
|
||||
|
||||
log = logging.getLogger("pm3py.pyws")
|
||||
|
||||
#: names injected into the workspace namespace from pm3py.sim (guarded by hasattr)
|
||||
_SIM_EXPORTS = [
|
||||
"SimSession",
|
||||
"NTAG210", "NTAG212", "NTAG213", "NTAG215", "NTAG216",
|
||||
"MifareUltralight", "MifareUltralightC", "MifareUltralightEV1",
|
||||
"MifareClassicTag", "NfcType2Tag", "NfcType4Tag",
|
||||
"Tag15693", "NfcType5Tag", "IcodeSlix2Tag",
|
||||
"xNT", "xM1", "NExT", "FlexDF",
|
||||
"ndef_uri", "ndef_text", "ndef_mime",
|
||||
]
|
||||
|
||||
|
||||
class ReaderResource(Resource):
|
||||
"""A connected ``Proxmark3`` client, exposed as ``reader`` in the namespace.
|
||||
|
||||
Opens the device on session start and drops it on stop. A missing / unreachable device is
|
||||
non-fatal — the resource reports ``disconnected`` so you can still work offline. ``client``
|
||||
is injectable for tests.
|
||||
"""
|
||||
|
||||
kind = "reader"
|
||||
|
||||
def __init__(self, name: str = "reader", config: dict | None = None, *, client: Any = None):
|
||||
super().__init__(name, obj=None, config=config or {})
|
||||
self._client = client
|
||||
|
||||
async def start(self) -> None:
|
||||
self.status = ResourceStatus.CONNECTING
|
||||
try:
|
||||
if self._client is None:
|
||||
from pm3py import Proxmark3
|
||||
|
||||
self._client = Proxmark3(
|
||||
self.config.get("port"), self.config.get("baudrate", 115200)
|
||||
)
|
||||
await self._client.connect()
|
||||
except Exception as exc: # no device / connect failure — non-fatal
|
||||
self.status = ResourceStatus.DISCONNECTED
|
||||
self.detail = f"no device ({type(exc).__name__}: {exc})"
|
||||
self._client = None
|
||||
return
|
||||
self.obj = self._client
|
||||
self.status = ResourceStatus.CONNECTED
|
||||
fw = getattr(self._client, "firmware", None)
|
||||
self.detail = str(getattr(fw, "version_string", "") or "connected")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._client is not None:
|
||||
try:
|
||||
await self._client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
self.status = ResourceStatus.DISCONNECTED
|
||||
|
||||
|
||||
class Sim:
|
||||
"""User-facing live-sim controls, bound as ``sim`` in the namespace.
|
||||
|
||||
Arming is explicit: opening the workspace opens the port, but the sim goes live only when
|
||||
you call ``sim.start(tag)``. Push model edits to a running sim with ``sim.push(tag)``.
|
||||
"""
|
||||
|
||||
def __init__(self, session: Any):
|
||||
self._sess = session
|
||||
|
||||
def start(self, tag: Any, **kwargs: Any):
|
||||
"""Arm the live sim for ``tag`` — dispatches 14a vs 15693 by tag type."""
|
||||
from pm3py.sim import Tag14443A, Tag15693
|
||||
|
||||
if isinstance(tag, Tag15693):
|
||||
return self._sess.start_15693(tag, **kwargs)
|
||||
if isinstance(tag, Tag14443A):
|
||||
return self._sess.start_14a(tag, **kwargs)
|
||||
raise TypeError(f"cannot simulate {type(tag).__name__}: not a 14a or 15693 tag")
|
||||
|
||||
def stop(self):
|
||||
return self._sess.stop()
|
||||
|
||||
def push(self, tag: Any):
|
||||
"""Push model edits to the running sim (``tag.sync()``)."""
|
||||
return tag.sync()
|
||||
|
||||
@property
|
||||
def frames(self):
|
||||
"""Decoded reader↔tag trace frames from the running sim."""
|
||||
return self._sess.entries
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
return self._sess
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Sim session={self._sess!r}>"
|
||||
|
||||
|
||||
class SimResource(Resource):
|
||||
"""A live ``SimSession``, exposed as ``sim`` in the namespace.
|
||||
|
||||
Opens the port on session start; the sim is armed explicitly via ``sim.start(tag)``. A
|
||||
missing device is non-fatal (disconnected). ``session`` is injectable for tests.
|
||||
"""
|
||||
|
||||
kind = "sim"
|
||||
|
||||
def __init__(self, name: str = "sim", config: dict | None = None, *, session: Any = None):
|
||||
super().__init__(name, obj=None, config=config or {})
|
||||
self._sess = session
|
||||
|
||||
async def start(self) -> None:
|
||||
self.status = ResourceStatus.CONNECTING
|
||||
try:
|
||||
if self._sess is None:
|
||||
from pm3py.sim import SimSession
|
||||
|
||||
self._sess = SimSession.open(
|
||||
self.config.get("port", "/dev/ttyACM0"),
|
||||
self.config.get("baudrate", 115200),
|
||||
)
|
||||
except Exception as exc: # no device — non-fatal
|
||||
self.status = ResourceStatus.DISCONNECTED
|
||||
self.detail = f"no device ({type(exc).__name__}: {exc})"
|
||||
self._sess = None
|
||||
return
|
||||
self.obj = Sim(self._sess)
|
||||
self.status = ResourceStatus.CONNECTED
|
||||
self.detail = "ready (not armed — call sim.start(tag))"
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._sess is not None:
|
||||
try:
|
||||
self._sess.close() # stop() + close serial + unbind tag
|
||||
except Exception:
|
||||
pass
|
||||
self.status = ResourceStatus.DISCONNECTED
|
||||
|
||||
|
||||
class Pm3pyPlugin(WorkspacePlugin):
|
||||
name = "pm3py"
|
||||
version = "0.1.0"
|
||||
#: the first connection resource created wins the serial port (reader OR sim, not both)
|
||||
_mode: str | None = None
|
||||
|
||||
#: replay-safety effect patterns (pyws safe-by-default deny-list): write / field / sim /
|
||||
#: destructive are withheld on autoreplay; connect / read and pure model edits replay.
|
||||
#: Patterns are matched against the FULL dotted call path (e.g. reader.hf.mfu.wrbl).
|
||||
effects = {
|
||||
"connect": ["reader.connect", "reader.disconnect", "sim.open", "sim.close"],
|
||||
"read": [
|
||||
"reader.hf.*.scan", "reader.hf.*.rdbl", "reader.hf.*.rdsc",
|
||||
"reader.hf.mfu.rdbl", "reader.hf.*.inventory", "reader.lf.*.read", "*.read_block*",
|
||||
],
|
||||
"field": ["reader.hf.tune", "reader.hf.dropfield", "reader.lf.tune", "reader.hf.search"],
|
||||
"write": [
|
||||
"reader.hf.*.wrbl", "reader.hf.*.writebl", "reader.lf.*.writebl",
|
||||
"*.sync", "sim.push", "*.write_block*",
|
||||
],
|
||||
"sim": ["sim.start", "sim.stop", "sim.start_14a", "sim.start_15693", "reader.hf.*.sim"],
|
||||
"destructive": ["*.format*", "*.lock*"],
|
||||
}
|
||||
|
||||
def namespace(self, session) -> dict[str, Any]:
|
||||
import pm3py
|
||||
import pm3py.sim as sim
|
||||
|
||||
ns: dict[str, Any] = {"Proxmark3": pm3py.Proxmark3}
|
||||
for name in _SIM_EXPORTS:
|
||||
obj = getattr(sim, name, None)
|
||||
if obj is not None:
|
||||
ns[name] = obj
|
||||
return ns
|
||||
|
||||
def create_resource(self, name: str, config: dict, session):
|
||||
if name in ("reader", "sim"):
|
||||
if self._mode is not None and self._mode != name:
|
||||
log.warning(
|
||||
"pm3py: workspace declares both %r and %r, which contend on the serial "
|
||||
"port; skipping %r",
|
||||
self._mode, name, name,
|
||||
)
|
||||
return None
|
||||
self._mode = name
|
||||
if name == "reader":
|
||||
return ReaderResource(name, config)
|
||||
if name == "sim":
|
||||
return SimResource(name, config)
|
||||
return None
|
||||
@@ -16,29 +16,27 @@ from .mcu_bridge import McuBridge
|
||||
from .dual_session import DualInterfaceSession
|
||||
|
||||
# --- Transponder models (canonical home: pm3py.transponders) ---
|
||||
from pm3py.transponders.hf.iso14443a.base import (
|
||||
Tag14443A, Tag14443A_3, Tag14443A_4, Reader14443A, State14443A,
|
||||
)
|
||||
from pm3py.transponders.hf.iso14443a.nxp.crypto1 import Crypto1
|
||||
from pm3py.transponders.hf.iso14443a.nxp.mifare_classic import MifareClassicTag, MifareClassicReader
|
||||
from pm3py.transponders.hf.iso14443a.nxp.desfire import DesfireTag, DesfireReader
|
||||
from pm3py.transponders.hf.iso14443a.ndef import NfcType2Tag, NfcType4Tag
|
||||
from pm3py.transponders.hf.iso15693.base import Tag15693, Reader15693, State15693
|
||||
from pm3py.transponders.hf.iso15693.type5 import NfcType5Tag, ndef_text, ndef_uri, ndef_mime
|
||||
from pm3py.transponders.hf.iso15693.nxp.nxp_icode import NxpIcodeTag
|
||||
from pm3py.transponders.hf.iso15693.nxp.icode_slix import IcodeSlixTag
|
||||
from pm3py.transponders.hf.iso15693.nxp.icode_slix2 import IcodeSlix2Tag
|
||||
from pm3py.transponders.hf.iso15693.nxp.icode3 import Icode3Tag
|
||||
from pm3py.transponders.hf.iso15693.nxp.icode_dna import IcodeDnaTag
|
||||
from pm3py.transponders.hf.iso15693.nxp.ntag5_platform import Ntag5PlatformTag
|
||||
from pm3py.transponders.hf.iso15693.nxp.ntag5_switch import Ntag5SwitchTag
|
||||
from pm3py.transponders.hf.iso15693.nxp.ntag5_link import Ntag5LinkTag
|
||||
from pm3py.transponders.hf.iso15693.nxp.ntag5_boost import Ntag5BoostTag
|
||||
from pm3py.transponders.lf.base import TagLF, ReaderLF, Modulation
|
||||
from pm3py.transponders.lf.em.em4100 import EM4100Tag, EM4100Reader
|
||||
from pm3py.transponders.lf.hid.hid import HIDProxTag, HIDReader
|
||||
from pm3py.transponders.lf.atmel.t5577 import T5577Tag, T5577Reader
|
||||
from pm3py.transponders.implants import xEM, xNT, xM1, FlexDF, NExT, MagicMifareClassicTag
|
||||
# Loaded lazily (PEP 562) from ._models. Those models import back into pm3py.sim for the
|
||||
# Transponder/Reader/RFFrame/Medium primitives, so importing them at package-init time would
|
||||
# create a transponders<->sim circular import (breaking e.g. `from pm3py.transponders...` as a
|
||||
# first import). On first access they load from ._models and cache into this module's globals.
|
||||
def __getattr__(name):
|
||||
if name.startswith("__") and name.endswith("__"):
|
||||
raise AttributeError(name) # don't trigger a model import for dunder probes
|
||||
import importlib
|
||||
|
||||
models = importlib.import_module(f"{__name__}._models")
|
||||
try:
|
||||
value = getattr(models, name)
|
||||
except AttributeError:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
||||
globals()[name] = value # cache: subsequent access is a direct lookup
|
||||
return value
|
||||
|
||||
|
||||
def __dir__():
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RFFrame",
|
||||
@@ -48,17 +46,39 @@ __all__ = [
|
||||
"Reader", "ScriptedReader", "InteractiveReader", "ReaderStep", "StepResult",
|
||||
"Tag14443A", "Tag14443A_3", "Tag14443A_4", "Reader14443A", "State14443A",
|
||||
"Crypto1",
|
||||
"MifareClassicTag", "MifareClassicReader",
|
||||
"MifareClassicTag", "MifareClassicReader", "MifareAccessConditions",
|
||||
"Tag15693", "Reader15693", "State15693",
|
||||
"TagLF", "ReaderLF", "Modulation",
|
||||
"EM4100Tag", "EM4100Reader",
|
||||
"EM4100Tag", "EM4100Reader", "EM4100Code",
|
||||
"HIDProxTag", "HIDReader",
|
||||
"T5577Tag", "T5577Reader",
|
||||
"T5577Tag", "T5577Reader", "T5577Config",
|
||||
"Hitag2Tag", "Hitag2Reader",
|
||||
"BitField", "Register",
|
||||
"NfcType2Tag", "NfcType4Tag", "NfcType5Tag",
|
||||
"NxpType2Tag", "Type2Config",
|
||||
"Ntag21x", "NTAG210", "NTAG212", "NTAG213", "NTAG215", "NTAG216", "Ntag21xConfig",
|
||||
"MifareUltralight", "MifareUltralightC", "MifareUltralightEV1",
|
||||
"MF0UL11", "MF0UL21", "UL_C_DEFAULT_KEY",
|
||||
"UltralightEV1Config", "UltralightCConfig",
|
||||
"NtagI2C", "NT3H2111", "NT3H2211",
|
||||
"NtagI2CConfig", "NtagI2CSession", "NtagI2CPtI2C",
|
||||
"ST25TN", "ST25TN512", "ST25TN01K",
|
||||
"ST25TA", "ST25TA512B", "ST25TA02KB", "ST25TA16K", "ST25TA64K",
|
||||
"OptigaAuthenticateNBT",
|
||||
"ST25TV", "ST25TV512C", "ST25TV02KC",
|
||||
"ST25DV", "ST25DV04K", "ST25DV16K", "ST25DV64K", "St25dvRfAreaSS",
|
||||
"MydVicinity", "SRF55V02P", "SRF55V10P",
|
||||
"Tag14443B", "StateB", "Tag14443B_4", "StateB4",
|
||||
"ST25TB", "ST25TB512AC", "ST25TB512AT", "ST25TB02K", "ST25TB04K",
|
||||
"RF430CL330H",
|
||||
"TagItHFIPlus",
|
||||
"RF430FRL15xH", "RF430FRL152H", "RF430FRL153H", "RF430FRL154H",
|
||||
"ndef_text", "ndef_uri", "ndef_mime",
|
||||
"NxpIcodeTag", "IcodeSlixTag", "IcodeSlix2Tag",
|
||||
"Icode3Tag", "IcodeDnaTag",
|
||||
"Ntag5PlatformTag", "Ntag5SwitchTag", "Ntag5LinkTag", "Ntag5BoostTag",
|
||||
"Ntag5StatusReg", "Ntag5ConfigReg",
|
||||
"NxpKeyPrivileges", "IcodeFeatureFlags",
|
||||
"DesfireTag", "DesfireReader",
|
||||
"xEM", "xNT", "xM1", "FlexDF", "NExT", "MagicMifareClassicTag",
|
||||
"MutationFuzzer", "GrammarFuzzer",
|
||||
|
||||
71
pm3py/sim/_models.py
Normal file
71
pm3py/sim/_models.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Transponder-model re-exports for :mod:`pm3py.sim`.
|
||||
|
||||
Loaded lazily by the package's PEP 562 ``__getattr__`` — these models import back into
|
||||
``pm3py.sim`` (for the ``Transponder`` / ``Reader`` / ``RFFrame`` / ``Medium`` primitives), so
|
||||
importing them at ``sim`` package-init time creates a circular import. Their canonical home is
|
||||
``pm3py.transponders``; this module just re-exposes them under ``pm3py.sim``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pm3py.transponders.hf.iso14443a.base import (
|
||||
Tag14443A, Tag14443A_3, Tag14443A_4, Reader14443A, State14443A,
|
||||
)
|
||||
from pm3py.transponders.hf.iso14443a.nxp.crypto1 import Crypto1
|
||||
from pm3py.transponders.hf.iso14443a.nxp.mifare_classic import (
|
||||
MifareClassicTag, MifareClassicReader, MifareAccessConditions,
|
||||
)
|
||||
from pm3py.transponders.hf.iso14443a.nxp.desfire import DesfireTag, DesfireReader
|
||||
from pm3py.transponders.hf.iso14443a.ndef import NfcType2Tag, NfcType4Tag
|
||||
from pm3py.transponders.hf.iso14443a.nxp.type2 import NxpType2Tag, Type2Config
|
||||
from pm3py.transponders.hf.iso14443a.nxp.ntag21x import (
|
||||
Ntag21x, NTAG210, NTAG212, NTAG213, NTAG215, NTAG216, Ntag21xConfig,
|
||||
)
|
||||
from pm3py.transponders.hf.iso14443a.nxp.ultralight import (
|
||||
MifareUltralight, MifareUltralightC, MifareUltralightEV1, MF0UL11, MF0UL21,
|
||||
UL_C_DEFAULT_KEY, UltralightEV1Config, UltralightCConfig,
|
||||
)
|
||||
from pm3py.transponders.hf.iso14443a.nxp.ntag_i2c import (
|
||||
NtagI2C, NT3H2111, NT3H2211, NtagI2CConfig, NtagI2CSession, NtagI2CPtI2C,
|
||||
)
|
||||
from pm3py.transponders.hf.iso14443a.st.st25tn import ST25TN, ST25TN512, ST25TN01K
|
||||
from pm3py.transponders.hf.iso14443a.infineon.optiga_nbt import OptigaAuthenticateNBT
|
||||
from pm3py.transponders.hf.iso14443a.st.st25ta import (
|
||||
ST25TA, ST25TA512B, ST25TA02KB, ST25TA16K, ST25TA64K,
|
||||
)
|
||||
from pm3py.transponders.hf.iso15693.base import Tag15693, Reader15693, State15693
|
||||
from pm3py.transponders.hf.iso15693.type5 import NfcType5Tag, ndef_text, ndef_uri, ndef_mime
|
||||
from pm3py.transponders.hf.iso15693.nxp.nxp_icode import NxpIcodeTag
|
||||
from pm3py.transponders.hf.iso15693.nxp.icode_slix import IcodeSlixTag
|
||||
from pm3py.transponders.hf.iso15693.nxp.icode_slix2 import IcodeSlix2Tag
|
||||
from pm3py.transponders.hf.iso15693.nxp.icode3 import Icode3Tag, IcodeFeatureFlags
|
||||
from pm3py.transponders.hf.iso15693.nxp.icode_dna import IcodeDnaTag
|
||||
from pm3py.transponders.hf.iso15693.nxp.auth_aes import NxpKeyPrivileges
|
||||
from pm3py.transponders.hf.iso15693.nxp.ntag5_platform import (
|
||||
Ntag5PlatformTag, Ntag5StatusReg, Ntag5ConfigReg,
|
||||
)
|
||||
from pm3py.transponders.hf.iso15693.nxp.ntag5_switch import Ntag5SwitchTag
|
||||
from pm3py.transponders.hf.iso15693.nxp.ntag5_link import Ntag5LinkTag
|
||||
from pm3py.transponders.hf.iso15693.nxp.ntag5_boost import Ntag5BoostTag
|
||||
from pm3py.transponders.hf.iso15693.st.st25tv import ST25TV, ST25TV512C, ST25TV02KC
|
||||
from pm3py.transponders.hf.iso15693.st.st25dv import (
|
||||
ST25DV, ST25DV04K, ST25DV16K, ST25DV64K, St25dvRfAreaSS,
|
||||
)
|
||||
from pm3py.transponders.hf.iso15693.infineon.myd_vicinity import (
|
||||
MydVicinity, SRF55V02P, SRF55V10P,
|
||||
)
|
||||
from pm3py.transponders.hf.iso14443b.base import Tag14443B, StateB, Tag14443B_4, StateB4
|
||||
from pm3py.transponders.hf.iso14443b.st.st25tb import (
|
||||
ST25TB, ST25TB512AC, ST25TB512AT, ST25TB02K, ST25TB04K,
|
||||
)
|
||||
from pm3py.transponders.hf.iso14443b.ti.rf430cl330h import RF430CL330H
|
||||
from pm3py.transponders.hf.iso15693.ti.tagit import TagItHFIPlus
|
||||
from pm3py.transponders.hf.iso15693.ti.rf430frl import (
|
||||
RF430FRL15xH, RF430FRL152H, RF430FRL153H, RF430FRL154H,
|
||||
)
|
||||
from pm3py.transponders.lf.base import TagLF, ReaderLF, Modulation
|
||||
from pm3py.transponders.lf.em.em4100 import EM4100Tag, EM4100Reader, EM4100Code
|
||||
from pm3py.transponders.lf.hid.hid import HIDProxTag, HIDReader
|
||||
from pm3py.transponders.bitfield import BitField, Register
|
||||
from pm3py.transponders.lf.atmel.t5577 import T5577Tag, T5577Reader, T5577Config
|
||||
from pm3py.transponders.lf.nxp.hitag2 import Hitag2Tag, Hitag2Reader
|
||||
from pm3py.transponders.implants import xEM, xNT, xM1, FlexDF, NExT, MagicMifareClassicTag
|
||||
@@ -6,7 +6,6 @@ from typing import Iterator
|
||||
|
||||
from bitarray import bitarray
|
||||
from .frame import RFFrame
|
||||
from pm3py.transponders.hf.iso14443a.base import CL1, NVB_SELECT, _compute_bcc
|
||||
|
||||
|
||||
class MutationFuzzer:
|
||||
@@ -76,6 +75,9 @@ class GrammarFuzzer:
|
||||
return None
|
||||
|
||||
def _gen_14443a(self, command: str, **overrides) -> RFFrame | None:
|
||||
# deferred: iso14443a.base imports back into pm3py.sim, so a module-level import here would
|
||||
# create a transponders<->sim circular import when 14443a is the first thing imported
|
||||
from pm3py.transponders.hf.iso14443a.base import CL1, NVB_SELECT, _compute_bcc
|
||||
match command:
|
||||
case "REQA":
|
||||
return RFFrame.from_bytes(b"\x26")
|
||||
|
||||
@@ -11,6 +11,8 @@ import serial
|
||||
from .frame import RFFrame
|
||||
from .table_compiler import ResponseTable, TableCompiler
|
||||
from pm3py.trace.format import TraceFormatter
|
||||
from pm3py.trace.decode_iso14a import decode_14443a
|
||||
from pm3py.trace.output import emit
|
||||
from .transponder import Transponder
|
||||
from ..core.protocol import Cmd
|
||||
from ..core.transport import encode_ng_frame, decode_response_frame, RESP_PREAMBLE_MAGIC, RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE
|
||||
@@ -20,6 +22,20 @@ CMD_SIM_TABLE_UPLOAD = 0x0950
|
||||
CMD_SIM_TABLE_CLEAR = 0x0951
|
||||
CMD_SIM_TABLE_UPDATE = 0x0952
|
||||
CMD_HF_ISO15693_SIM_TRACE = 0x0336
|
||||
CMD_HF_ISO14443A_SIM_TRACE = 0x0339
|
||||
|
||||
# 14a sim flag bit (pm3_cmd.h FLAG_SIM_TRACE): stream live reader<->tag trace.
|
||||
FLAG_SIM_TRACE = 0x2000
|
||||
|
||||
# Sim-trace command id -> protocol tag used in trace entries (0 = ISO 15693,
|
||||
# 1 = ISO 14443-A). The firmware ring format is identical across protocols:
|
||||
# byte 0 = dir (low nibble) | crc_fail (0x80) | 0x03 ADC report (15693 only),
|
||||
# followed by the raw frame bytes. The active protocol's decoder lives in the
|
||||
# session formatter, so this only needs the protocol tag.
|
||||
_TRACE_CMDS = {
|
||||
CMD_HF_ISO15693_SIM_TRACE: 0,
|
||||
CMD_HF_ISO14443A_SIM_TRACE: 1,
|
||||
}
|
||||
|
||||
# Max NG payload per frame
|
||||
MAX_PAYLOAD = 512
|
||||
@@ -50,6 +66,10 @@ class SimSession:
|
||||
self._tag_model: Transponder | None = None
|
||||
self._formatter: TraceFormatter | None = None
|
||||
self.on_field_strength: callable | None = None # callback(adc_mv: int)
|
||||
self._entries: list[dict] = []
|
||||
self._on_frame = None
|
||||
self._quiet = False
|
||||
self._print_frames = False
|
||||
|
||||
@classmethod
|
||||
def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SimSession":
|
||||
@@ -94,35 +114,118 @@ class SimSession:
|
||||
"""Clear the firmware response table."""
|
||||
await self._t.send_ng(CMD_SIM_TABLE_CLEAR, b"")
|
||||
|
||||
async def start_14a(self, tag, compile: bool = True) -> None:
|
||||
"""Compile table, upload, start 14443-A sim with WTX relay loop."""
|
||||
self._tag_model = tag
|
||||
if compile:
|
||||
if hasattr(tag, '_keys_a'):
|
||||
table = TableCompiler.compile_mifare(tag)
|
||||
else:
|
||||
table = TableCompiler.compile_14a(tag)
|
||||
await self.upload_table(table)
|
||||
def start_14a(self, tag, tagtype: int = 7, trace: bool = False,
|
||||
on_frame=None, quiet: bool = False, exit_after: int = 0) -> None:
|
||||
"""Start an ISO 14443-A (Layer 3) sim, optionally streaming a live trace.
|
||||
|
||||
# Start sim (fire-and-forget — response comes when sim ends)
|
||||
Synchronous, mirroring :meth:`start_15693`: the firmware runs
|
||||
anticollision and the tagtype's built-in command set autonomously; this
|
||||
sends the sim-start frame over raw serial and, when tracing, spawns the
|
||||
reader thread that decodes each reader↔tag frame.
|
||||
|
||||
Args:
|
||||
tagtype: firmware emulation type (see ``hf.iso14a.sim``; 7 = MFU
|
||||
EV1/NTAG215, 1 = MIFARE Classic 1k, 2 = Ultralight, ...).
|
||||
trace: if True, print live reader↔tag communication to the console.
|
||||
on_frame: optional callback(frame: dict) invoked per decoded frame
|
||||
(see the ``entries`` property for the dict shape). Passing it
|
||||
auto-enables trace streaming.
|
||||
quiet: if True, don't print frames (callback + ``entries`` still fill).
|
||||
exit_after: stop the sim after N reader reads (0 = run until stopped).
|
||||
|
||||
Note: this starts anticollision + protocol handling from the tagtype's
|
||||
firmware model. Loading the tag's memory contents into emulator RAM (so
|
||||
reads return model data) is a separate step and is not performed here;
|
||||
the trace still shows every reader↔tag frame regardless.
|
||||
|
||||
14a-4 (ISO-DEP / WTX relay) reuses the same firmware trace but routes it
|
||||
through the async relay loop — see the plan's I/O model decision.
|
||||
"""
|
||||
# Local import: pm3py.transponders.hf.iso14443a.base pulls in pm3py.sim,
|
||||
# so a module-level import here would be circular.
|
||||
from pm3py.transponders.hf.iso14443a.base import Tag14443A
|
||||
if not isinstance(tag, Tag14443A):
|
||||
raise TypeError(
|
||||
f"start_14a requires an ISO 14443-A transponder (a Tag14443A "
|
||||
f"subclass), got {type(tag).__name__}. Use start_15693() for "
|
||||
f"ISO 15693 tags."
|
||||
)
|
||||
|
||||
self._tag_model = tag
|
||||
self._on_frame = on_frame
|
||||
self._quiet = quiet
|
||||
self._print_frames = trace and not quiet
|
||||
stream = trace or (on_frame is not None)
|
||||
|
||||
# Resolve the raw serial port
|
||||
if self._port:
|
||||
ser = self._port
|
||||
elif self._t and self._t._writer:
|
||||
ser = self._t._writer.transport.serial
|
||||
else:
|
||||
raise RuntimeError("No serial port available")
|
||||
|
||||
tag._serial = ser
|
||||
|
||||
# Fold the UID length into flags per FLAG_SET_UID_IN_DATA (pm3_cmd.h).
|
||||
uid = tag._uid
|
||||
flag_val = 0x0001 # FLAG_INTERACTIVE
|
||||
if len(uid) == 4:
|
||||
flag_val |= 0x0010
|
||||
uid_flag = 0x0010 # FLAG_4B_UID_IN_DATA
|
||||
elif len(uid) == 7:
|
||||
flag_val |= 0x0020
|
||||
uid_flag = 0x0020 # FLAG_7B_UID_IN_DATA
|
||||
elif len(uid) == 10:
|
||||
flag_val |= 0x0030
|
||||
atqa = tag.atqa if hasattr(tag, 'atqa') else b"\x04\x00"
|
||||
sak = tag.sak if hasattr(tag, 'sak') else 0x08
|
||||
payload = atqa + bytes([sak]) + uid
|
||||
await self._t.send_ng_no_response(Cmd.HF_ISO14443A_SIMULATE)
|
||||
# TODO: use send_mix for 14a sim (MIX frame format)
|
||||
uid_flag = 0x0030 # FLAG_10B_UID_IN_DATA
|
||||
else:
|
||||
raise ValueError("UID must be 4, 7, or 10 bytes")
|
||||
flag_val = uid_flag
|
||||
if stream:
|
||||
flag_val |= FLAG_SIM_TRACE
|
||||
|
||||
# HF_ISO14443A_SIMULATE NG payload — same layout as hf.iso14a.sim():
|
||||
# struct { u8 tagtype; u16 flags; u8 uid[10]; u8 exitAfter;
|
||||
# u8 rats[20]; u8 ulauth_1a1_len; u8 ulauth_1a2_len;
|
||||
# u8 ulauth_1a1[16]; u8 ulauth_1a2[16]; bool mirror; } PACKED
|
||||
payload = struct.pack(
|
||||
"<BH10sB20sBB16s16sB",
|
||||
tagtype & 0xFF,
|
||||
flag_val & 0xFFFF,
|
||||
uid.ljust(10, b"\x00")[:10],
|
||||
exit_after & 0xFF,
|
||||
b"\x00" * 20,
|
||||
0, 0,
|
||||
b"\x00" * 16, b"\x00" * 16,
|
||||
0,
|
||||
)
|
||||
|
||||
# Load the model's memory into emulator RAM BEFORE starting the sim, so the
|
||||
# reader sees real page data (UID pages, CC, NDEF, PWD, PACK) instead of a
|
||||
# stale/empty buffer. The MFU/NTAG sim reads pages + version + signature from
|
||||
# this mfu_dump image (firmware is data-driven for tagtype 2/7). Handled by
|
||||
# the firmware command dispatch here (sim not yet running); tag.sync() reuses
|
||||
# the same push to update a running sim.
|
||||
if hasattr(tag, "_build_mfu_dump"):
|
||||
tag.sync()
|
||||
time.sleep(0.05)
|
||||
|
||||
frame = encode_ng_frame(Cmd.HF_ISO14443A_SIMULATE, payload)
|
||||
ser.write(frame)
|
||||
time.sleep(0.2) # let firmware start the sim before streaming
|
||||
|
||||
self._active = True
|
||||
self._relay_task = asyncio.create_task(self._relay_loop(tag))
|
||||
|
||||
def start_15693(self, tag, compile: bool = True, trace: bool = False) -> None:
|
||||
if stream:
|
||||
decoder = getattr(tag, "decode_trace", None) or decode_14443a
|
||||
self._formatter = TraceFormatter(mode="sim", decoder=decoder, crc_len=2)
|
||||
self._trace_thread = threading.Thread(
|
||||
target=self._trace_reader, args=(ser,), daemon=True
|
||||
)
|
||||
self._trace_thread.start()
|
||||
|
||||
print(f"[SimSession] 14a sim started, tagtype={tagtype}, UID={uid.hex()}"
|
||||
+ (" (trace ON)" if stream else ""))
|
||||
|
||||
def start_15693(self, tag, compile: bool = True, trace: bool = False,
|
||||
on_frame=None, quiet: bool = False) -> None:
|
||||
"""Start 15693 sim (synchronous).
|
||||
|
||||
Firmware handles standard commands (inventory, read, write) autonomously.
|
||||
@@ -131,8 +234,26 @@ class SimSession:
|
||||
|
||||
Args:
|
||||
trace: If True, print live reader↔tag communication to console.
|
||||
on_frame: optional callback(frame: dict) invoked per decoded frame from
|
||||
the reader thread — for scripting (see the `entries` property for the
|
||||
dict shape). Passing it auto-enables trace streaming.
|
||||
quiet: if True, don't print frames (callback + `entries` still fill).
|
||||
"""
|
||||
# Local import: pm3py.transponders.hf.iso15693.base pulls in pm3py.sim,
|
||||
# so a module-level import here would be circular.
|
||||
from pm3py.transponders.hf.iso15693.base import Tag15693
|
||||
if not isinstance(tag, Tag15693):
|
||||
raise TypeError(
|
||||
f"start_15693 requires an ISO 15693 transponder (a Tag15693 "
|
||||
f"subclass), got {type(tag).__name__}. Use start_14a() for "
|
||||
f"ISO 14443-A tags."
|
||||
)
|
||||
|
||||
self._tag_model = tag
|
||||
self._on_frame = on_frame
|
||||
self._quiet = quiet
|
||||
self._print_frames = trace and not quiet
|
||||
stream = trace or (on_frame is not None)
|
||||
|
||||
# Resolve the raw serial port
|
||||
if self._port:
|
||||
@@ -147,7 +268,7 @@ class SimSession:
|
||||
|
||||
# Start sim with UID and block_size — same format as PM3 client
|
||||
# Client sends UID as-is (firmware reverses internally)
|
||||
flags = 0x02 if trace else 0x00
|
||||
flags = 0x02 if stream else 0x00
|
||||
block_size = tag._block_size if hasattr(tag, '_block_size') else 4
|
||||
payload = tag._uid + bytes([block_size, flags])
|
||||
frame = encode_ng_frame(Cmd.HF_ISO15693_SIMULATE, payload)
|
||||
@@ -168,7 +289,7 @@ class SimSession:
|
||||
if compile:
|
||||
self._compile_and_upload_table(tag, ser)
|
||||
|
||||
if trace:
|
||||
if stream:
|
||||
self._formatter = TraceFormatter(mode="sim", decoder=tag.decode_trace, crc_len=2)
|
||||
self._trace_thread = threading.Thread(
|
||||
target=self._trace_reader, args=(ser,), daemon=True
|
||||
@@ -176,7 +297,7 @@ class SimSession:
|
||||
self._trace_thread.start()
|
||||
|
||||
print(f"[SimSession] 15693 sim started, UID={tag._uid.hex()}"
|
||||
+ (" (trace ON)" if trace else ""))
|
||||
+ (" (trace ON)" if stream else ""))
|
||||
print(f"[SimSession] Modify tag in REPL, then call tag.sync()")
|
||||
|
||||
def _compile_and_upload_table(self, tag, ser) -> None:
|
||||
@@ -268,19 +389,45 @@ class SimSession:
|
||||
resp = decode_response_frame(bytes(buf[:frame_len]))
|
||||
buf = buf[frame_len:]
|
||||
|
||||
if resp["cmd"] == CMD_HF_ISO15693_SIM_TRACE:
|
||||
if resp["cmd"] == Cmd.HF_MIFARE_SIMULATE:
|
||||
# 14a sim tore down (button press, host stop, or
|
||||
# exit_after). The firmware starts the sim with
|
||||
# HF_ISO14443A_SIMULATE (0x0384) but its teardown reply
|
||||
# is HF_MIFARE_SIMULATE (0x0610) — watch for the latter.
|
||||
self._active = False
|
||||
emit(f"[SimSession] 14a sim ended, status={resp['status']}\n")
|
||||
return
|
||||
|
||||
if resp["cmd"] in _TRACE_CMDS:
|
||||
protocol = _TRACE_CMDS[resp["cmd"]]
|
||||
data = resp["data"]
|
||||
if len(data) >= 2:
|
||||
direction = data[0] & 0x0F
|
||||
if direction == 0x03 and len(data) >= 3:
|
||||
# ADC field strength report
|
||||
if protocol == 0 and direction == 0x03 and len(data) >= 3:
|
||||
# ADC field strength report (ISO 15693 only)
|
||||
adc_mv = (data[1] << 8) | data[2]
|
||||
if self.on_field_strength:
|
||||
self.on_field_strength(adc_mv)
|
||||
else:
|
||||
crc_fail = bool(data[0] & 0x80)
|
||||
payload = data[1:]
|
||||
self._formatter.print(direction, payload, crc_fail=crc_fail)
|
||||
entry = {
|
||||
"protocol": protocol,
|
||||
"direction": direction,
|
||||
"timestamp": None,
|
||||
"duration": None,
|
||||
"data": payload,
|
||||
"data_hex": payload.hex(),
|
||||
"decoded": self._formatter.decode(
|
||||
direction, payload, crc_fail),
|
||||
"crc_fail": crc_fail,
|
||||
}
|
||||
self._entries.append(entry)
|
||||
if self._on_frame is not None:
|
||||
self._on_frame(entry)
|
||||
if self._print_frames:
|
||||
self._formatter.print(
|
||||
direction, payload, crc_fail=crc_fail)
|
||||
except Exception:
|
||||
buf = buf[4:] # skip bad magic, try again
|
||||
|
||||
@@ -305,8 +452,9 @@ class SimSession:
|
||||
if resp is None:
|
||||
continue
|
||||
|
||||
elif resp.cmd == Cmd.HF_ISO15693_SIMULATE:
|
||||
# Sim ended (firmware sent final reply)
|
||||
elif resp.cmd in (Cmd.HF_ISO15693_SIMULATE, Cmd.HF_MIFARE_SIMULATE):
|
||||
# Sim ended (firmware sent final reply). 15693 replies with
|
||||
# HF_ISO15693_SIMULATE; 14a tears down with HF_MIFARE_SIMULATE.
|
||||
print(f"[SimSession] Sim ended, status={resp.status}")
|
||||
self._active = False
|
||||
break
|
||||
@@ -361,6 +509,20 @@ class SimSession:
|
||||
payload = struct.pack("<IH", offset, len(data)) + data
|
||||
await self._t.send_ng_no_response(Cmd.HF_ISO15693_EML_SETMEM, payload)
|
||||
|
||||
@property
|
||||
def entries(self) -> list[dict]:
|
||||
"""Decoded reader↔tag frames captured while streaming (a copy).
|
||||
|
||||
Each: {'protocol', 'direction', 'timestamp', 'duration', 'data': bytes,
|
||||
'data_hex': str, 'decoded': str|None, 'crc_fail': bool}. Populated when
|
||||
the sim runs with trace=True or an on_frame callback.
|
||||
"""
|
||||
return list(self._entries)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Discard accumulated frames."""
|
||||
self._entries.clear()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop simulation."""
|
||||
self._active = False
|
||||
|
||||
@@ -128,3 +128,8 @@ class Transponder(abc.ABC):
|
||||
for r in self.regions.values():
|
||||
if r.eml_offset >= 0:
|
||||
self._eml_push_region(r)
|
||||
|
||||
@property
|
||||
def uid(self) -> bytes:
|
||||
"""Current tag UID."""
|
||||
return self._parse_uid(self._uid)
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Sniff sessions — live streaming with button-toggle, or legacy batch."""
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
@@ -19,6 +18,7 @@ from ..core.transport import (
|
||||
from ..trace import parse_tracelog
|
||||
from ..trace.decode_iso15 import decode_15693
|
||||
from ..trace.format import TraceFormatter, format_sniff_line
|
||||
from ..trace.output import emit
|
||||
|
||||
# Default serial port (same as SimSession)
|
||||
DEFAULT_PORT = "/dev/ttyACM0"
|
||||
@@ -65,6 +65,8 @@ class SniffSession:
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
self._formatter: TraceFormatter | None = None
|
||||
self._state = "idle"
|
||||
self._on_frame = None
|
||||
self._quiet = False
|
||||
|
||||
@classmethod
|
||||
def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SniffSession":
|
||||
@@ -88,14 +90,20 @@ class SniffSession:
|
||||
|
||||
return cls(ser)
|
||||
|
||||
def start_15693(self, live: bool = True, idle_timeout_ms: int = 400) -> None:
|
||||
def start_15693(self, live: bool = True, idle_timeout_ms: int = 400,
|
||||
on_frame=None, quiet: bool = False) -> None:
|
||||
"""Start 15693 sniff session.
|
||||
|
||||
live=True (default): streaming with button-toggle pause/resume.
|
||||
live=False: legacy BigBuf batch mode, blocks until button exits.
|
||||
on_frame: optional callback(frame: dict) invoked per decoded frame from the
|
||||
reader thread — for scripting (see the `entries` property for the dict shape).
|
||||
quiet: if True, don't print frames to the console (callback + `entries` still fill).
|
||||
"""
|
||||
if self._active:
|
||||
self.stop()
|
||||
self._on_frame = on_frame
|
||||
self._quiet = quiet
|
||||
|
||||
self._protocol = 0 # 15693
|
||||
flags = 0
|
||||
@@ -190,10 +198,13 @@ class SniffSession:
|
||||
"duration": duration,
|
||||
"timestamp": timestamp,
|
||||
"data": payload,
|
||||
"data_hex": payload.hex(),
|
||||
"decoded": self._formatter.decode(direction, payload) if self._formatter else None,
|
||||
}
|
||||
self._entries.append(entry)
|
||||
|
||||
if self._formatter:
|
||||
if self._on_frame is not None:
|
||||
self._on_frame(entry)
|
||||
if self._formatter and not self._quiet:
|
||||
self._formatter.print(direction, payload)
|
||||
|
||||
def _handle_status(self, data: bytes) -> None:
|
||||
@@ -209,8 +220,7 @@ class SniffSession:
|
||||
count_str = f" ({count} frames)" if count > 0 else ""
|
||||
|
||||
msg = f"\n[Snf] {icon} {label}{count_str}\n"
|
||||
sys.stdout.write(msg)
|
||||
sys.stdout.flush()
|
||||
emit(msg)
|
||||
|
||||
if state == SNIFF_STATE_STOPPED:
|
||||
self._active = False
|
||||
|
||||
@@ -13,6 +13,84 @@ def decode_14443a(direction: int, payload: bytes) -> str | None:
|
||||
return _decode_14443a_response(payload)
|
||||
|
||||
|
||||
# ---- NFC Forum Type 2 / MIFARE Ultralight / NTAG command set ----
|
||||
#
|
||||
# These decoders cover the byte-oriented command set shared by MIFARE
|
||||
# Ultralight (MF0ICU2), Ultralight EV1 (MF0UL11/21), NTAG21x
|
||||
# (NTAG210/212/213/215/216) and NTAG I2C plus (NT3H2111/2211). They are
|
||||
# layered on top of the generic 14443-A decoder by the tag models'
|
||||
# ``decode_trace`` (see NfcType2Tag), mirroring how the NXP ICODE tags
|
||||
# layer ``decode_15693_nxp`` over the base 15693 decoder.
|
||||
|
||||
_TYPE2_CMDS = {
|
||||
0x30: "READ",
|
||||
0x3A: "FAST_READ",
|
||||
0xA2: "WRITE",
|
||||
0xA0: "COMPAT_WRITE",
|
||||
0x60: "GET_VERSION",
|
||||
0x1B: "PWD_AUTH",
|
||||
0x39: "READ_CNT",
|
||||
0xA5: "INCR_CNT",
|
||||
0x3E: "CHECK_TEARING",
|
||||
0x3C: "READ_SIG",
|
||||
0xC2: "SECTOR_SELECT",
|
||||
0x50: "HALT",
|
||||
}
|
||||
|
||||
|
||||
def _decode_type2_request(payload: bytes) -> str | None:
|
||||
b0 = payload[0]
|
||||
name = _TYPE2_CMDS.get(b0)
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
if b0 == 0x30 and len(payload) >= 2: # READ <page>
|
||||
return f"READ p{payload[1]}"
|
||||
if b0 == 0x3A and len(payload) >= 3: # FAST_READ <start> <end>
|
||||
return f"FAST_READ p{payload[1]}-{payload[2]}"
|
||||
if b0 in (0xA2, 0xA0) and len(payload) >= 2: # WRITE / COMPAT_WRITE <page>
|
||||
return f"{name} p{payload[1]}"
|
||||
if b0 == 0x1B and len(payload) >= 5: # PWD_AUTH <4-byte pwd>
|
||||
return f"PWD_AUTH {payload[1:5].hex().upper()}"
|
||||
if b0 == 0x39 and len(payload) >= 2: # READ_CNT <counter>
|
||||
return f"READ_CNT #{payload[1]}"
|
||||
if b0 == 0xA5 and len(payload) >= 2: # INCR_CNT <counter>
|
||||
return f"INCR_CNT #{payload[1]}"
|
||||
if b0 == 0x3E and len(payload) >= 2: # CHECK_TEARING <counter>
|
||||
return f"CHECK_TEARING #{payload[1]}"
|
||||
if b0 == 0xC2 and len(payload) >= 2 and payload[1] == 0xFF:
|
||||
return "SECTOR_SELECT p1" # packet 1 (C2 FF)
|
||||
return name
|
||||
|
||||
|
||||
def _decode_type2_response(payload: bytes) -> str | None:
|
||||
# 4-bit ACK/NAK acknowledge responses
|
||||
if len(payload) == 1:
|
||||
b0 = payload[0]
|
||||
if b0 == 0x0A:
|
||||
return "ACK"
|
||||
if b0 in (0x00, 0x01, 0x04, 0x05):
|
||||
return f"NAK 0x{b0:X}"
|
||||
if len(payload) == 8:
|
||||
return f"VERSION {payload.hex().upper()}"
|
||||
return None
|
||||
|
||||
|
||||
def decode_14443a_type2(direction: int, payload: bytes) -> str | None:
|
||||
"""Decode a Type 2 (Ultralight/NTAG) frame, falling back to generic 14443-A.
|
||||
|
||||
Used by the ``decode_trace`` of NfcType2Tag and its subclasses so that
|
||||
byte-oriented commands (READ, FAST_READ, GET_VERSION, PWD_AUTH, counters,
|
||||
SECTOR_SELECT) get named while anticollision/REQA/SAK still decode via the
|
||||
base decoder.
|
||||
"""
|
||||
if not payload:
|
||||
return None
|
||||
if direction == 0:
|
||||
return _decode_type2_request(payload) or _decode_14443a_request(payload)
|
||||
return _decode_type2_response(payload) or _decode_14443a_response(payload)
|
||||
|
||||
|
||||
def _decode_14443a_request(payload: bytes) -> str | None:
|
||||
b0 = payload[0]
|
||||
|
||||
|
||||
98
pm3py/trace/decode_iso14b.py
Normal file
98
pm3py/trace/decode_iso14b.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""ISO 14443 Type B (SRIX / ST25TB lineage) request/response decoders + CRC_B."""
|
||||
from __future__ import annotations
|
||||
|
||||
# ST25TB / SRIX command opcodes
|
||||
_14B_CMDS = {
|
||||
0x08: "READ_BLOCK",
|
||||
0x09: "WRITE_BLOCK",
|
||||
0x0B: "GET_UID",
|
||||
0x0C: "RESET_TO_INVENTORY",
|
||||
0x0E: "SELECT",
|
||||
0x0F: "COMPLETION",
|
||||
}
|
||||
|
||||
|
||||
def decode_14443b(direction: int, payload: bytes) -> str | None:
|
||||
"""Decode an ISO 14443 Type B (ST25TB/SRIX) frame."""
|
||||
if not payload:
|
||||
return None
|
||||
if direction == 0:
|
||||
return _decode_request(payload)
|
||||
return _decode_response(payload)
|
||||
|
||||
|
||||
def _decode_request(payload: bytes) -> str | None:
|
||||
b0 = payload[0]
|
||||
|
||||
# SRIX / ST25TB anticollision (opcode 0x06)
|
||||
if b0 == 0x06 and len(payload) >= 2:
|
||||
if payload[1] == 0x00:
|
||||
return "INITIATE"
|
||||
if payload[1] == 0x04:
|
||||
return "PCALL16"
|
||||
return f"ANTICOL 0x{payload[1]:02X}"
|
||||
|
||||
# SRIX slot marker: low nibble = 6, high nibble = slot 1..15
|
||||
if (b0 & 0x0F) == 0x06 and (b0 >> 4) != 0:
|
||||
return f"SLOT_MARKER {b0 >> 4}"
|
||||
|
||||
# Standard ISO 14443-3 Type B (REQB/WUPB/ATTRIB/HLTB)
|
||||
if b0 == 0x05 and len(payload) >= 3:
|
||||
return "WUPB" if (payload[2] & 0x08) else "REQB"
|
||||
if (b0 & 0x0F) == 0x05 and (b0 >> 4) != 0:
|
||||
return f"SLOT-MARKER {b0 >> 4}"
|
||||
if b0 == 0x1D:
|
||||
return "ATTRIB"
|
||||
if b0 == 0x50 and len(payload) >= 5:
|
||||
return "HLTB"
|
||||
|
||||
# SRIX / ST25TB memory + select opcodes (must precede the I-block fallback,
|
||||
# since they share the PCB=00 bit pattern).
|
||||
name = _14B_CMDS.get(b0)
|
||||
if name is not None:
|
||||
if b0 == 0x08 and len(payload) >= 2:
|
||||
return f"READ_BLOCK #{payload[1]}"
|
||||
if b0 == 0x09 and len(payload) >= 2:
|
||||
return f"WRITE_BLOCK #{payload[1]}"
|
||||
if b0 == 0x0E and len(payload) >= 2:
|
||||
return f"SELECT chip=0x{payload[1]:02X}"
|
||||
return name
|
||||
|
||||
# ISO-DEP I-block (PCB) — fallback for standard Type B Layer 4.
|
||||
if b0 & 0xC0 == 0x00 and len(payload) > 1:
|
||||
return f"I-BLOCK({b0 & 0x01})"
|
||||
return None
|
||||
|
||||
|
||||
def _decode_response(payload: bytes) -> str | None:
|
||||
# ATQB: 50 | PUPI(4) | AppData(4) | ProtInfo(3) = 12 bytes
|
||||
if len(payload) == 12 and payload[0] == 0x50:
|
||||
return f"ATQB PUPI={payload[1:5].hex().upper()}"
|
||||
if len(payload) == 1:
|
||||
return f"CHIP_ID 0x{payload[0]:02X}"
|
||||
if len(payload) == 4:
|
||||
return f"BLOCK {payload.hex().upper()}"
|
||||
if len(payload) == 8:
|
||||
return f"UID {bytes(reversed(payload)).hex().upper()}"
|
||||
# ISO-DEP I-block response
|
||||
if payload[0] & 0xC0 == 0x00 and len(payload) > 1:
|
||||
return f"I-BLOCK({payload[0] & 0x01})"
|
||||
return None
|
||||
|
||||
|
||||
def crc14b(data: bytes) -> bytes:
|
||||
"""ISO/IEC 14443-3 Type B CRC (CRC_B), transmitted LSByte first.
|
||||
|
||||
Polynomial x^16 + x^12 + x^5 + 1, init 0xFFFF, result one's-complemented,
|
||||
reflected. Matches the reference routine in the ST25TB datasheet appendix.
|
||||
"""
|
||||
crc = 0xFFFF
|
||||
for b in data:
|
||||
crc ^= b
|
||||
for _ in range(8):
|
||||
if crc & 0x0001:
|
||||
crc = (crc >> 1) ^ 0x8408
|
||||
else:
|
||||
crc >>= 1
|
||||
crc = (~crc) & 0xFFFF
|
||||
return bytes([crc & 0xFF, (crc >> 8) & 0xFF])
|
||||
@@ -42,6 +42,26 @@ _15693_NXP_CMDS = {
|
||||
0xBD: "NXP READ SIGNATURE",
|
||||
}
|
||||
|
||||
_15693_ST_CMDS = {
|
||||
0xA0: "ST READ CONFIG",
|
||||
0xA1: "ST WRITE CONFIG",
|
||||
0xA6: "ST KILL",
|
||||
0xB1: "ST WRITE PASSWORD",
|
||||
0xB3: "ST PRESENT PASSWORD",
|
||||
0xB4: "ST GET RANDOM",
|
||||
0xBA: "ST TOGGLE DISCREET/SILENT",
|
||||
0xB5: "ST FAST READ DYN CONFIG",
|
||||
0xB7: "ST FAST WRITE DYN CONFIG",
|
||||
0xAA: "ST READ DYN CONFIG",
|
||||
0xAB: "ST WRITE DYN CONFIG",
|
||||
0xAC: "ST READ MAILBOX MSG LEN",
|
||||
0xAD: "ST READ MAILBOX MSG",
|
||||
0xAE: "ST WRITE MAILBOX MSG",
|
||||
0xC0: "ST MANAGE GPO",
|
||||
0xD1: "ST INVENTORY INITIATED",
|
||||
0xD2: "ST INITIATE",
|
||||
}
|
||||
|
||||
_15693_ERRORS = {
|
||||
0x01: "not supported",
|
||||
0x02: "not recognized",
|
||||
@@ -212,3 +232,22 @@ def decode_15693_nxp(direction: int, payload: bytes) -> str | None:
|
||||
if name is not None:
|
||||
return _annotate_nxp_request(name, cmd, payload)
|
||||
return None
|
||||
|
||||
|
||||
def decode_15693_st(direction: int, payload: bytes) -> str | None:
|
||||
"""Decode an ST (STMicroelectronics) custom ISO 15693 command."""
|
||||
if direction != 0:
|
||||
return None
|
||||
if len(payload) < 2:
|
||||
return None
|
||||
cmd = payload[1]
|
||||
name = _15693_ST_CMDS.get(cmd)
|
||||
if name is None:
|
||||
return None
|
||||
# ST custom commands carry the mfg code (0x02) then, often, FID/PID or a
|
||||
# password id. Annotate the common ones.
|
||||
if cmd in (0xA0, 0xA1) and len(payload) >= 5: # READ/WRITE CONFIG: FID/PID
|
||||
return f"{name} FID={payload[3]:02X} PID={payload[4]:02X}"
|
||||
if cmd in (0xB3, 0xB1, 0xBA) and len(payload) >= 4: # password id
|
||||
return f"{name} id={payload[3]}"
|
||||
return name
|
||||
|
||||
@@ -6,6 +6,7 @@ import signal
|
||||
import sys
|
||||
|
||||
from .decode_iso15 import decode_15693
|
||||
from .output import emit
|
||||
|
||||
# ---- ANSI colors ----
|
||||
_C_CYAN = "\033[36m"
|
||||
@@ -65,6 +66,21 @@ class TraceFormatter:
|
||||
return text
|
||||
return f"{code}{text}{_C_RESET}"
|
||||
|
||||
def decode(self, direction: int, payload: bytes, crc_fail: bool = False) -> str | None:
|
||||
"""Return the plain-text decoded annotation for a frame — no ANSI, no hex.
|
||||
|
||||
The same annotation ``format()`` renders (e.g. "INVENTORY", "READ BLOCK 4"),
|
||||
exposed as structured data for scripting/callbacks.
|
||||
"""
|
||||
if self._crc_len > 0 and len(payload) > self._crc_len:
|
||||
data = payload[:-self._crc_len]
|
||||
else:
|
||||
data = payload
|
||||
annotation = self._decoder(direction, data) if self._decoder is not None else None
|
||||
if crc_fail:
|
||||
annotation = f"{annotation} BAD CRC" if annotation else "BAD CRC"
|
||||
return annotation
|
||||
|
||||
def format(self, direction: int, payload: bytes, crc_fail: bool = False) -> str:
|
||||
"""Format a trace line with colors, decoding, and wrapping."""
|
||||
self._line_count += 1
|
||||
@@ -135,7 +151,7 @@ class TraceFormatter:
|
||||
line = f"{prefix_colored}{hex_colored}{' ' * gap}{ann_colored}"
|
||||
else:
|
||||
line = f"{prefix_colored}{hex_colored}"
|
||||
return f"\n{line}"
|
||||
return f"{line}\n"
|
||||
|
||||
# Multi-line
|
||||
pad = " " * prefix_len
|
||||
@@ -153,7 +169,7 @@ class TraceFormatter:
|
||||
ann_pad = max(avail - len(al), 0)
|
||||
parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, al)}")
|
||||
|
||||
return "\n" + "\n".join(parts)
|
||||
return "\n".join(parts) + "\n"
|
||||
|
||||
def _color_hex_line(self, line: str, data_hex: str, crc_hex: str) -> str:
|
||||
if not crc_hex:
|
||||
@@ -214,9 +230,8 @@ class TraceFormatter:
|
||||
return lines or [""]
|
||||
|
||||
def print(self, direction: int, payload: bytes, crc_fail: bool = False) -> None:
|
||||
"""Format and print a trace line to stdout."""
|
||||
sys.stdout.write(self.format(direction, payload, crc_fail=crc_fail))
|
||||
sys.stdout.flush()
|
||||
"""Format and print a trace line, live even under bpython."""
|
||||
emit(self.format(direction, payload, crc_fail=crc_fail))
|
||||
|
||||
|
||||
def format_sniff_line(entry: dict, width: int = 120, is_tty: bool = True) -> str:
|
||||
@@ -228,5 +243,5 @@ def format_sniff_line(entry: dict, width: int = 120, is_tty: bool = True) -> str
|
||||
width=width, is_tty=is_tty, crc_len=2)
|
||||
direction = 1 if entry["is_response"] else 0
|
||||
result = fmt.format(direction, entry["data"])
|
||||
# Strip leading newline that TraceFormatter adds
|
||||
return result.lstrip("\n")
|
||||
# Strip the trailing newline that TraceFormatter adds
|
||||
return result.rstrip("\n")
|
||||
|
||||
75
pm3py/trace/output.py
Normal file
75
pm3py/trace/output.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Terminal output that stays live under interactive REPLs.
|
||||
|
||||
Live sniff and sim stream trace frames from a background thread. Writing those
|
||||
straight to ``sys.stdout`` is fine in a plain REPL, but bpython replaces stdout
|
||||
with a capture object whose ``write`` hands control back to the main REPL thread
|
||||
via a greenlet switch — which raises from any other thread. The text is buffered
|
||||
first, so it survives, but the display only repaints on the next keypress, so
|
||||
streamed frames appear to "pile up until you touch a key".
|
||||
|
||||
bpython exposes exactly the escape hatch we need: ``_interrupting_refresh_callback``
|
||||
is a curtsies ``threadsafe_event_trigger`` built to wake the input loop from
|
||||
another thread. So under bpython we buffer the text directly (via the repl's
|
||||
``on_write``, skipping the greenlet-switching ``write``) and then fire that
|
||||
trigger to force an immediate repaint. Everywhere else — plain python, IPython —
|
||||
it is just a direct write. All of the bpython plumbing is internal, so any
|
||||
mismatch falls back to a plain write and the sniff keeps working (just
|
||||
keypress-flushed) rather than breaking.
|
||||
|
||||
The detection is intentionally bpython-only: the "wake the render loop" step is
|
||||
per-REPL and there is no universal hook, so we special-case the one REPL we use
|
||||
and let every other environment take the direct-write default.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def _bpython_sink():
|
||||
"""Return ``(on_write, wake)`` for the running bpython repl, else ``None``.
|
||||
|
||||
``sys.stdout`` under bpython is a ``FakeOutput`` whose ``on_write`` is a bound
|
||||
method of the running repl; that repl carries the thread-safe repaint trigger.
|
||||
Every access here is bpython-internal, so a miss just yields ``None``.
|
||||
"""
|
||||
out = sys.stdout
|
||||
if type(out).__module__.split(".", 1)[0] != "bpython":
|
||||
return None
|
||||
on_write = getattr(out, "on_write", None)
|
||||
repl = getattr(on_write, "__self__", None)
|
||||
wake = getattr(repl, "_interrupting_refresh_callback", None)
|
||||
if callable(on_write) and callable(wake):
|
||||
return on_write, wake
|
||||
return None
|
||||
|
||||
|
||||
def emit(text: str) -> None:
|
||||
"""Write trace ``text`` to the terminal, live even under bpython.
|
||||
|
||||
Safe to call from a background thread. Under bpython the text is buffered and
|
||||
an immediate repaint is triggered; otherwise it is a plain stdout write.
|
||||
"""
|
||||
sink = None
|
||||
try:
|
||||
sink = _bpython_sink()
|
||||
except Exception:
|
||||
sink = None
|
||||
|
||||
if sink is not None:
|
||||
on_write, wake = sink
|
||||
try:
|
||||
on_write(text)
|
||||
except Exception:
|
||||
pass # fall through to a plain write below
|
||||
else:
|
||||
try:
|
||||
wake()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
pass
|
||||
166
pm3py/transponders/bitfield.py
Normal file
166
pm3py/transponders/bitfield.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Self-describing bit-field registers.
|
||||
|
||||
A ``Register`` is a fixed-width integer — a config word, a status byte — whose bits carry
|
||||
named, documented meaning. Declare the layout once with ``BitField`` descriptors and you get,
|
||||
for free:
|
||||
|
||||
* read / edit by field name ``cfg.modulation``, ``cfg.max_block = 3``
|
||||
* ``<tab>`` completion of the field names in the shell, each field's one-line ``doc`` as the
|
||||
autocomplete tooltip (bpython/ipython read the descriptor docstring)
|
||||
* a decoded table when you print it — the "always-on hover"
|
||||
|
||||
The point is that a bare ``0x00148040`` tells you nothing, while the register spells it out.
|
||||
Nobody hand-decodes these words from a datasheet if the object does it for them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
# House-style ANSI (mirrors pm3py/trace/format.py); TTY-gated so tests/pipes stay plain text.
|
||||
_DIM = "\033[2m"
|
||||
_CYAN = "\033[36m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
|
||||
def _color(code: str, text: str) -> str:
|
||||
if not code or not sys.stdout.isatty():
|
||||
return text
|
||||
return f"{code}{text}{_RESET}"
|
||||
|
||||
|
||||
class BitField:
|
||||
"""A named slice of a register's bits.
|
||||
|
||||
``BitField(hi, lo)`` covers bits ``[lo, hi]`` inclusive (``lo`` defaults to ``hi`` for a
|
||||
single bit). ``doc`` is the one-line meaning shown in completion tooltips and the decode
|
||||
table. ``values`` is an optional ``{raw_code: label}`` map — when given, reads return the
|
||||
label and writes accept either the label or the raw code (single-bit fields with no map
|
||||
read/write as ``bool``).
|
||||
"""
|
||||
|
||||
def __init__(self, hi: int, lo: int | None = None, *, doc: str = "",
|
||||
values: dict[int, Any] | None = None):
|
||||
if lo is None:
|
||||
lo = hi
|
||||
self.hi, self.lo = max(hi, lo), min(hi, lo)
|
||||
self.width = self.hi - self.lo + 1
|
||||
self.doc = doc
|
||||
self.values = values
|
||||
self._reverse = {v: k for k, v in values.items()} if values else {}
|
||||
self.is_bool = self.width == 1 and values is None
|
||||
self.name = "?"
|
||||
self.__doc__ = doc # so `cfg.field?` / bpython introspection shows the meaning
|
||||
|
||||
def __set_name__(self, owner: type, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
@property
|
||||
def _mask(self) -> int:
|
||||
return ((1 << self.width) - 1) << self.lo
|
||||
|
||||
def raw(self, reg: "Register") -> int:
|
||||
"""The raw code stored in these bits, before any ``values`` mapping."""
|
||||
return (reg.value >> self.lo) & ((1 << self.width) - 1)
|
||||
|
||||
def __get__(self, reg: "Register | None", owner: type | None = None):
|
||||
if reg is None:
|
||||
return self
|
||||
code = self.raw(reg)
|
||||
if self.values is not None:
|
||||
return self.values.get(code, code)
|
||||
return bool(code) if self.is_bool else code
|
||||
|
||||
def __set__(self, reg: "Register", value: Any) -> None:
|
||||
code = self._encode(value)
|
||||
if code < 0 or code >> self.width:
|
||||
raise ValueError(f"{self.name}: {value!r} does not fit in {self.width} bit(s)")
|
||||
reg.value = (reg.value & ~self._mask) | (code << self.lo)
|
||||
|
||||
def _encode(self, value: Any) -> int:
|
||||
if self._reverse and value in self._reverse:
|
||||
return self._reverse[value]
|
||||
if isinstance(value, bool):
|
||||
return int(value)
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
raise ValueError(f"{self.name}: cannot set to {value!r}")
|
||||
|
||||
def display(self, reg: "Register") -> str:
|
||||
if self.is_bool:
|
||||
return "on" if self.raw(reg) else "off"
|
||||
return str(self.__get__(reg))
|
||||
|
||||
@property
|
||||
def bits(self) -> str:
|
||||
return f"[{self.hi}]" if self.width == 1 else f"[{self.hi}:{self.lo}]"
|
||||
|
||||
|
||||
class Register:
|
||||
"""Base for a fixed-width register. Subclass, set ``_width_bits`` / ``_label``, and declare
|
||||
``BitField`` class attributes. Construct from a raw value (``Reg(0x00148040)``) or empty."""
|
||||
|
||||
_width_bits = 32
|
||||
_label = "register"
|
||||
|
||||
def __init__(self, value: "int | Register" = 0):
|
||||
self.value = int(value) & ((1 << self._width_bits) - 1)
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.value
|
||||
|
||||
def __index__(self) -> int:
|
||||
return self.value
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return self.value.to_bytes(self._width_bits // 8, "big")
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if isinstance(other, Register):
|
||||
return type(self) is type(other) and self.value == other.value
|
||||
if isinstance(other, int):
|
||||
return self.value == other
|
||||
return NotImplemented
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((type(self), self.value))
|
||||
|
||||
@classmethod
|
||||
def fields(cls) -> list[tuple[str, BitField]]:
|
||||
"""All declared ``BitField``s, highest bit first (base classes included)."""
|
||||
seen: dict[str, BitField] = {}
|
||||
for klass in cls.__mro__:
|
||||
for name, obj in vars(klass).items():
|
||||
if isinstance(obj, BitField) and name not in seen:
|
||||
seen[name] = obj
|
||||
return sorted(seen.items(), key=lambda kv: kv[1].lo, reverse=True)
|
||||
|
||||
def _rows(self) -> list[dict]:
|
||||
"""Decoded rows (dicts: name/value/bits/doc/lo), high bit first. Subclasses may add
|
||||
computed rows via ``_extra_rows`` (e.g. a mode-dependent field)."""
|
||||
rows = [
|
||||
{"name": name, "value": bf.display(self), "bits": bf.bits, "doc": bf.doc, "lo": bf.lo}
|
||||
for name, bf in self.fields()
|
||||
]
|
||||
rows.extend(self._extra_rows())
|
||||
rows.sort(key=lambda r: r["lo"], reverse=True)
|
||||
return rows
|
||||
|
||||
def _extra_rows(self) -> list[dict]:
|
||||
return []
|
||||
|
||||
def decode(self) -> dict[str, Any]:
|
||||
"""Plain ``{field: value}`` mapping — no ANSI, handy for tests and programmatic use."""
|
||||
return {r["name"]: r["value"] for r in self._rows()}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
rows = self._rows()
|
||||
hexw = self._width_bits // 4
|
||||
head = f"{self._label} = 0x{self.value:0{hexw}X}"
|
||||
name_w = max((len(r["name"]) for r in rows), default=0)
|
||||
val_w = max((len(r["value"]) for r in rows), default=0)
|
||||
lines = [head]
|
||||
for r in rows:
|
||||
line = f" {r['name']:<{name_w}} {r['value']:<{val_w}} {_color(_DIM, r['bits'])}"
|
||||
lines.append(line.rstrip())
|
||||
return _color(_CYAN, head) + "\n" + "\n".join(lines[1:])
|
||||
1
pm3py/transponders/hf/iso14443a/infineon/__init__.py
Normal file
1
pm3py/transponders/hf/iso14443a/infineon/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Infineon ISO 14443-A transponders (OPTIGA Authenticate NBT)."""
|
||||
475
pm3py/transponders/hf/iso14443a/infineon/optiga_nbt.py
Normal file
475
pm3py/transponders/hf/iso14443a/infineon/optiga_nbt.py
Normal file
@@ -0,0 +1,475 @@
|
||||
"""OPTIGA(TM) Authenticate NBT (NBT2000A8K0T4) — Infineon NFC bridge tag.
|
||||
|
||||
Sourced from the Infineon Extended Datasheet Rev 2.1. NFC Forum Type 4 Tag over
|
||||
ISO/IEC 14443-A (7-byte UID, Infineon mfg code 0x05), with an 8 KB file system
|
||||
(4 KB NDEF + 4x1 KB proprietary files), a File Access Policy (EF.FAP) gating
|
||||
per-file read/write, 32-bit password management, and ECDSA (NIST P-256) one-way
|
||||
tag authentication.
|
||||
|
||||
Modelled here (the NFC side): activation + ISO-DEP, the Type 4 file tree
|
||||
(EF.CC / NDEF / 4 proprietary / EF.FAP), SELECT (by AID and by file id with
|
||||
inline password verification), READ BINARY / UPDATE BINARY with FAP enforcement,
|
||||
CREATE / DELETE / CHANGE / UNBLOCK PASSWORD, AUTHENTICATE TAG (ECDSA-P256),
|
||||
GET DATA (applet version) and the PERSONALIZATION -> OPERATIONAL life cycle.
|
||||
The I2C bridge / pass-through side is not simulated.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import struct
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from ..base import Tag14443A_4
|
||||
|
||||
# File IDs
|
||||
FID_CC = b"\xE1\x03"
|
||||
FID_NDEF = b"\xE1\x04"
|
||||
FID_PP = [b"\xE1\xA1", b"\xE1\xA2", b"\xE1\xA3", b"\xE1\xA4"]
|
||||
FID_FAP = b"\xE1\xAF"
|
||||
|
||||
NDEF_AID = b"\xD2\x76\x00\x00\x85\x01\x01"
|
||||
|
||||
# Command INS (CLA 0x00)
|
||||
INS_SELECT = 0xA4
|
||||
INS_PERSONALIZE = 0xE2
|
||||
INS_READ_BINARY = 0xB0
|
||||
INS_AUTH_TAG = 0x88
|
||||
INS_UPDATE_BINARY = 0xD6
|
||||
INS_CHANGE_UNBLOCK_PWD = 0x24
|
||||
INS_CREATE_PWD = 0xE1
|
||||
INS_DELETE_PWD = 0xE4
|
||||
INS_GET_DATA = 0x30
|
||||
|
||||
# FAP config byte values
|
||||
FAP_NEVER = 0x00
|
||||
FAP_ALWAYS = 0x40
|
||||
FAP_PWD = 0x80 # | password-id in low 5 bits
|
||||
|
||||
# Status words
|
||||
SW_OK = b"\x90\x00"
|
||||
SW_WRONG_LENGTH = b"\x67\x00"
|
||||
SW_SEC_NOT_SATISFIED = b"\x69\x82"
|
||||
SW_COND_NOT_SATISFIED = b"\x69\x85"
|
||||
SW_WRONG_DATA = b"\x6A\x80"
|
||||
SW_FILE_NOT_FOUND = b"\x6A\x82"
|
||||
SW_WRONG_P1P2 = b"\x6A\x86"
|
||||
SW_REF_NOT_FOUND = b"\x6A\x88"
|
||||
SW_CMD_NOT_ALLOWED = b"\x69\x86"
|
||||
|
||||
# Default ATS: length 0C, T0=78, TA1=77, TB1=70, TC1=02, hist "NBT2000"
|
||||
DEFAULT_ATS = b"\x0C\x78\x77\x70\x02" + b"NBT2000"
|
||||
|
||||
|
||||
class _Password:
|
||||
def __init__(self, value: bytes, response: bytes = b"\x00\x00",
|
||||
limit: int = 0x7F):
|
||||
self.value = value
|
||||
self.response = response
|
||||
self.limit = limit
|
||||
self.tries = limit
|
||||
self.blocked = False
|
||||
|
||||
|
||||
class OptigaAuthenticateNBT(Tag14443A_4):
|
||||
"""Infineon OPTIGA Authenticate NBT NFC Type 4 tag."""
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
ecc_key=None, atqa: bytes = b"\x44\x00", sak: int = 0x20):
|
||||
if uid is None:
|
||||
uid = b"\x05" + os.urandom(6) # Infineon mfg code 0x05
|
||||
# ATQA/SAK are NOT published in the NBT2000 extended datasheet or the
|
||||
# hardware integration guide (verified 2026-07: both omit them). They are
|
||||
# fixed by the tag being a 7-byte-UID ISO 14443-4 (Type 4A) device:
|
||||
# ATQA 0x0044 (wire 44 00) = bit-frame anticollision + double-size UID,
|
||||
# SAK 0x20 = ISO-DEP compliant, UID complete.
|
||||
# These match every 7-byte-UID Type-4A tag (NTAG21x, DESFire, ST25TA).
|
||||
super().__init__(uid=uid, atqa=atqa, sak=sak, ats=DEFAULT_ATS)
|
||||
self._lifecycle = "PERSONALIZATION"
|
||||
self._ecc_key = ecc_key # ECC P-256 private key (lazy-generated)
|
||||
self._passwords: dict[int, _Password] = {}
|
||||
self._files: dict[bytes, bytearray] = {
|
||||
FID_CC: bytearray(64),
|
||||
FID_NDEF: bytearray(4096),
|
||||
FID_PP[0]: bytearray(1024),
|
||||
FID_PP[1]: bytearray(1024),
|
||||
FID_PP[2]: bytearray(1024),
|
||||
FID_PP[3]: bytearray(1024),
|
||||
FID_FAP: bytearray(42),
|
||||
}
|
||||
# File Access Policy: fileid -> [i2c_r, i2c_w, nfc_r, nfc_w]
|
||||
self._fap: dict[bytes, list[int]] = {
|
||||
FID_CC: [FAP_ALWAYS, FAP_NEVER, FAP_ALWAYS, FAP_NEVER],
|
||||
FID_NDEF: [FAP_ALWAYS, FAP_ALWAYS, FAP_ALWAYS, FAP_ALWAYS],
|
||||
FID_PP[0]: [FAP_ALWAYS] * 4,
|
||||
FID_PP[1]: [FAP_ALWAYS] * 4,
|
||||
FID_PP[2]: [FAP_ALWAYS] * 4,
|
||||
FID_PP[3]: [FAP_ALWAYS] * 4,
|
||||
FID_FAP: [FAP_ALWAYS] * 4,
|
||||
}
|
||||
self._app_selected = False
|
||||
self._selected_file: bytes | None = None
|
||||
self._read_ok: set[bytes] = set()
|
||||
self._write_ok: set[bytes] = set()
|
||||
self._build_cc(ndef_message)
|
||||
|
||||
def _build_cc(self, ndef_message: bytes) -> None:
|
||||
cc = bytearray(64)
|
||||
struct.pack_into(">H", cc, 0, 0x002F) # CCLEN
|
||||
cc[2] = 0x20 # mapping version 2.0
|
||||
struct.pack_into(">H", cc, 3, 0x0100) # MLe
|
||||
struct.pack_into(">H", cc, 5, 0x00FF) # MLc
|
||||
cc[7:15] = b"\x04\x06" + FID_NDEF + struct.pack(">H", 0x1000) + b"\x00\x00"
|
||||
off = 15
|
||||
for fid in FID_PP:
|
||||
cc[off:off + 8] = b"\x05\x06" + fid + struct.pack(">H", 0x0400) + b"\x00\x00"
|
||||
off += 8
|
||||
self._files[FID_CC] = cc
|
||||
self._write_fap_file()
|
||||
if ndef_message:
|
||||
self.set_ndef(ndef_message)
|
||||
|
||||
def _write_fap_file(self) -> None:
|
||||
fap = bytearray()
|
||||
for fid in (FID_CC, FID_NDEF, *FID_PP, FID_FAP):
|
||||
fap += fid + bytes(self._fap[fid])
|
||||
self._files[FID_FAP] = fap
|
||||
|
||||
# ----- NDEF convenience -----
|
||||
|
||||
def set_ndef(self, message: bytes) -> None:
|
||||
if len(message) > 4096 - 2:
|
||||
raise ValueError("NDEF message too large (max 4094 bytes)")
|
||||
f = self._files[FID_NDEF]
|
||||
struct.pack_into(">H", f, 0, len(message))
|
||||
f[2:2 + len(message)] = message
|
||||
for i in range(2 + len(message), len(f)):
|
||||
f[i] = 0
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""Move to OPERATIONAL life cycle (config locked)."""
|
||||
self._lifecycle = "OPERATIONAL"
|
||||
|
||||
def add_password(self, pwid: int, value: bytes, response: bytes = b"\x00\x00",
|
||||
limit: int = 0x7F) -> None:
|
||||
self._passwords[pwid] = _Password(value, response, limit)
|
||||
|
||||
def set_fap(self, fileid: bytes, nfc_read: int, nfc_write: int) -> None:
|
||||
self._fap[fileid][2] = nfc_read
|
||||
self._fap[fileid][3] = nfc_write
|
||||
self._write_fap_file()
|
||||
|
||||
@property
|
||||
def lifecycle(self) -> str:
|
||||
return self._lifecycle
|
||||
|
||||
# ----- ISO-DEP reset -----
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._app_selected = False
|
||||
self._selected_file = None
|
||||
self._read_ok.clear()
|
||||
self._write_ok.clear()
|
||||
|
||||
def _handle_rats(self, frame: RFFrame) -> RFFrame | None:
|
||||
self._app_selected = False
|
||||
self._selected_file = None
|
||||
self._read_ok.clear()
|
||||
self._write_ok.clear()
|
||||
return super()._handle_rats(frame)
|
||||
|
||||
# ----- APDU dispatch -----
|
||||
|
||||
def _handle_apdu(self, apdu: bytes) -> bytes:
|
||||
if len(apdu) < 4: # need at least CLA INS P1 P2
|
||||
return SW_WRONG_LENGTH
|
||||
cla, ins = apdu[0], apdu[1]
|
||||
if cla != 0x00:
|
||||
return b"\x6E\x00" # only the NFC CLA 0x00 set is modelled
|
||||
if ins == INS_SELECT:
|
||||
return self._handle_select(apdu)
|
||||
if ins == INS_READ_BINARY:
|
||||
return self._handle_read_binary(apdu)
|
||||
if ins == INS_UPDATE_BINARY:
|
||||
return self._handle_update_binary(apdu)
|
||||
if ins == INS_AUTH_TAG:
|
||||
return self._handle_authenticate(apdu)
|
||||
if ins == INS_CREATE_PWD:
|
||||
return self._handle_create_password(apdu)
|
||||
if ins == INS_DELETE_PWD:
|
||||
return self._handle_delete_password(apdu)
|
||||
if ins == INS_CHANGE_UNBLOCK_PWD:
|
||||
return self._handle_change_unblock(apdu)
|
||||
if ins == INS_GET_DATA:
|
||||
return self._handle_get_data(apdu)
|
||||
if ins == INS_PERSONALIZE:
|
||||
return self._handle_personalize(apdu)
|
||||
return b"\x6D\x00"
|
||||
|
||||
# ----- SELECT -----
|
||||
|
||||
def _handle_select(self, apdu: bytes) -> bytes:
|
||||
p1, p2 = apdu[2], apdu[3]
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
if p1 == 0x04: # select by AID
|
||||
if data == NDEF_AID:
|
||||
self._app_selected = True
|
||||
self._selected_file = None
|
||||
return SW_OK
|
||||
return SW_FILE_NOT_FOUND
|
||||
if p1 == 0x00 and p2 == 0x0C: # select by file id
|
||||
if not self._app_selected or len(data) < 2:
|
||||
return SW_FILE_NOT_FOUND
|
||||
fid = bytes(data[0:2])
|
||||
if fid not in self._files:
|
||||
return SW_FILE_NOT_FOUND
|
||||
self._selected_file = fid
|
||||
fci = self._verify_select_passwords(fid, data[2:])
|
||||
return fci + SW_OK
|
||||
return SW_WRONG_P1P2
|
||||
|
||||
def _verify_select_passwords(self, fid: bytes, tlvs: bytes) -> bytes:
|
||||
"""Parse 52h (read) / 54h (write) password TLVs, mark verified files."""
|
||||
fci = b""
|
||||
i = 0
|
||||
while i + 6 <= len(tlvs):
|
||||
tag, ln = tlvs[i], tlvs[i + 1]
|
||||
if ln != 0x04:
|
||||
break
|
||||
pwd = bytes(tlvs[i + 2:i + 6])
|
||||
if tag == 0x52:
|
||||
if self._check_password(self._fap[fid][2], pwd):
|
||||
self._read_ok.add(fid)
|
||||
fci += b"\x02\x52"
|
||||
elif tag == 0x54:
|
||||
if self._check_password(self._fap[fid][3], pwd):
|
||||
self._write_ok.add(fid)
|
||||
fci += b"\x02\x54"
|
||||
i += 6
|
||||
if fci:
|
||||
return b"\x6F" + bytes([len(fci)]) + fci
|
||||
return b""
|
||||
|
||||
def _check_password(self, fap_byte: int, supplied: bytes) -> bool:
|
||||
if not (fap_byte & FAP_PWD):
|
||||
return False
|
||||
pwid = fap_byte & 0x1F
|
||||
pw = self._passwords.get(pwid)
|
||||
if pw is None or pw.blocked:
|
||||
return False
|
||||
if supplied == pw.value:
|
||||
pw.tries = pw.limit
|
||||
return True
|
||||
if pw.limit != 0xFFFF:
|
||||
pw.tries -= 1
|
||||
if pw.tries <= 0:
|
||||
pw.blocked = True
|
||||
return False
|
||||
|
||||
# ----- access-control helpers -----
|
||||
|
||||
def _read_allowed(self, fid: bytes) -> bool:
|
||||
cfg = self._fap[fid][2]
|
||||
if cfg == FAP_ALWAYS:
|
||||
return True
|
||||
if cfg == FAP_NEVER:
|
||||
return False
|
||||
return fid in self._read_ok
|
||||
|
||||
def _write_allowed(self, fid: bytes) -> bool:
|
||||
cfg = self._fap[fid][3]
|
||||
if cfg == FAP_ALWAYS:
|
||||
return True
|
||||
if cfg == FAP_NEVER:
|
||||
return False
|
||||
return fid in self._write_ok
|
||||
|
||||
# ----- READ / UPDATE BINARY -----
|
||||
|
||||
def _handle_read_binary(self, apdu: bytes) -> bytes:
|
||||
if self._selected_file is None:
|
||||
return SW_CMD_NOT_ALLOWED
|
||||
fid = self._selected_file
|
||||
content = self._files[fid]
|
||||
offset = (apdu[2] << 8) | apdu[3]
|
||||
if len(apdu) < 5:
|
||||
return SW_WRONG_LENGTH
|
||||
le = apdu[4]
|
||||
if offset > len(content):
|
||||
return SW_WRONG_LENGTH
|
||||
if not self._read_allowed(fid):
|
||||
return SW_SEC_NOT_SATISFIED
|
||||
if offset + le > len(content):
|
||||
avail = len(content) - offset
|
||||
return bytes([0x6C, avail & 0xFF])
|
||||
return bytes(content[offset:offset + le]) + SW_OK
|
||||
|
||||
def _handle_update_binary(self, apdu: bytes) -> bytes:
|
||||
if self._selected_file is None:
|
||||
return SW_CMD_NOT_ALLOWED
|
||||
fid = self._selected_file
|
||||
offset = (apdu[2] << 8) | apdu[3]
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
if fid == FID_CC and 0x02 <= offset <= 0x0E:
|
||||
return SW_COND_NOT_SATISFIED # CC 02..0E never updatable
|
||||
if not self._write_allowed(fid):
|
||||
return SW_SEC_NOT_SATISFIED
|
||||
if fid == FID_FAP:
|
||||
return self._update_fap(data)
|
||||
content = self._files[fid]
|
||||
if offset + len(data) > len(content):
|
||||
return SW_WRONG_P1P2
|
||||
content[offset:offset + len(data)] = data
|
||||
return SW_OK
|
||||
|
||||
def _update_fap(self, data: bytes) -> bytes:
|
||||
if len(data) != 6:
|
||||
return SW_WRONG_LENGTH
|
||||
fid = bytes(data[0:2])
|
||||
if fid not in self._fap:
|
||||
return SW_WRONG_DATA
|
||||
self._fap[fid] = list(data[2:6])
|
||||
self._write_fap_file()
|
||||
return SW_OK
|
||||
|
||||
# ----- AUTHENTICATE TAG (ECDSA P-256) -----
|
||||
|
||||
def _handle_authenticate(self, apdu: bytes) -> bytes:
|
||||
if apdu[2] != 0x00 or apdu[3] != 0x00:
|
||||
return SW_WRONG_P1P2
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
challenge = apdu[5:5 + lc]
|
||||
if lc == 0:
|
||||
return SW_WRONG_LENGTH
|
||||
try:
|
||||
from Crypto.PublicKey import ECC
|
||||
from Crypto.Signature import DSS
|
||||
from Crypto.Hash import SHA256
|
||||
except ImportError:
|
||||
return SW_COND_NOT_SATISFIED
|
||||
if self._ecc_key is None:
|
||||
self._ecc_key = ECC.generate(curve="P-256")
|
||||
h = SHA256.new(bytes(challenge))
|
||||
sig = DSS.new(self._ecc_key, "fips-186-3", encoding="der").sign(h)
|
||||
return sig + SW_OK
|
||||
|
||||
# ----- password management -----
|
||||
|
||||
def _handle_create_password(self, apdu: bytes) -> bytes:
|
||||
if apdu[2] != 0x00 or apdu[3] != 0x00:
|
||||
return SW_WRONG_P1P2
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
if lc == 0x09:
|
||||
body = data
|
||||
elif lc == 0x0D:
|
||||
body = data[4:] # skip master password (verify skipped)
|
||||
else:
|
||||
return SW_WRONG_LENGTH
|
||||
if len(body) != 9:
|
||||
return SW_WRONG_LENGTH
|
||||
pwid = body[0]
|
||||
value = bytes(body[1:5])
|
||||
response = bytes(body[5:7])
|
||||
limit = (body[7] << 8) | body[8]
|
||||
if pwid == 0x00 or pwid > 0x1F:
|
||||
return SW_WRONG_DATA
|
||||
if response in (b"\x00\x00", b"\xFF\xFF"):
|
||||
return SW_WRONG_DATA
|
||||
if limit == 0x0000 or (0x0080 <= limit <= 0xFFFE):
|
||||
return SW_WRONG_DATA
|
||||
if pwid in self._passwords:
|
||||
return SW_COND_NOT_SATISFIED
|
||||
if len(self._passwords) >= 0x1C:
|
||||
return SW_COND_NOT_SATISFIED
|
||||
self._passwords[pwid] = _Password(value, response, limit)
|
||||
return SW_OK
|
||||
|
||||
def _handle_delete_password(self, apdu: bytes) -> bytes:
|
||||
if apdu[2] != 0x00:
|
||||
return SW_WRONG_P1P2
|
||||
pwid = apdu[3] & 0x1F
|
||||
if pwid not in self._passwords:
|
||||
return SW_REF_NOT_FOUND
|
||||
del self._passwords[pwid]
|
||||
return SW_OK
|
||||
|
||||
def _handle_change_unblock(self, apdu: bytes) -> bytes:
|
||||
if apdu[2] != 0x00:
|
||||
return SW_WRONG_P1P2
|
||||
p2 = apdu[3]
|
||||
op = (p2 >> 6) & 0x03 # b8,b7: 00=UNBLOCK, 01=CHANGE
|
||||
pwid = p2 & 0x1F
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
pw = self._passwords.get(pwid)
|
||||
if pw is None:
|
||||
return SW_REF_NOT_FOUND
|
||||
if op == 0x00: # UNBLOCK
|
||||
if lc not in (0x00, 0x04):
|
||||
return SW_WRONG_LENGTH
|
||||
pw.blocked = False
|
||||
pw.tries = pw.limit
|
||||
return SW_OK
|
||||
if op == 0x01: # CHANGE
|
||||
if lc not in (0x04, 0x08):
|
||||
return SW_WRONG_LENGTH
|
||||
if pw.blocked:
|
||||
return SW_COND_NOT_SATISFIED
|
||||
new_pwd = data[-4:]
|
||||
pw.value = bytes(new_pwd)
|
||||
return SW_OK
|
||||
return SW_WRONG_P1P2
|
||||
|
||||
# ----- GET DATA (applet version) -----
|
||||
|
||||
def _handle_get_data(self, apdu: bytes) -> bytes:
|
||||
if apdu[2] == 0xDF and apdu[3] == 0x3A:
|
||||
return b"\x6F\x05\x04\x03\x01\x01\x02" + SW_OK # version 1.1.2
|
||||
return SW_WRONG_P1P2
|
||||
|
||||
# ----- PERSONALIZE DATA (PERSONALIZATION only) -----
|
||||
|
||||
def _handle_personalize(self, apdu: bytes) -> bytes:
|
||||
if self._lifecycle != "PERSONALIZATION":
|
||||
return SW_COND_NOT_SATISFIED
|
||||
if apdu[2] != 0x00 or apdu[3] != 0x00:
|
||||
return SW_WRONG_P1P2
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
if len(data) < 3:
|
||||
return SW_WRONG_LENGTH
|
||||
dgi = bytes(data[0:2])
|
||||
dlen = data[2]
|
||||
body = data[3:3 + dlen]
|
||||
if dgi == b"\xBF\x63": # FINALIZE PERSONALIZATION
|
||||
self._lifecycle = "OPERATIONAL"
|
||||
return SW_OK
|
||||
if dgi == b"\xA0\x03": # password data set
|
||||
if len(body) != 9:
|
||||
return SW_WRONG_DATA
|
||||
self._passwords[body[0]] = _Password(
|
||||
bytes(body[1:5]), bytes(body[5:7]), (body[7] << 8) | body[8])
|
||||
return SW_OK
|
||||
if dgi == b"\xE1\xAF": # FAP config
|
||||
if len(body) != 42:
|
||||
return SW_WRONG_DATA
|
||||
for i in range(0, 42, 6):
|
||||
fid = bytes(body[i:i + 2])
|
||||
if fid in self._fap:
|
||||
self._fap[fid] = list(body[i + 2:i + 6])
|
||||
self._write_fap_file()
|
||||
return SW_OK
|
||||
if dgi in self._files: # file content: <offset(2)><content>
|
||||
if len(body) < 2:
|
||||
return SW_WRONG_DATA
|
||||
off = (body[0] << 8) | body[1]
|
||||
content = self._files[dgi]
|
||||
payload = body[2:]
|
||||
if off + len(payload) > len(content):
|
||||
return SW_WRONG_DATA
|
||||
content[off:off + len(payload)] = payload
|
||||
return SW_OK
|
||||
return b"\x6A\x88" # unsupported data group
|
||||
@@ -13,6 +13,12 @@ NDEF_TLV_TYPE = 0x03
|
||||
TERMINATOR_TLV = 0xFE
|
||||
NDEF_MAGIC = 0xE1
|
||||
|
||||
# Firmware emulator-memory layout (must match firmware include/mifare.h).
|
||||
# The MFU/NTAG sim (tagtype 2/7) reads pages/version/signature from a 56-byte
|
||||
# mfu_dump_t prefix and serves page reads from the data area that follows it.
|
||||
MFU_DUMP_PREFIX_LENGTH = 56
|
||||
CMD_HF_MIFARE_EML_MEMSET = 0x0602 # pm3_cmd.h; {u16 blockno,u8 cnt,u8 width,data}
|
||||
|
||||
|
||||
class NfcType2Tag(Tag14443A_3):
|
||||
"""NFC Forum Type 2 Tag (NTAG-like).
|
||||
@@ -67,9 +73,10 @@ class NfcType2Tag(Tag14443A_3):
|
||||
f"NDEF message too large: {len(message)} bytes, "
|
||||
f"capacity is {self._ndef_capacity} bytes")
|
||||
|
||||
# Clear data area
|
||||
self._memory[self._ndef_data_offset:] = bytes(
|
||||
len(self._memory) - self._ndef_data_offset)
|
||||
# Clear only the NDEF data area — never the lock / config / PWD / PACK
|
||||
# pages that follow it on NTAG-class parts (clearing to end wipes them).
|
||||
end = self._ndef_area_end()
|
||||
self._memory[self._ndef_data_offset:end] = bytes(end - self._ndef_data_offset)
|
||||
|
||||
offset = self._ndef_data_offset
|
||||
if len(message) < 0xFF:
|
||||
@@ -82,9 +89,72 @@ class NfcType2Tag(Tag14443A_3):
|
||||
self._memory[offset:offset + len(tlv)] = tlv
|
||||
|
||||
def clear_ndef(self) -> None:
|
||||
"""Remove NDEF message."""
|
||||
self._memory[self._ndef_data_offset:] = bytes(
|
||||
len(self._memory) - self._ndef_data_offset)
|
||||
"""Remove NDEF message (leaves lock/config/PWD/PACK pages intact)."""
|
||||
end = self._ndef_area_end()
|
||||
self._memory[self._ndef_data_offset:end] = bytes(end - self._ndef_data_offset)
|
||||
|
||||
def _ndef_area_end(self) -> int:
|
||||
"""Byte offset just past the NDEF data area.
|
||||
|
||||
Bounded by the CC-declared size (page 3 byte 2 × 8) so the NDEF area never
|
||||
overlaps the lock/config/PWD/PACK pages at the top of an NTAG's memory.
|
||||
Falls back to end-of-memory when no CC size is present.
|
||||
"""
|
||||
cc_size = self._memory[3 * PAGE_SIZE + 2] * 8
|
||||
if cc_size:
|
||||
return min(self._ndef_data_offset + cc_size, len(self._memory))
|
||||
return len(self._memory)
|
||||
|
||||
# ----- emulator-memory sync (14a sim) -----
|
||||
|
||||
def _build_mfu_dump(self) -> bytes:
|
||||
"""Assemble the firmware ``mfu_dump_t`` image: 56-byte prefix + page data.
|
||||
|
||||
The 14a MFU/NTAG sim (tagtype 2/7) is data-driven — firmware reads
|
||||
``pages``, ``version`` and ``signature`` from this prefix and serves page
|
||||
reads from the data area at offset 56. Loaded into emulator RAM so the
|
||||
reader sees the model's real memory (UID pages, CC, NDEF, PWD, PACK).
|
||||
"""
|
||||
prefix = bytearray(MFU_DUMP_PREFIX_LENGTH)
|
||||
version = getattr(self, "_version_bytes", None) or b"\x00" * 8
|
||||
prefix[0:8] = bytes(version)[:8].ljust(8, b"\x00") # version[8]
|
||||
# tbo[2] @8, tbo1 @10 stay 0
|
||||
prefix[11] = (self._total_pages - 1) & 0xFF # pages = last page idx
|
||||
signature = getattr(self, "_signature", None) or b"\x00" * 32
|
||||
prefix[12:44] = bytes(signature)[:32].ljust(32, b"\x00") # signature[32]
|
||||
# counter_tearing[3][4] @44: NTAG21x NFC counter lives at index 2 (24-bit LE)
|
||||
counter = int(getattr(self, "_nfc_counter", 0) or 0) & 0xFFFFFF
|
||||
prefix[52:55] = counter.to_bytes(3, "little")
|
||||
return bytes(prefix) + bytes(self._memory)
|
||||
|
||||
def sync(self) -> None:
|
||||
"""Push the full tag memory into the running sim's emulator RAM.
|
||||
|
||||
Call after mutating any memory — pages 0/1, CC, NDEF, PWD, PACK — so the
|
||||
firmware serves the updated bytes. READ/FAST_READ and PWD_AUTH read the
|
||||
emulator buffer live, so changes take effect on the next reader command.
|
||||
Requires binding to a live sim (``SimSession.start_14a`` sets ``_serial``);
|
||||
for a change to land while a sim is *already running*, the firmware must
|
||||
accept EML updates mid-sim (the live-sync firmware build).
|
||||
"""
|
||||
if self._serial is None:
|
||||
raise RuntimeError(
|
||||
"Not bound to a live session — call SimSession.start_14a(tag) first")
|
||||
import time
|
||||
from pm3py.core.transport import encode_ng_frame
|
||||
|
||||
image = self._build_mfu_dump()
|
||||
if len(image) % PAGE_SIZE:
|
||||
image += b"\x00" * (PAGE_SIZE - len(image) % PAGE_SIZE)
|
||||
total_blocks = len(image) // PAGE_SIZE
|
||||
MAX_BLOCKS = 120 # 120*4 = 480B payload (+4B header) < 512B NG cap
|
||||
for start in range(0, total_blocks, MAX_BLOCKS):
|
||||
n = min(MAX_BLOCKS, total_blocks - start)
|
||||
chunk = image[start * PAGE_SIZE:(start + n) * PAGE_SIZE]
|
||||
# EML_MEMSET: {u16 blockno, u8 blockcnt, u8 blockwidth, data[]}
|
||||
payload = struct.pack("<HBB", start, n, PAGE_SIZE) + chunk
|
||||
self._serial.write(encode_ng_frame(CMD_HF_MIFARE_EML_MEMSET, payload))
|
||||
time.sleep(0.02)
|
||||
|
||||
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||
if not frame.data:
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
"""NXP ISO 14443-A transponders (MIFARE Classic, DESFire)."""
|
||||
"""NXP ISO 14443-A transponders.
|
||||
|
||||
- mifare_classic, desfire — MIFARE Classic / DESFire.
|
||||
- type2 — NxpType2Tag: shared Ultralight/NTAG command core.
|
||||
- ntag21x — NTAG210/212/213/215/216.
|
||||
- ultralight — MIFARE Ultralight, Ultralight C (3DES), Ultralight EV1
|
||||
(MF0UL11/MF0UL21).
|
||||
- ntag_i2c — NTAG I2C plus (NT3H2111 1k / NT3H2211 2k).
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,7 @@ import struct
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.medium import Medium
|
||||
from pm3py.transponders.bitfield import BitField, Register
|
||||
from ..base import Tag14443A_3, Reader14443A, _compute_bcc
|
||||
from .crypto1 import Crypto1
|
||||
|
||||
@@ -18,6 +19,118 @@ NACK = 0x00
|
||||
|
||||
BLOCK_SIZE = 16
|
||||
|
||||
# Per-block access-condition (C1 C2 C3) meanings. Data-block table matches Proxmark3
|
||||
# mifare4.c; the trailer table is written to the NXP MF1S50 datasheet (r=read, w=write,
|
||||
# A/B = which key, AB = either key, - = never).
|
||||
_MFAC_DATA = {
|
||||
0: "r=AB w=AB incr=AB dec/tr/rst=AB (transport)",
|
||||
1: "r=AB dec/tr/rst=AB (value block)",
|
||||
2: "r=AB (read-only)",
|
||||
3: "r=B w=B",
|
||||
4: "r=AB w=B",
|
||||
5: "r=B (read-only, key B)",
|
||||
6: "r=AB w=B incr=B dec/tr/rst=AB (value block)",
|
||||
7: "no access",
|
||||
}
|
||||
_MFAC_TRAILER = {
|
||||
0: "KeyA w=A; AC r=A; KeyB r=A w=A (KeyB is readable data)",
|
||||
1: "KeyA w=A; AC r=A w=A; KeyB r=A w=A (transport/default)",
|
||||
2: "KeyA w=-; AC r=A; KeyB r=A w=-",
|
||||
3: "KeyA w=B; AC r=AB w=B; KeyB w=B",
|
||||
4: "KeyA w=B; AC r=AB; KeyB w=B",
|
||||
5: "AC r=AB w=B (keys locked)",
|
||||
6: "KeyA w=-; AC r=AB (keys locked)",
|
||||
7: "AC r=AB (all keys locked)",
|
||||
}
|
||||
|
||||
|
||||
class MifareAccessConditions(Register):
|
||||
"""MIFARE Classic sector-trailer access bits — trailer bytes 6-9 (the three access bytes
|
||||
plus the general-purpose byte), decoded as a 32-bit word.
|
||||
|
||||
Each of the sector's four blocks (three data blocks + the trailer) has a 3-bit access
|
||||
condition ``C1 C2 C3``. The three access bytes store every condition *twice* — once true,
|
||||
once inverted — so one flipped bit permanently bricks the sector. This spells out each
|
||||
block in plain English, flags an inconsistent inverted copy (``.valid``), and, when you
|
||||
:meth:`build` one, computes the inverted copy for you so it is always self-consistent::
|
||||
|
||||
MifareAccessConditions(0xFF078069) # decode the factory-default trailer
|
||||
ac = MifareAccessConditions.build(trailer=3) # KeyB-controlled trailer
|
||||
bytes(ac) # -> the 4 trailer bytes 6..9 to write
|
||||
"""
|
||||
|
||||
_width_bits = 32
|
||||
_label = "MIFARE access bits (trailer 6-9)"
|
||||
|
||||
gpb = BitField(7, 0, doc="general-purpose byte — free user data (e.g. the MAD GPB)")
|
||||
|
||||
_BLOCKS = ("block0", "block1", "block2", "trailer")
|
||||
|
||||
def _cond(self, blockn: int) -> int:
|
||||
"""The 3-bit access condition (C1<<2 | C2<<1 | C3) for one block of the sector."""
|
||||
v = self.value
|
||||
c1 = (v >> (20 + blockn)) & 1 # C1 = byte7 high nibble
|
||||
c2 = (v >> (8 + blockn)) & 1 # C2 = byte8 low nibble
|
||||
c3 = (v >> (12 + blockn)) & 1 # C3 = byte8 high nibble
|
||||
return (c1 << 2) | (c2 << 1) | c3
|
||||
|
||||
@property
|
||||
def block0(self) -> int:
|
||||
return self._cond(0)
|
||||
|
||||
@property
|
||||
def block1(self) -> int:
|
||||
return self._cond(1)
|
||||
|
||||
@property
|
||||
def block2(self) -> int:
|
||||
return self._cond(2)
|
||||
|
||||
@property
|
||||
def trailer(self) -> int:
|
||||
return self._cond(3)
|
||||
|
||||
def describe(self, blockn: int) -> str:
|
||||
table = _MFAC_TRAILER if blockn == 3 else _MFAC_DATA
|
||||
return table.get(self._cond(blockn), "?")
|
||||
|
||||
@property
|
||||
def valid(self) -> bool:
|
||||
"""True iff the redundant inverted copy is the nibble-complement of the true copy."""
|
||||
v = self.value
|
||||
return (((v >> 24) & 0x0F) == (((v >> 20) & 0x0F) ^ 0x0F) and # ~C1 vs C1
|
||||
((v >> 28) & 0x0F) == (((v >> 8) & 0x0F) ^ 0x0F) and # ~C2 vs C2
|
||||
((v >> 16) & 0x0F) == (((v >> 12) & 0x0F) ^ 0x0F)) # ~C3 vs C3
|
||||
|
||||
@classmethod
|
||||
def build(cls, block0: int = 0, block1: int = 0, block2: int = 0,
|
||||
trailer: int = 1, gpb: int = 0x00) -> "MifareAccessConditions":
|
||||
"""Compose access bytes from the four per-block conditions (0..7), filling in the
|
||||
inverted copy so the result always passes ``.valid``. ``trailer`` defaults to 1
|
||||
(the factory/transport trailer). ``gpb`` is the general-purpose byte."""
|
||||
c1 = c2 = c3 = 0
|
||||
for n, cond in enumerate((block0, block1, block2, trailer)):
|
||||
if not 0 <= cond <= 7:
|
||||
raise ValueError(f"block {n} access condition must be 0..7, got {cond}")
|
||||
c1 |= ((cond >> 2) & 1) << n
|
||||
c2 |= ((cond >> 1) & 1) << n
|
||||
c3 |= (cond & 1) << n
|
||||
byte6 = ((c2 ^ 0x0F) << 4) | (c1 ^ 0x0F)
|
||||
byte7 = (c1 << 4) | (c3 ^ 0x0F)
|
||||
byte8 = (c3 << 4) | c2
|
||||
return cls((byte6 << 24) | (byte7 << 16) | (byte8 << 8) | (gpb & 0xFF))
|
||||
|
||||
def _rows(self) -> list[dict]:
|
||||
rows = [
|
||||
{"name": name, "value": f"{self._cond(n)} {self.describe(n)}", "bits": "", "doc": ""}
|
||||
for n, name in enumerate(self._BLOCKS)
|
||||
]
|
||||
rows.append({"name": "gpb", "value": f"0x{self.gpb:02X}", "bits": "[7:0]", "doc": ""})
|
||||
if not self.valid:
|
||||
rows.append({"name": "(!)", "value": "inverted copy inconsistent — INVALID",
|
||||
"bits": "", "doc": ""})
|
||||
return rows
|
||||
|
||||
|
||||
class MifareClassicTag(Tag14443A_3):
|
||||
"""MIFARE Classic 1K/4K transponder with Crypto-1 authentication."""
|
||||
@@ -63,6 +176,12 @@ class MifareClassicTag(Tag14443A_3):
|
||||
else:
|
||||
return 128 + (sector - 32) * 16 + 15
|
||||
|
||||
def access(self, sector: int) -> MifareAccessConditions:
|
||||
"""Decode the sector's trailer access bits (trailer bytes 6-9) as a self-describing
|
||||
:class:`MifareAccessConditions` register."""
|
||||
trailer = self.read_block_raw(self._sector_trailer_block(sector))
|
||||
return MifareAccessConditions(int.from_bytes(trailer[6:10], "big"))
|
||||
|
||||
def read_block_raw(self, block: int) -> bytes:
|
||||
"""Direct read of block data (bypassing auth)."""
|
||||
offset = block * BLOCK_SIZE
|
||||
|
||||
188
pm3py/transponders/hf/iso14443a/nxp/ntag21x.py
Normal file
188
pm3py/transponders/hf/iso14443a/nxp/ntag21x.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""NTAG21x — NXP NFC Forum Type 2 tags (NTAG210/212/213/215/216).
|
||||
|
||||
Sourced from the NXP datasheets:
|
||||
- NTAG210/212 (Rev. 3.0, doc 265331)
|
||||
- NTAG213/215/216 (Rev. 3.2, doc 265332)
|
||||
|
||||
Feature summary:
|
||||
- GET_VERSION, FAST_READ, PWD_AUTH (+PACK), READ_SIG on all five.
|
||||
- Single 24-bit NFC one-way counter (READ_CNT 0x39, addr 0x02) on 213/215/216
|
||||
only — enabled by ACCESS.NFC_CNT_EN, optionally password protected by
|
||||
ACCESS.NFC_CNT_PWD_PROT. NTAG210/212 have no counter.
|
||||
- Password protection from AUTH0 onwards (write, or read+write if ACCESS.PROT).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.bitfield import BitField
|
||||
from .type2 import NxpType2Tag, Type2Config, READ_CNT, FAST_READ, READ_CMD, _nak, NAK_ARG
|
||||
|
||||
# ACCESS byte bits (CFG1 byte 0)
|
||||
ACCESS_PROT = 0x80
|
||||
ACCESS_CFGLCK = 0x40
|
||||
ACCESS_NFC_CNT_EN = 0x10
|
||||
ACCESS_NFC_CNT_PWD_PROT = 0x08
|
||||
|
||||
|
||||
class Ntag21xConfig(Type2Config):
|
||||
"""NTAG21x CFG0/CFG1 config word (see :class:`Type2Config`), adding the NTAG-only ASCII
|
||||
mirror and NFC-counter fields. Decode ``tag.config`` or build one and assign it back."""
|
||||
|
||||
_label = "NTAG21x config (CFG0/CFG1)"
|
||||
|
||||
mirror_conf = BitField(63, 62, doc="ASCII mirror mode",
|
||||
values={0: "off", 1: "UID", 2: "NFC counter", 3: "UID+counter"})
|
||||
mirror_byte = BitField(61, 60, doc="byte offset within mirror_page where the mirror starts")
|
||||
mirror_page = BitField(47, 40, doc="page where ASCII mirroring begins (0 = disabled)")
|
||||
nfc_cnt_en = BitField(28, doc="enable the 24-bit NFC read counter (213/215/216)")
|
||||
nfc_cnt_pwd_prot = BitField(27, doc="NFC counter readable only after PWD_AUTH")
|
||||
|
||||
|
||||
class Ntag21x(NxpType2Tag):
|
||||
"""Base for the NTAG21x family. Concrete ICs set the class attributes."""
|
||||
|
||||
_has_nfc_counter: bool = False # True for 213/215/216
|
||||
_cc_default: bytes = b"\xE1\x10\x00\x00"
|
||||
_config_cls = Ntag21xConfig
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
password: bytes = b"\xFF\xFF\xFF\xFF",
|
||||
pack: bytes = b"\x00\x00", auth0: int = 0xFF,
|
||||
prot: bool = False, signature: bytes | None = None,
|
||||
nfc_cnt_en: bool = False):
|
||||
if uid is None:
|
||||
import os
|
||||
uid = b"\x04" + os.urandom(6)
|
||||
self._nfc_counter = 0
|
||||
self._counter_triggered = False
|
||||
super().__init__(uid=uid, password=password, pack=pack, auth0=auth0,
|
||||
prot=prot, signature=signature, cc=self._cc_default,
|
||||
ndef_message=ndef_message,
|
||||
total_pages=self._total_pages_cls,
|
||||
atqa=b"\x44\x00", sak=0x00)
|
||||
if nfc_cnt_en and self._cfg1_page is not None:
|
||||
off = self._cfg1_page * 4
|
||||
self._memory[off] |= ACCESS_NFC_CNT_EN
|
||||
|
||||
# ----- config bits -----
|
||||
|
||||
@property
|
||||
def _access(self) -> int:
|
||||
if self._cfg1_page is None:
|
||||
return 0
|
||||
return self._memory[self._cfg1_page * 4]
|
||||
|
||||
@property
|
||||
def nfc_counter_enabled(self) -> bool:
|
||||
return self._has_nfc_counter and bool(self._access & ACCESS_NFC_CNT_EN)
|
||||
|
||||
@property
|
||||
def nfc_counter(self) -> int:
|
||||
return self._nfc_counter
|
||||
|
||||
# ----- power cycle resets the "first read" trigger -----
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._counter_triggered = False
|
||||
|
||||
# ----- NFC counter auto-increment on first READ/FAST_READ -----
|
||||
|
||||
def _maybe_bump_counter(self) -> None:
|
||||
if self.nfc_counter_enabled and not self._counter_triggered:
|
||||
self._counter_triggered = True
|
||||
if self._nfc_counter < 0xFFFFFF:
|
||||
self._nfc_counter += 1
|
||||
|
||||
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
|
||||
resp = super()._handle_read(frame)
|
||||
# A data frame is >1 byte; a single-byte reply is an ACK/NAK.
|
||||
if resp is not None and len(resp.data) > 1:
|
||||
self._maybe_bump_counter()
|
||||
return resp
|
||||
|
||||
def _handle_fast_read(self, frame: RFFrame) -> RFFrame | None:
|
||||
resp = super()._handle_fast_read(frame)
|
||||
if resp is not None and len(resp.data) > 1:
|
||||
self._maybe_bump_counter()
|
||||
return resp
|
||||
|
||||
# ----- READ_CNT (0x39) -----
|
||||
|
||||
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||
if (frame.data and frame.data[0] == READ_CNT and self._has_nfc_counter):
|
||||
return self._handle_read_cnt(frame)
|
||||
return super()._handle_application(frame)
|
||||
|
||||
def _handle_read_cnt(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2 or frame.data[1] != 0x02:
|
||||
return _nak(NAK_ARG)
|
||||
if not self.nfc_counter_enabled:
|
||||
return _nak(NAK_ARG)
|
||||
if (self._access & ACCESS_NFC_CNT_PWD_PROT) and not self._authenticated_pwd:
|
||||
return _nak(NAK_ARG)
|
||||
return RFFrame.from_bytes(self._nfc_counter.to_bytes(3, "little"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Concrete ICs
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class NTAG213(Ntag21x):
|
||||
"""NTAG213 — 180 bytes total, 144 bytes user (pages 04h–27h)."""
|
||||
_total_pages_cls = 45
|
||||
_version_bytes = b"\x00\x04\x04\x02\x01\x00\x0F\x03"
|
||||
_cc_default = b"\xE1\x10\x12\x00"
|
||||
_has_nfc_counter = True
|
||||
_cfg0_page = 0x29
|
||||
_cfg1_page = 0x2A
|
||||
_pwd_page = 0x2B
|
||||
_pack_page = 0x2C
|
||||
|
||||
|
||||
class NTAG215(Ntag21x):
|
||||
"""NTAG215 — 540 bytes total, 504 bytes user (pages 04h–81h)."""
|
||||
_total_pages_cls = 135
|
||||
_version_bytes = b"\x00\x04\x04\x02\x01\x00\x11\x03"
|
||||
_cc_default = b"\xE1\x10\x3E\x00"
|
||||
_has_nfc_counter = True
|
||||
_cfg0_page = 0x83
|
||||
_cfg1_page = 0x84
|
||||
_pwd_page = 0x85
|
||||
_pack_page = 0x86
|
||||
|
||||
|
||||
class NTAG216(Ntag21x):
|
||||
"""NTAG216 — 924 bytes total, 888 bytes user (pages 04h–E1h)."""
|
||||
_total_pages_cls = 231
|
||||
_version_bytes = b"\x00\x04\x04\x02\x01\x00\x13\x03"
|
||||
_cc_default = b"\xE1\x10\x6D\x00"
|
||||
_has_nfc_counter = True
|
||||
_cfg0_page = 0xE3
|
||||
_cfg1_page = 0xE4
|
||||
_pwd_page = 0xE5
|
||||
_pack_page = 0xE6
|
||||
|
||||
|
||||
class NTAG210(Ntag21x):
|
||||
"""NTAG210 — 48 bytes user (pages 04h–0Fh). No NFC counter, no dyn lock."""
|
||||
_total_pages_cls = 20
|
||||
_version_bytes = b"\x00\x04\x04\x01\x01\x00\x0B\x03"
|
||||
_cc_default = b"\xE1\x10\x06\x00"
|
||||
_has_nfc_counter = False
|
||||
_cfg0_page = 0x10
|
||||
_cfg1_page = 0x11
|
||||
_pwd_page = 0x12
|
||||
_pack_page = 0x13
|
||||
|
||||
|
||||
class NTAG212(Ntag21x):
|
||||
"""NTAG212 — 128 bytes user (pages 04h–23h). No NFC counter."""
|
||||
_total_pages_cls = 41
|
||||
_version_bytes = b"\x00\x04\x04\x01\x01\x00\x0E\x03"
|
||||
_cc_default = b"\xE1\x10\x10\x00"
|
||||
_has_nfc_counter = False
|
||||
_cfg0_page = 0x25
|
||||
_cfg1_page = 0x26
|
||||
_pwd_page = 0x27
|
||||
_pack_page = 0x28
|
||||
359
pm3py/transponders/hf/iso14443a/nxp/ntag_i2c.py
Normal file
359
pm3py/transponders/hf/iso14443a/nxp/ntag_i2c.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""NTAG I2C plus — NXP NT3H2111 (1k) / NT3H2211 (2k).
|
||||
|
||||
Sourced from the NXP datasheet (Rev. 3.5, doc 359935), RF/NFC interface only.
|
||||
|
||||
Distinctive features vs NTAG21x:
|
||||
- Memory addressed in sectors reached via SECTOR_SELECT (0xC2). Sector 0 holds
|
||||
UID/CC/user/config; Sector 1 (2k only) is 1 KB extra user memory; Sector 3
|
||||
mirrors the session registers.
|
||||
- READ does not roll over — a window running into an invalid region reads 00h;
|
||||
an out-of-range start page returns NAK.
|
||||
- FAST_WRITE (0xA6) writes the 64-byte SRAM in one shot (pass-through mode).
|
||||
- Has GET_VERSION, FAST_READ, PWD_AUTH (+PACK), READ_SIG. No NFC counter.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.bitfield import BitField, Register
|
||||
from .type2 import (
|
||||
NxpType2Tag, _ack, _nak, NAK_ARG, PWD_AUTH,
|
||||
SECTOR_SELECT, READ_CMD, WRITE_CMD, GET_VERSION, FAST_READ, READ_SIG,
|
||||
)
|
||||
|
||||
FAST_WRITE = 0xA6
|
||||
|
||||
# ACCESS byte (page E4h byte 0) bits
|
||||
NFC_PROT = 0x80
|
||||
NFC_DIS_SEC1 = 0x20
|
||||
|
||||
# NC_REG field-detect event maps (datasheet Table 13)
|
||||
_FD_OFF = {0: "field off", 1: "field off / HALT",
|
||||
2: "field off / last NDEF read", 3: "field off / last data (pass-through)"}
|
||||
_FD_ON = {0: "field on", 1: "first SoC", 2: "tag selected", 3: "data ready (pass-through)"}
|
||||
_I2C_PROT = {0: "full I2C access", 1: "protected area read-only",
|
||||
2: "protected area no access", 3: "protected area no access"}
|
||||
|
||||
|
||||
class _NtagI2CRegBlock(Register):
|
||||
"""Shared decode of an NTAG I2C plus register block (config pages E8h/E9h or the session
|
||||
copy at ECh/EDh) as one 64-bit word. Byte 6 differs (REG_LOCK vs NS_REG), so the concrete
|
||||
:class:`NtagI2CConfig` / :class:`NtagI2CSession` add it."""
|
||||
|
||||
_width_bits = 64
|
||||
|
||||
# --- NC_REG (byte 0) ---
|
||||
nfcs_i2c_rst = BitField(63, doc="enable NFC-silence + I2C soft-reset via repeated start")
|
||||
pthru_on = BitField(62, doc="pass-through mode: SRAM mapped to end of Sector 0")
|
||||
fd_off = BitField(61, 60, values=_FD_OFF, doc="event that releases the field-detect pin")
|
||||
fd_on = BitField(59, 58, values=_FD_ON, doc="event that pulls the field-detect pin low")
|
||||
sram_mirror_on = BitField(57, doc="mirror the 64-byte SRAM into user memory")
|
||||
transfer_dir = BitField(56, values={0: "I2C->NFC", 1: "NFC->I2C"},
|
||||
doc="pass-through data-flow direction (must be 1 when not in pass-through)")
|
||||
# --- byte 1..5 ---
|
||||
last_ndef_block = BitField(55, 48, doc="I2C block of the last NDEF byte (drives NDEF_DATA_READ)")
|
||||
sram_mirror_block = BitField(47, 40, doc="I2C block where the SRAM mirror starts (default F8h)")
|
||||
wdt_ls = BitField(39, 32, doc="watchdog timer, least-significant byte")
|
||||
wdt_ms = BitField(31, 24, doc="watchdog timer, most-significant byte")
|
||||
i2c_clock_str = BitField(16, doc="enable I2C clock stretching")
|
||||
|
||||
@property
|
||||
def watchdog(self) -> int:
|
||||
"""16-bit watchdog time (WDT_MS:WDT_LS)."""
|
||||
return (self.wdt_ms << 8) | self.wdt_ls
|
||||
|
||||
|
||||
class NtagI2CConfig(_NtagI2CRegBlock):
|
||||
"""NTAG I2C plus configuration registers (pages E8h/E9h) — the power-on default behaviour.
|
||||
Changes take effect after the next POR; use :class:`NtagI2CSession` to change the live
|
||||
session. ``REG_LOCK`` here permanently freezes these registers per interface."""
|
||||
|
||||
_label = "NTAG I2C config registers (E8/E9)"
|
||||
|
||||
reg_lock_i2c = BitField(9, doc="permanently lock these registers from I2C writes (irreversible)")
|
||||
reg_lock_nfc = BitField(8, doc="permanently lock these registers from NFC writes (irreversible)")
|
||||
|
||||
|
||||
class NtagI2CSession(_NtagI2CRegBlock):
|
||||
"""NTAG I2C plus session registers (pages ECh/EDh) — the live copy for the current RF
|
||||
session (NFC-read-only; POR reloads them from the config registers). Byte 6 is the NS_REG
|
||||
status; ``I2C_CLOCK_STR`` byte also carries the ``neg_auth_reached`` status bit."""
|
||||
|
||||
_label = "NTAG I2C session registers (EC/ED)"
|
||||
|
||||
neg_auth_reached = BitField(17, doc="status: negative-PWD_AUTH limit reached (auth locked)")
|
||||
# NS_REG status (byte 6)
|
||||
ndef_data_read = BitField(15, doc="status: all bytes up to LAST_NDEF_BLOCK have been read")
|
||||
i2c_locked = BitField(14, doc="status: memory arbitration locked to the I2C interface")
|
||||
rf_locked = BitField(13, doc="status: memory arbitration locked to the NFC interface")
|
||||
sram_i2c_ready = BitField(12, doc="status: SRAM data ready for the I2C interface to read")
|
||||
sram_rf_ready = BitField(11, doc="status: SRAM data ready for the NFC interface to read")
|
||||
eeprom_wr_err = BitField(10, doc="status: EEPROM write/erase HV error (write 0 via I2C to clear)")
|
||||
eeprom_wr_busy = BitField(9, doc="status: EEPROM write in progress")
|
||||
rf_field_present = BitField(8, doc="status: an RF field is present")
|
||||
|
||||
|
||||
class NtagI2CPtI2C(Register):
|
||||
"""NTAG I2C plus I2C-protection register (PT_I2C, page E7h byte 0) — restricts access to
|
||||
the password-protected area from the I2C side."""
|
||||
|
||||
_width_bits = 8
|
||||
_label = "NTAG I2C PT_I2C (E7)"
|
||||
|
||||
prot_2k = BitField(3, doc="require password auth to reach Sector 1 from I2C (2k)")
|
||||
sram_prot = BitField(2, doc="require password auth for pass-through / SRAM mirror")
|
||||
i2c_prot = BitField(1, 0, values=_I2C_PROT, doc="I2C access level to the protected area")
|
||||
|
||||
|
||||
class NtagI2C(NxpType2Tag):
|
||||
"""NTAG I2C plus base. Concrete ICs: NT3H2111 (1k), NT3H2211 (2k)."""
|
||||
|
||||
_is_2k: bool = False
|
||||
_has_cc = True
|
||||
|
||||
# Config / session pages within Sector 0.
|
||||
_cfg0_page = 0xE3 # RFU/RFU/RFU/AUTH0
|
||||
_cfg1_page = 0xE4 # ACCESS/RFU/RFU/RFU
|
||||
_pwd_page = 0xE5
|
||||
_pack_page = 0xE6
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
password: bytes = b"\xFF\xFF\xFF\xFF", pack: bytes = b"\x00\x00",
|
||||
auth0: int = 0xFF, prot: bool = False,
|
||||
signature: bytes | None = None):
|
||||
if uid is None:
|
||||
uid = b"\x04" + os.urandom(6)
|
||||
self._current_sector = 0
|
||||
self._sector1 = bytearray(256 * 4) if self._is_2k else None
|
||||
self._sram = bytearray(64)
|
||||
self._passthru = False
|
||||
super().__init__(uid=uid, password=password, pack=pack, auth0=auth0,
|
||||
prot=prot, signature=signature, cc=self._cc_default,
|
||||
ndef_message=ndef_message, total_pages=256,
|
||||
atqa=b"\x44\x00", sak=0x00)
|
||||
self._init_registers()
|
||||
|
||||
def _init_registers(self) -> None:
|
||||
"""Lay down configuration + session register defaults (Sector 0)."""
|
||||
# Configuration registers (pages E8h/E9h) and session (ECh/EDh).
|
||||
self._set_page(0xE8, bytes([0x01, 0x00, 0xF8, 0x48])) # NC_REG/LAST_NDEF/SRAM_MIRROR/WDT_LS
|
||||
self._set_page(0xE9, bytes([0x08, 0x01, 0x00, 0x00])) # WDT_MS/I2C_CLK_STR/REG_LOCK/RFU
|
||||
self._set_page(0xEC, bytes([0x01, 0x00, 0xF8, 0x48])) # session copy
|
||||
self._set_page(0xED, bytes([0x08, 0x01, 0x00, 0x00])) # WDT_MS/I2C_CLK_STR/NS_REG/RFU
|
||||
self._set_page(0xE7, bytes([0x00, 0x00, 0x00, 0x00])) # PT_I2C
|
||||
|
||||
# ----- config / session / protection registers -----
|
||||
|
||||
@property
|
||||
def config(self) -> NtagI2CConfig:
|
||||
"""The configuration registers (pages E8h/E9h) — power-on defaults. Assign back to
|
||||
program them (``tag.config = cfg``); effect applies after the next POR."""
|
||||
raw = self._get_page(0xE8) + self._get_page(0xE9)
|
||||
return NtagI2CConfig(int.from_bytes(raw, "big"))
|
||||
|
||||
@config.setter
|
||||
def config(self, value: "NtagI2CConfig | int") -> None:
|
||||
raw = int(value).to_bytes(8, "big")
|
||||
self._set_page(0xE8, raw[0:4])
|
||||
self._set_page(0xE9, raw[4:8])
|
||||
|
||||
@property
|
||||
def session(self) -> NtagI2CSession:
|
||||
"""The session registers (pages ECh/EDh) — the live copy for this RF session."""
|
||||
raw = self._get_page(0xEC) + self._get_page(0xED)
|
||||
return NtagI2CSession(int.from_bytes(raw, "big"))
|
||||
|
||||
@session.setter
|
||||
def session(self, value: "NtagI2CSession | int") -> None:
|
||||
raw = int(value).to_bytes(8, "big")
|
||||
self._set_page(0xEC, raw[0:4])
|
||||
self._set_page(0xED, raw[4:8])
|
||||
|
||||
@property
|
||||
def pt_i2c(self) -> NtagI2CPtI2C:
|
||||
"""The I2C-protection register (PT_I2C, page E7h byte 0)."""
|
||||
return NtagI2CPtI2C(self._get_page(0xE7)[0])
|
||||
|
||||
@pt_i2c.setter
|
||||
def pt_i2c(self, value: "NtagI2CPtI2C | int") -> None:
|
||||
self._set_page(0xE7, bytes([int(value) & 0xFF, 0, 0, 0]))
|
||||
|
||||
# ----- sector / page validity -----
|
||||
|
||||
@property
|
||||
def _access(self) -> int:
|
||||
return self._memory[self._cfg1_page * 4]
|
||||
|
||||
def _page_readable(self, sector: int, page: int) -> bool:
|
||||
if page > 0xFF:
|
||||
return False
|
||||
if sector == 0:
|
||||
if page <= 0xE9 or page in (0xEC, 0xED):
|
||||
return True
|
||||
if self._passthru and 0xF0 <= page <= 0xFF:
|
||||
return True
|
||||
return False
|
||||
if sector == 1:
|
||||
if not self._is_2k or (self._access & NFC_DIS_SEC1):
|
||||
return False
|
||||
return True
|
||||
if sector == 3:
|
||||
return page in (0xF8, 0xF9)
|
||||
return False
|
||||
|
||||
def _page_writable(self, sector: int, page: int) -> bool:
|
||||
if sector == 0:
|
||||
return 2 <= page <= 0xE9
|
||||
if sector == 1:
|
||||
return self._is_2k and not (self._access & NFC_DIS_SEC1)
|
||||
return False
|
||||
|
||||
def _page_bytes(self, sector: int, page: int) -> bytes:
|
||||
if sector == 0:
|
||||
return bytes(self._memory[page * 4:page * 4 + 4])
|
||||
if sector == 1 and self._sector1 is not None:
|
||||
return bytes(self._sector1[page * 4:page * 4 + 4])
|
||||
if sector == 3 and page in (0xF8, 0xF9):
|
||||
base = 0xEC + (page - 0xF8)
|
||||
return bytes(self._memory[base * 4:base * 4 + 4])
|
||||
if self._passthru and 0xF0 <= page <= 0xFF and sector == 0:
|
||||
off = (page - 0xF0) * 4
|
||||
return bytes(self._sram[off:off + 4])
|
||||
return b"\x00\x00\x00\x00"
|
||||
|
||||
def _masked_page(self, sector: int, page: int) -> bytes:
|
||||
b = self._page_bytes(sector, page)
|
||||
if sector == 0 and page in (self._pwd_page, self._pack_page):
|
||||
return b"\x00\x00\x00\x00"
|
||||
return b
|
||||
|
||||
# ----- command dispatch -----
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._current_sector = 0
|
||||
self._passthru = False
|
||||
|
||||
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||
if not frame.data:
|
||||
return None
|
||||
if self._pending == "sector_select":
|
||||
return self._handle_sector_select_p2(frame)
|
||||
cmd = frame.data[0]
|
||||
if cmd == SECTOR_SELECT:
|
||||
return self._handle_sector_select_p1(frame)
|
||||
if cmd == FAST_WRITE:
|
||||
return self._handle_fast_write(frame)
|
||||
return super()._handle_application(frame)
|
||||
|
||||
# ----- READ / FAST_READ / WRITE with sector routing, no rollover -----
|
||||
|
||||
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2:
|
||||
return _nak(NAK_ARG)
|
||||
page = frame.data[1]
|
||||
sec = self._current_sector
|
||||
if not self._page_readable(sec, page):
|
||||
return _nak(NAK_ARG)
|
||||
if self._read_protected(page) and sec == 0:
|
||||
return _nak(NAK_ARG)
|
||||
data = bytearray()
|
||||
for i in range(4):
|
||||
p = page + i
|
||||
if self._page_readable(sec, p):
|
||||
data += self._masked_page(sec, p)
|
||||
else:
|
||||
data += b"\x00\x00\x00\x00"
|
||||
return RFFrame.from_bytes(bytes(data))
|
||||
|
||||
def _handle_fast_read(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 3:
|
||||
return _nak(NAK_ARG)
|
||||
start, end = frame.data[1], frame.data[2]
|
||||
sec = self._current_sector
|
||||
if end < start or not self._page_readable(sec, start):
|
||||
return _nak(NAK_ARG)
|
||||
if self._read_protected(start) and sec == 0:
|
||||
return _nak(NAK_ARG)
|
||||
data = bytearray()
|
||||
for p in range(start, end + 1):
|
||||
if self._page_readable(sec, p):
|
||||
data += self._masked_page(sec, p)
|
||||
else:
|
||||
data += b"\x00\x00\x00\x00"
|
||||
return RFFrame.from_bytes(bytes(data))
|
||||
|
||||
def _handle_write(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 6:
|
||||
return _nak(NAK_ARG)
|
||||
page = frame.data[1]
|
||||
sec = self._current_sector
|
||||
if not self._page_writable(sec, page):
|
||||
return _nak(NAK_ARG)
|
||||
if sec == 0 and self._write_protected(page):
|
||||
return _nak(NAK_ARG)
|
||||
payload = bytes(frame.data[2:6])
|
||||
if sec == 0:
|
||||
self._apply_page_write(page, payload)
|
||||
elif sec == 1 and self._sector1 is not None:
|
||||
self._sector1[page * 4:page * 4 + 4] = payload
|
||||
return _ack()
|
||||
|
||||
# ----- SECTOR_SELECT (0xC2), two packets -----
|
||||
|
||||
def _handle_sector_select_p1(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2 or frame.data[1] != 0xFF:
|
||||
return _nak(NAK_ARG)
|
||||
self._pending = "sector_select"
|
||||
return _ack()
|
||||
|
||||
def _handle_sector_select_p2(self, frame: RFFrame) -> RFFrame | None:
|
||||
self._pending = None
|
||||
sec = frame.data[0]
|
||||
valid = sec == 0 or sec == 3 or (sec == 1 and self._is_2k)
|
||||
if not valid:
|
||||
return _nak(NAK_ARG)
|
||||
self._current_sector = sec
|
||||
# Success is signalled by passive ACK (>1 ms of silence) — no reply.
|
||||
return None
|
||||
|
||||
# ----- FAST_WRITE (0xA6): 64-byte SRAM (pass-through) -----
|
||||
|
||||
def _handle_fast_write(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 3 or frame.data[1] != 0xF0 or frame.data[2] != 0xFF:
|
||||
return _nak(NAK_ARG)
|
||||
data = bytes(frame.data[3:3 + 64])
|
||||
self._sram[:len(data)] = data
|
||||
return _ack()
|
||||
|
||||
def enumerate_responses(self):
|
||||
"""Table entries for the readable Sector 0 pages + GET_VERSION/READ_SIG."""
|
||||
FLAG_CRC = 0x01
|
||||
if self._version_bytes is not None:
|
||||
yield (bytes([GET_VERSION]), self._version_bytes, FLAG_CRC)
|
||||
for page in range(0xEA): # Sector 0 readable user/config span
|
||||
if self._read_protected(page):
|
||||
continue
|
||||
data = bytearray()
|
||||
for i in range(4):
|
||||
data += self._masked_page(0, page + i)
|
||||
yield (bytes([READ_CMD, page]), bytes(data), FLAG_CRC)
|
||||
if self._has_read_sig:
|
||||
yield (bytes([READ_SIG, 0x00]), self._signature, FLAG_CRC)
|
||||
|
||||
|
||||
class NT3H2111(NtagI2C):
|
||||
"""NTAG I2C plus 1k (NT3H2111) — 888 bytes user (Sector 0, pages 04h-E1h)."""
|
||||
_is_2k = False
|
||||
_version_bytes = b"\x00\x04\x04\x05\x02\x02\x13\x03"
|
||||
_cc_default = b"\xE1\x10\x6D\x00" # NFC-Forum recommended CC (datasheet Fig. 11)
|
||||
|
||||
|
||||
class NT3H2211(NtagI2C):
|
||||
"""NTAG I2C plus 2k (NT3H2211) — 888 + 1024 bytes user (Sector 0 + Sector 1)."""
|
||||
_is_2k = True
|
||||
_version_bytes = b"\x00\x04\x04\x05\x02\x02\x15\x03"
|
||||
# The datasheet does not tabulate a 2k CC; E1 10 EA 00 is the value NXP
|
||||
# ships on NT3H2211 (T2T area sized to 1872 bytes). Adjust with set CC if needed.
|
||||
_cc_default = b"\xE1\x10\xEA\x00"
|
||||
435
pm3py/transponders/hf/iso14443a/nxp/type2.py
Normal file
435
pm3py/transponders/hf/iso14443a/nxp/type2.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""NXP byte-oriented Type 2 tag platform (MIFARE Ultralight / NTAG).
|
||||
|
||||
Shared command handling for the NXP Type 2 family: GET_VERSION, FAST_READ,
|
||||
COMPATIBILITY_WRITE, PWD_AUTH, READ_SIG plus AUTH0-based password protection
|
||||
and the PWD/PACK config-page read-back masking. Concrete ICs (NTAG21x,
|
||||
Ultralight EV1, NTAG I2C) subclass this and add their product-specific bits
|
||||
(NFC counter, incrementable counters, sector select).
|
||||
|
||||
This mirrors the ISO 15693 side, where ``NxpIcodeTag`` centralises the NXP
|
||||
custom command set and per-IC classes extend it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.trace.decode_iso14a import decode_14443a_type2
|
||||
from pm3py.transponders.bitfield import BitField, Register
|
||||
from ..ndef import NfcType2Tag, PAGE_SIZE, READ_CMD, WRITE_CMD
|
||||
|
||||
# ---- Command codes (NFC Forum Type 2 / Ultralight / NTAG) ----
|
||||
GET_VERSION = 0x60
|
||||
FAST_READ = 0x3A
|
||||
COMPAT_WRITE = 0xA0
|
||||
PWD_AUTH = 0x1B
|
||||
READ_CNT = 0x39
|
||||
INCR_CNT = 0xA5
|
||||
CHECK_TEARING = 0x3E
|
||||
READ_SIG = 0x3C
|
||||
SECTOR_SELECT = 0xC2
|
||||
|
||||
# ---- 4-bit ACK / NAK codes (Table 23, NTAG21x datasheet) ----
|
||||
ACK = 0x0A
|
||||
NAK_ARG = 0x00 # invalid argument (invalid page address)
|
||||
NAK_CRC = 0x01 # parity or CRC error
|
||||
NAK_AUTH = 0x04 # invalid authentication counter overflow
|
||||
NAK_WRITE = 0x05 # EEPROM write error
|
||||
|
||||
|
||||
def _ack() -> RFFrame:
|
||||
return RFFrame.from_bytes(bytes([ACK]))
|
||||
|
||||
|
||||
def _nak(code: int = NAK_ARG) -> RFFrame:
|
||||
return RFFrame.from_bytes(bytes([code]))
|
||||
|
||||
|
||||
class Type2Config(Register):
|
||||
"""The two NXP Type 2 configuration pages (CFG0 + CFG1) decoded as one 64-bit word:
|
||||
the 4 bytes of CFG0 (MSB) followed by the 4 bytes of CFG1.
|
||||
|
||||
Common to Ultralight EV1 and NTAG21x. Only the fields every part shares live here —
|
||||
NTAG adds its mirror / NFC-counter fields in :class:`Ntag21xConfig`. Bits are numbered on
|
||||
the concatenated word, so CFG0 byte 0 is bits 63:56 and CFG1 byte 0 (ACCESS) is bits 31:24.
|
||||
"""
|
||||
|
||||
_width_bits = 64
|
||||
_label = "Type 2 config (CFG0/CFG1)"
|
||||
|
||||
strg_mod_en = BitField(58, doc="strong load modulation enable (CFG0 MIRROR bit 2)")
|
||||
auth0 = BitField(39, 32, doc="first page needing PWD_AUTH; >= last page ⇒ protection off")
|
||||
prot = BitField(31, doc="0 = write protect from AUTH0; 1 = read+write protect")
|
||||
cfglck = BitField(30, doc="lock CFG0/CFG1 permanently — irreversible")
|
||||
authlim = BitField(26, 24, doc="failed-PWD_AUTH attempts before lockout (0 = unlimited)")
|
||||
|
||||
|
||||
class NxpType2Tag(NfcType2Tag):
|
||||
"""NXP Type 2 platform: Ultralight EV1 / NTAG21x / NTAG I2C command core.
|
||||
|
||||
Subclasses set the capability/layout class attributes below. Everything
|
||||
that is common — version, fast read, compatibility write, password auth,
|
||||
originality signature, AUTH0 access control — lives here.
|
||||
"""
|
||||
|
||||
# Capabilities (subclasses override)
|
||||
_version_bytes: bytes | None = None # 8-byte GET_VERSION response, None = unsupported
|
||||
_has_fast_read: bool = True
|
||||
_has_read_sig: bool = True
|
||||
_has_pwd_auth: bool = True
|
||||
|
||||
_has_cc: bool = True # False for Ultralight-style parts (page 3 = OTP)
|
||||
_ndef_cc: bytes | None = None # CC stamped into OTP when NDEF-formatting a UL part
|
||||
|
||||
# Config-page indices (subclasses set to real page numbers, None = none)
|
||||
_cfg0_page: int | None = None # MIRROR / RFUI / MIRROR_PAGE / AUTH0
|
||||
_cfg1_page: int | None = None # ACCESS / RFUI / RFUI / RFUI
|
||||
_pwd_page: int | None = None # 4-byte password (reads back as 00)
|
||||
_pack_page: int | None = None # 2-byte PACK + RFUI (reads back as 00)
|
||||
|
||||
#: register class decoding the CFG0/CFG1 pages (subclasses with config pages set this)
|
||||
_config_cls: type[Register] | None = None
|
||||
|
||||
@property
|
||||
def config(self) -> Register:
|
||||
"""CFG0/CFG1 decoded as a self-describing register (see the part's ``_config_cls``).
|
||||
Assign one back (``tag.config = cfg``) to program both config pages."""
|
||||
if self._config_cls is None or self._cfg0_page is None or self._cfg1_page is None:
|
||||
raise AttributeError(f"{type(self).__name__} has no CFG0/CFG1 config register")
|
||||
raw = self._get_page(self._cfg0_page) + self._get_page(self._cfg1_page)
|
||||
return self._config_cls(int.from_bytes(raw, "big"))
|
||||
|
||||
@config.setter
|
||||
def config(self, value: "Register | int") -> None:
|
||||
if self._cfg0_page is None or self._cfg1_page is None:
|
||||
raise AttributeError(f"{type(self).__name__} has no CFG0/CFG1 config register")
|
||||
raw = int(value).to_bytes(8, "big")
|
||||
self._set_page(self._cfg0_page, raw[0:4])
|
||||
self._set_page(self._cfg1_page, raw[4:8])
|
||||
|
||||
def __init__(self, uid: bytes, *,
|
||||
password: bytes = b"\xFF\xFF\xFF\xFF",
|
||||
pack: bytes = b"\x00\x00",
|
||||
auth0: int = 0xFF,
|
||||
prot: bool = False,
|
||||
signature: bytes | None = None,
|
||||
cc: bytes | None = None,
|
||||
ndef_message: bytes = b"",
|
||||
total_pages: int = 45,
|
||||
atqa: bytes = b"\x44\x00", sak: int = 0x00):
|
||||
# Stash product config so the overridden _init_memory() can use it.
|
||||
self._cc = cc
|
||||
self._init_password = password
|
||||
self._init_pack = pack
|
||||
self._init_auth0 = auth0
|
||||
self._init_prot = prot
|
||||
self._signature = (signature or bytes(32))
|
||||
self._authenticated_pwd = False
|
||||
self._pending: str | None = None # two-phase command state
|
||||
self._pending_page: int = 0
|
||||
super().__init__(uid=uid, ndef_message=ndef_message,
|
||||
total_pages=total_pages, atqa=atqa, sak=sak)
|
||||
|
||||
# ----- memory / config initialisation -----
|
||||
|
||||
def _init_memory(self, ndef_message: bytes) -> None:
|
||||
"""Lay out UID/BCC, CC, config pages, then optional NDEF."""
|
||||
self._ndef_data_offset = 4 * PAGE_SIZE
|
||||
self._ndef_capacity = (self._total_pages - 4) * PAGE_SIZE - 3
|
||||
|
||||
# Mirror the 7-byte UID + BCC into pages 0-2 so READ returns it
|
||||
# (page0 = SN0..2+BCC0, page1 = SN3..6, page2 byte0 = BCC1).
|
||||
uid = self._uid
|
||||
if len(uid) == 7:
|
||||
bcc0 = 0x88 ^ uid[0] ^ uid[1] ^ uid[2]
|
||||
bcc1 = uid[3] ^ uid[4] ^ uid[5] ^ uid[6]
|
||||
self._memory[0:4] = bytes(uid[0:3]) + bytes([bcc0])
|
||||
self._memory[4:8] = bytes(uid[3:7])
|
||||
self._memory[8] = bcc1
|
||||
|
||||
# Page 3 — Capability Container (NTAG) or OTP (Ultralight). Only lay
|
||||
# down a CC for parts that carry one; Ultralight-family parts leave
|
||||
# page 3 as the (zeroed) OTP area.
|
||||
if self._has_cc:
|
||||
if self._cc is not None:
|
||||
self._memory[3 * PAGE_SIZE:4 * PAGE_SIZE] = self._cc
|
||||
else:
|
||||
data_size = (self._total_pages - 4) * PAGE_SIZE
|
||||
self._memory[3 * PAGE_SIZE:4 * PAGE_SIZE] = bytes(
|
||||
[0xE1, 0x10, data_size // 8, 0x00])
|
||||
elif ndef_message and self._ndef_cc is not None:
|
||||
# Ultralight parts carry OTP at page 3; NDEF-formatting writes the
|
||||
# product CC there so a phone recognises the tag as Type 2 NDEF.
|
||||
self._memory[3 * PAGE_SIZE:4 * PAGE_SIZE] = self._ndef_cc
|
||||
|
||||
# Config pages
|
||||
if self._pwd_page is not None:
|
||||
self._set_page(self._pwd_page, self._init_password)
|
||||
if self._pack_page is not None:
|
||||
self._set_page(self._pack_page, self._init_pack + b"\x00\x00")
|
||||
if self._cfg0_page is not None:
|
||||
cfg0 = bytearray(self._get_page(self._cfg0_page))
|
||||
cfg0[3] = self._init_auth0 & 0xFF # AUTH0
|
||||
self._set_page(self._cfg0_page, bytes(cfg0))
|
||||
if self._cfg1_page is not None:
|
||||
cfg1 = bytearray(self._get_page(self._cfg1_page))
|
||||
if self._init_prot:
|
||||
cfg1[0] |= 0x80 # ACCESS.PROT
|
||||
self._set_page(self._cfg1_page, bytes(cfg1))
|
||||
|
||||
if ndef_message:
|
||||
self.set_ndef(ndef_message)
|
||||
|
||||
# ----- page helpers -----
|
||||
|
||||
def _get_page(self, page: int) -> bytes:
|
||||
off = page * PAGE_SIZE
|
||||
return bytes(self._memory[off:off + PAGE_SIZE])
|
||||
|
||||
def get_page(self, page: int) -> bytes:
|
||||
return self._get_page(page)
|
||||
|
||||
def _set_page(self, page: int, data: bytes) -> None:
|
||||
off = page * PAGE_SIZE
|
||||
self._memory[off:off + PAGE_SIZE] = data[:PAGE_SIZE].ljust(PAGE_SIZE, b"\x00")
|
||||
|
||||
def set_page(self, page: int, data: bytes) -> None:
|
||||
if len(data) != PAGE_SIZE:
|
||||
raise ValueError(f"Expected {PAGE_SIZE} bytes")
|
||||
self._set_page(page, data)
|
||||
|
||||
def _apply_page_write(self, page: int, data: bytes) -> None:
|
||||
"""Write a page honouring the OR-once bytes.
|
||||
|
||||
Page 2 bytes 0-1 are read-only (UID / internal); bytes 2-3 are the
|
||||
static lock bytes and are bit-wise OR'd. Page 3 (OTP on Ultralight,
|
||||
CC on NTAG) is OR'd whole. Everything else overwrites.
|
||||
"""
|
||||
data = data[:PAGE_SIZE].ljust(PAGE_SIZE, b"\x00")
|
||||
off = page * PAGE_SIZE
|
||||
cur = bytearray(self._memory[off:off + PAGE_SIZE])
|
||||
if page == 2:
|
||||
cur[2] |= data[2]
|
||||
cur[3] |= data[3]
|
||||
elif page == 3:
|
||||
for i in range(PAGE_SIZE):
|
||||
cur[i] |= data[i]
|
||||
else:
|
||||
cur[:] = data
|
||||
self._memory[off:off + PAGE_SIZE] = bytes(cur)
|
||||
|
||||
# ----- config accessors (derived from memory, faithful to the IC) -----
|
||||
|
||||
@property
|
||||
def auth0(self) -> int:
|
||||
if self._cfg0_page is None:
|
||||
return 0xFF
|
||||
return self._memory[self._cfg0_page * PAGE_SIZE + 3]
|
||||
|
||||
@property
|
||||
def memory(self) -> bytes:
|
||||
return bytes(self._memory)
|
||||
|
||||
@property
|
||||
def prot(self) -> bool:
|
||||
if self._cfg1_page is None:
|
||||
return False
|
||||
return bool(self._memory[self._cfg1_page * PAGE_SIZE] & 0x80)
|
||||
|
||||
@property
|
||||
def password(self) -> bytes:
|
||||
if self._pwd_page is None:
|
||||
return b""
|
||||
return self._get_page(self._pwd_page)
|
||||
|
||||
@password.setter
|
||||
def password(self, value: bytes) -> None:
|
||||
if self._pwd_page is not None and len(value) == 4:
|
||||
self._set_page(self._pwd_page, value)
|
||||
|
||||
@property
|
||||
def pack(self) -> bytes:
|
||||
if self._pack_page is None:
|
||||
return b""
|
||||
return self._get_page(self._pack_page)[:2]
|
||||
|
||||
@pack.setter
|
||||
def pack(self, value: bytes) -> None:
|
||||
if self._pack_page is not None and len(value) == 2:
|
||||
self._set_page(self._pack_page, value + b"\x00\x00")
|
||||
|
||||
def _is_config_page(self, page: int) -> bool:
|
||||
return page in (self._pwd_page, self._pack_page)
|
||||
|
||||
# ----- trace decode -----
|
||||
|
||||
def decode_trace(self, direction: int, payload: bytes) -> str | None:
|
||||
return decode_14443a_type2(direction, payload)
|
||||
|
||||
# ----- power / reset -----
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._authenticated_pwd = False
|
||||
self._pending = None
|
||||
|
||||
async def power_off(self) -> None:
|
||||
await super().power_off()
|
||||
self._authenticated_pwd = False
|
||||
self._pending = None
|
||||
|
||||
# ----- command dispatch -----
|
||||
|
||||
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||
if not frame.data:
|
||||
return None
|
||||
|
||||
# Second phase of COMPATIBILITY_WRITE: 16 data bytes (not a command).
|
||||
if self._pending == "compat_write":
|
||||
return self._handle_compat_write_data(frame)
|
||||
|
||||
cmd = frame.data[0]
|
||||
|
||||
if cmd == GET_VERSION and self._version_bytes is not None:
|
||||
return RFFrame.from_bytes(self._version_bytes)
|
||||
if cmd == FAST_READ and self._has_fast_read:
|
||||
return self._handle_fast_read(frame)
|
||||
if cmd == COMPAT_WRITE:
|
||||
return self._handle_compat_write_cmd(frame)
|
||||
if cmd == PWD_AUTH and self._has_pwd_auth:
|
||||
return self._handle_pwd_auth(frame)
|
||||
if cmd == READ_SIG and self._has_read_sig:
|
||||
return self._handle_read_sig(frame)
|
||||
|
||||
# READ / WRITE handled by NfcType2Tag, but with our access rules.
|
||||
if cmd == READ_CMD:
|
||||
return self._handle_read(frame)
|
||||
if cmd == WRITE_CMD:
|
||||
return self._handle_write(frame)
|
||||
|
||||
return None
|
||||
|
||||
# ----- access control -----
|
||||
|
||||
def _read_protected(self, page: int) -> bool:
|
||||
"""Read protection applies only if PROT=1 (read+write protected)."""
|
||||
return (self.prot and not self._authenticated_pwd
|
||||
and page >= self.auth0 and self.auth0 <= self._total_pages)
|
||||
|
||||
def _write_protected(self, page: int) -> bool:
|
||||
return (not self._authenticated_pwd
|
||||
and page >= self.auth0 and self.auth0 <= self._total_pages)
|
||||
|
||||
def _mask_config(self, start_page: int, data: bytearray) -> None:
|
||||
"""Blank out PWD/PACK bytes in a READ window (they never read back)."""
|
||||
for i in range(len(data)):
|
||||
pg = (start_page + i // PAGE_SIZE)
|
||||
# honour rollover
|
||||
pg = pg % self._total_pages
|
||||
if self._is_config_page(pg):
|
||||
data[i] = 0x00
|
||||
|
||||
# ----- READ (override to add protection + config masking) -----
|
||||
|
||||
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2:
|
||||
return None
|
||||
page = frame.data[1]
|
||||
if page >= self._total_pages:
|
||||
return _nak(NAK_ARG)
|
||||
if self._read_protected(page):
|
||||
return _nak(NAK_ARG)
|
||||
data = bytearray(16)
|
||||
for i in range(16):
|
||||
offset = (page * PAGE_SIZE + i) % len(self._memory)
|
||||
data[i] = self._memory[offset]
|
||||
self._mask_config(page, data)
|
||||
return RFFrame.from_bytes(bytes(data))
|
||||
|
||||
def _handle_fast_read(self, frame: RFFrame) -> RFFrame | None:
|
||||
"""FAST_READ (0x3A): return pages start..end inclusive."""
|
||||
if len(frame.data) < 3:
|
||||
return _nak(NAK_ARG)
|
||||
start = frame.data[1]
|
||||
end = frame.data[2]
|
||||
if start >= self._total_pages or end >= self._total_pages or end < start:
|
||||
return _nak(NAK_ARG)
|
||||
if self._read_protected(start) or self._read_protected(end):
|
||||
return _nak(NAK_ARG)
|
||||
data = bytearray(self._memory[start * PAGE_SIZE:(end + 1) * PAGE_SIZE])
|
||||
self._mask_config(start, data)
|
||||
return RFFrame.from_bytes(bytes(data))
|
||||
|
||||
# ----- WRITE (override to add protection) -----
|
||||
|
||||
def _handle_write(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 6:
|
||||
return _nak(NAK_ARG)
|
||||
page = frame.data[1]
|
||||
if page < 2 or page >= self._total_pages:
|
||||
return _nak(NAK_ARG)
|
||||
if self._write_protected(page):
|
||||
return _nak(NAK_ARG)
|
||||
self._apply_page_write(page, bytes(frame.data[2:6]))
|
||||
return _ack()
|
||||
|
||||
# ----- COMPATIBILITY_WRITE (0xA0), two-phase -----
|
||||
|
||||
def _handle_compat_write_cmd(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2:
|
||||
return _nak(NAK_ARG)
|
||||
page = frame.data[1]
|
||||
if page < 2 or page >= self._total_pages:
|
||||
return _nak(NAK_ARG)
|
||||
if self._write_protected(page):
|
||||
return _nak(NAK_ARG)
|
||||
self._pending = "compat_write"
|
||||
self._pending_page = page
|
||||
return _ack()
|
||||
|
||||
def _handle_compat_write_data(self, frame: RFFrame) -> RFFrame | None:
|
||||
self._pending = None
|
||||
if len(frame.data) < 4:
|
||||
return _nak(NAK_ARG)
|
||||
# Only the least-significant 4 bytes are written (rest must be 00).
|
||||
self._apply_page_write(self._pending_page, bytes(frame.data[:4]))
|
||||
return _ack()
|
||||
|
||||
# ----- PWD_AUTH (0x1B) -----
|
||||
|
||||
def _handle_pwd_auth(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 5:
|
||||
return _nak(NAK_ARG)
|
||||
supplied = bytes(frame.data[1:5])
|
||||
if self._pwd_page is None or supplied != self.password:
|
||||
return _nak(NAK_AUTH)
|
||||
self._authenticated_pwd = True
|
||||
return RFFrame.from_bytes(self.pack)
|
||||
|
||||
# ----- READ_SIG (0x3C) -----
|
||||
|
||||
def _handle_read_sig(self, frame: RFFrame) -> RFFrame | None:
|
||||
return RFFrame.from_bytes(self._signature)
|
||||
|
||||
# ----- table compilation hook -----
|
||||
|
||||
def enumerate_responses(self):
|
||||
"""Yield (cmd_bytes, response_bytes, flags) for the firmware table.
|
||||
|
||||
Covers the static, stateless commands a phone issues before any
|
||||
password/auth interaction: GET_VERSION, per-page READ, FAST_READ of
|
||||
the whole memory, READ_SIG. Stateful commands (PWD_AUTH, counters,
|
||||
writes) are served by the tag model over the WTX/retry relay.
|
||||
"""
|
||||
FLAG_CRC = 0x01
|
||||
if self._version_bytes is not None:
|
||||
yield (bytes([GET_VERSION]), self._version_bytes, FLAG_CRC)
|
||||
# READ for each unprotected page (16 bytes, rolling over).
|
||||
for page in range(self._total_pages):
|
||||
if self._read_protected(page):
|
||||
continue
|
||||
data = bytearray(16)
|
||||
for i in range(16):
|
||||
data[i] = self._memory[(page * PAGE_SIZE + i) % len(self._memory)]
|
||||
self._mask_config(page, data)
|
||||
yield (bytes([READ_CMD, page]), bytes(data), FLAG_CRC)
|
||||
if self._has_read_sig:
|
||||
yield (bytes([READ_SIG, 0x00]), self._signature, FLAG_CRC)
|
||||
301
pm3py/transponders/hf/iso14443a/nxp/ultralight.py
Normal file
301
pm3py/transponders/hf/iso14443a/nxp/ultralight.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""MIFARE Ultralight family — original, Ultralight C, Ultralight EV1.
|
||||
|
||||
Sourced from the NXP datasheets:
|
||||
- MF0ICU2 = MIFARE Ultralight C (Rev. 3.5) — 3DES AUTHENTICATE, 16-bit counter.
|
||||
- MF0ULX1 = MIFARE Ultralight EV1 (Rev. 3.3) — MF0UL11 / MF0UL21.
|
||||
The original MIFARE Ultralight (MF0ICU1) datasheet was not supplied; MifareUltralight
|
||||
below models its well-established behaviour (16 pages, READ/WRITE/COMPAT_WRITE, OTP).
|
||||
|
||||
All three share the Type 2 command core in ``type2.NxpType2Tag`` (READ, WRITE,
|
||||
COMPATIBILITY_WRITE, OTP/lock OR-on-write). Product-specific pieces are added here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.bitfield import BitField, Register
|
||||
from .type2 import (
|
||||
NxpType2Tag, Type2Config, _ack, _nak, NAK_ARG, NAK_AUTH,
|
||||
READ_CNT, INCR_CNT, CHECK_TEARING, READ_CMD, WRITE_CMD,
|
||||
)
|
||||
|
||||
AUTHENTICATE = 0x1A # Ultralight C 3DES authenticate
|
||||
AUTH_CONT = 0xAF # continuation byte (part 2)
|
||||
|
||||
|
||||
class UltralightEV1Config(Type2Config):
|
||||
"""Ultralight EV1 CFG0/CFG1 — the shared :class:`Type2Config` fields (auth0, prot, cfglck,
|
||||
authlim, strg_mod_en). EV1 has no ASCII mirror or NFC counter, so nothing is added."""
|
||||
|
||||
_label = "Ultralight EV1 config (CFG0/CFG1)"
|
||||
|
||||
|
||||
class UltralightCConfig(Register):
|
||||
"""Ultralight C protection config: AUTH0 (page 2Ah byte 0) + the AUTH1 protection bit
|
||||
(page 2Bh byte 0), packed as ``auth0 << 8 | auth1``. Protection from AUTH0 onward is
|
||||
enforced by 3DES AUTHENTICATE rather than a password."""
|
||||
|
||||
_width_bits = 16
|
||||
_label = "Ultralight C auth config"
|
||||
|
||||
auth0 = BitField(15, 8, doc="first page (>= 3) protected; 0x30+ ⇒ protection disabled")
|
||||
auth1_prot = BitField(0, values={0: "read+write", 1: "write-only"},
|
||||
doc="protection scope from AUTH0 onward")
|
||||
|
||||
# NXP factory default Ultralight C 3DES key (K1||K2, 16 bytes)
|
||||
UL_C_DEFAULT_KEY = bytes.fromhex("49454D4B41455242214E4143554F5946")
|
||||
|
||||
|
||||
def _rotl1(b: bytes) -> bytes:
|
||||
"""Rotate an 8-byte block left by one byte."""
|
||||
return b[1:] + b[:1]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Original MIFARE Ultralight (MF0ICU1)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class MifareUltralight(NxpType2Tag):
|
||||
"""Original MIFARE Ultralight (MF0ICU1) — 64 bytes, 16 pages.
|
||||
|
||||
Pages 0-1 UID, page 2 internal + static lock, page 3 OTP, pages 4-15 user.
|
||||
Commands: READ (0x30), WRITE (0xA2), COMPATIBILITY_WRITE (0xA0). No
|
||||
GET_VERSION / FAST_READ / PWD_AUTH — those are EV1 features.
|
||||
"""
|
||||
_version_bytes = None
|
||||
_has_fast_read = False
|
||||
_has_read_sig = False
|
||||
_has_pwd_auth = False
|
||||
_has_cc = False
|
||||
_ndef_cc = b"\xE1\x10\x06\x00" # 48 bytes user
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b""):
|
||||
if uid is None:
|
||||
uid = b"\x04" + os.urandom(6)
|
||||
super().__init__(uid=uid, ndef_message=ndef_message, total_pages=16,
|
||||
atqa=b"\x44\x00", sak=0x00)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MIFARE Ultralight C (MF0ICU2)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class MifareUltralightC(NxpType2Tag):
|
||||
"""MIFARE Ultralight C (MF0ICU2) — 192 bytes, 48 pages, 3DES auth.
|
||||
|
||||
Layout: page 3 OTP, pages 04h-27h user, page 28h lock bytes 2/3,
|
||||
page 29h 16-bit counter, page 2Ah AUTH0, page 2Bh AUTH1, pages 2Ch-2Fh
|
||||
16-byte 3DES key. Protection from AUTH0 onward is gated by a successful
|
||||
3DES AUTHENTICATE (0x1A) rather than PWD_AUTH.
|
||||
"""
|
||||
_version_bytes = None
|
||||
_has_fast_read = False
|
||||
_has_read_sig = False
|
||||
_has_pwd_auth = False
|
||||
_has_cc = False
|
||||
_ndef_cc = b"\xE1\x10\x12\x00" # 144 bytes user
|
||||
|
||||
AUTH0_PAGE = 0x2A
|
||||
AUTH1_PAGE = 0x2B
|
||||
KEY_PAGE = 0x2C # 4 pages: 2C..2F
|
||||
COUNTER_PAGE = 0x29
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
key: bytes = UL_C_DEFAULT_KEY, auth0: int = 0x30,
|
||||
auth1_read_protect: bool = True):
|
||||
if uid is None:
|
||||
uid = b"\x04" + os.urandom(6)
|
||||
self._auth_state = "NONE" # NONE, WAIT_RESP, AUTHENTICATED
|
||||
self._rnd_b = b""
|
||||
self._resp_iv = b""
|
||||
super().__init__(uid=uid, ndef_message=ndef_message, total_pages=48,
|
||||
atqa=b"\x44\x00", sak=0x00)
|
||||
# Install key + AUTH0/AUTH1 into their pages.
|
||||
for i in range(4):
|
||||
self._set_page(self.KEY_PAGE + i, key[i * 4:i * 4 + 4])
|
||||
self._set_page(self.AUTH0_PAGE, bytes([auth0, 0, 0, 0]))
|
||||
self._set_page(self.AUTH1_PAGE, bytes([0x00 if auth1_read_protect else 0x01, 0, 0, 0]))
|
||||
self._counter = 0
|
||||
|
||||
@property
|
||||
def key(self) -> bytes:
|
||||
return b"".join(self._get_page(self.KEY_PAGE + i) for i in range(4))
|
||||
|
||||
@property
|
||||
def auth0(self) -> int:
|
||||
return self._get_page(self.AUTH0_PAGE)[0]
|
||||
|
||||
@property
|
||||
def _auth1_read_protected(self) -> bool:
|
||||
return (self._get_page(self.AUTH1_PAGE)[0] & 0x01) == 0x00
|
||||
|
||||
@property
|
||||
def config(self) -> UltralightCConfig:
|
||||
"""AUTH0 + AUTH1 protection decoded as a self-describing register. Assign one back
|
||||
(``tag.config = cfg``) to program the AUTH0 / AUTH1 pages."""
|
||||
return UltralightCConfig(
|
||||
(self._get_page(self.AUTH0_PAGE)[0] << 8) | self._get_page(self.AUTH1_PAGE)[0]
|
||||
)
|
||||
|
||||
@config.setter
|
||||
def config(self, value: "UltralightCConfig | int") -> None:
|
||||
v = int(value)
|
||||
self._set_page(self.AUTH0_PAGE, bytes([(v >> 8) & 0xFF, 0, 0, 0]))
|
||||
self._set_page(self.AUTH1_PAGE, bytes([v & 0x01, 0, 0, 0]))
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._auth_state = "NONE"
|
||||
|
||||
# UL-C protection is gated by 3DES auth, not PWD_AUTH.
|
||||
def _read_protected(self, page: int) -> bool:
|
||||
return (self._auth1_read_protected and self._auth_state != "AUTHENTICATED"
|
||||
and page >= self.auth0 and self.auth0 <= self._total_pages)
|
||||
|
||||
def _write_protected(self, page: int) -> bool:
|
||||
return (self._auth_state != "AUTHENTICATED"
|
||||
and page >= self.auth0 and self.auth0 <= self._total_pages)
|
||||
|
||||
def _is_config_page(self, page: int) -> bool:
|
||||
# The 3DES key never reads back.
|
||||
return self.KEY_PAGE <= page < self.KEY_PAGE + 4
|
||||
|
||||
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||
if not frame.data:
|
||||
return None
|
||||
# Second phase of AUTHENTICATE (reader sends 0xAF + 16 bytes).
|
||||
if self._auth_state == "WAIT_RESP" and frame.data[0] == AUTH_CONT:
|
||||
return self._handle_auth_part2(frame)
|
||||
if frame.data[0] == AUTHENTICATE:
|
||||
return self._handle_auth_part1(frame)
|
||||
if frame.data[0] == WRITE_CMD and len(frame.data) >= 2 and frame.data[1] == self.COUNTER_PAGE:
|
||||
return self._handle_counter_write(frame)
|
||||
return super()._handle_application(frame)
|
||||
|
||||
# ----- 16-bit one-way counter (page 0x29) -----
|
||||
|
||||
def _handle_counter_write(self, frame: RFFrame) -> RFFrame | None:
|
||||
"""WRITE to the counter page increments the 16-bit one-way counter."""
|
||||
if len(frame.data) < 6:
|
||||
return _nak(NAK_ARG)
|
||||
incr = frame.data[2] | (frame.data[3] << 8)
|
||||
self._counter = min(0xFFFF, self._counter + incr)
|
||||
self._set_page(self.COUNTER_PAGE,
|
||||
bytes([self._counter & 0xFF, (self._counter >> 8) & 0xFF, 0, 0]))
|
||||
return _ack()
|
||||
|
||||
# ----- 3DES AUTHENTICATE (0x1A / 0xAF) -----
|
||||
|
||||
def _handle_auth_part1(self, frame: RFFrame) -> RFFrame | None:
|
||||
"""AUTHENTICATE part 1: tag returns ek(RndB)."""
|
||||
try:
|
||||
from Crypto.Cipher import DES3
|
||||
except ImportError:
|
||||
return _nak(NAK_AUTH)
|
||||
self._rnd_b = os.urandom(8)
|
||||
cipher = DES3.new(self.key, DES3.MODE_CBC, iv=bytes(8))
|
||||
ek_rnd_b = cipher.encrypt(self._rnd_b)
|
||||
self._resp_iv = ek_rnd_b # IV that the reader will chain from
|
||||
self._auth_state = "WAIT_RESP"
|
||||
return RFFrame.from_bytes(bytes([AUTH_CONT]) + ek_rnd_b)
|
||||
|
||||
def _handle_auth_part2(self, frame: RFFrame) -> RFFrame | None:
|
||||
"""AUTHENTICATE part 2: verify reader token, return ek(RndA')."""
|
||||
from Crypto.Cipher import DES3
|
||||
token = bytes(frame.data[1:17])
|
||||
if len(token) != 16:
|
||||
self._auth_state = "NONE"
|
||||
return _nak(NAK_AUTH)
|
||||
cipher = DES3.new(self.key, DES3.MODE_CBC, iv=self._resp_iv)
|
||||
plain = cipher.decrypt(token)
|
||||
rnd_a = plain[0:8]
|
||||
rnd_b_prime = plain[8:16]
|
||||
if rnd_b_prime != _rotl1(self._rnd_b):
|
||||
self._auth_state = "NONE"
|
||||
return _nak(NAK_AUTH)
|
||||
# Success: return ek(RndA'), IV chains from last received cipher block.
|
||||
enc = DES3.new(self.key, DES3.MODE_CBC, iv=token[8:16])
|
||||
ek_rnd_a_prime = enc.encrypt(_rotl1(rnd_a))
|
||||
self._auth_state = "AUTHENTICATED"
|
||||
return RFFrame.from_bytes(bytes([0x00]) + ek_rnd_a_prime)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MIFARE Ultralight EV1 (MF0UL11 / MF0UL21)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class MifareUltralightEV1(NxpType2Tag):
|
||||
"""MIFARE Ultralight EV1 base — GET_VERSION, FAST_READ, PWD_AUTH, READ_SIG,
|
||||
plus three independent 24-bit one-way counters (READ_CNT / INCR_CNT /
|
||||
CHECK_TEARING_EVENT). Concrete ICs: MF0UL11, MF0UL21."""
|
||||
|
||||
_has_cc = False # page 3 is OTP; format for NDEF to add a CC
|
||||
_config_cls = UltralightEV1Config
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
password: bytes = b"\xFF\xFF\xFF\xFF", pack: bytes = b"\x00\x00",
|
||||
auth0: int = 0xFF, prot: bool = False,
|
||||
signature: bytes | None = None):
|
||||
if uid is None:
|
||||
uid = b"\x04" + os.urandom(6)
|
||||
self._counters = [0, 0, 0]
|
||||
self._tearing = [0xBD, 0xBD, 0xBD] # BD = no tearing event
|
||||
super().__init__(uid=uid, password=password, pack=pack, auth0=auth0,
|
||||
prot=prot, signature=signature, cc=None,
|
||||
ndef_message=ndef_message,
|
||||
total_pages=self._total_pages_cls,
|
||||
atqa=b"\x44\x00", sak=0x00)
|
||||
|
||||
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||
if frame.data:
|
||||
cmd = frame.data[0]
|
||||
if cmd == READ_CNT:
|
||||
return self._handle_read_cnt(frame)
|
||||
if cmd == INCR_CNT:
|
||||
return self._handle_incr_cnt(frame)
|
||||
if cmd == CHECK_TEARING:
|
||||
return self._handle_check_tearing(frame)
|
||||
return super()._handle_application(frame)
|
||||
|
||||
def _handle_read_cnt(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2 or frame.data[1] > 2:
|
||||
return _nak(NAK_ARG)
|
||||
return RFFrame.from_bytes(self._counters[frame.data[1]].to_bytes(3, "little"))
|
||||
|
||||
def _handle_incr_cnt(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 6 or frame.data[1] > 2:
|
||||
return _nak(NAK_ARG)
|
||||
n = frame.data[1]
|
||||
incr = frame.data[2] | (frame.data[3] << 8) | (frame.data[4] << 16)
|
||||
if self._counters[n] + incr > 0xFFFFFF:
|
||||
return _nak(NAK_AUTH) # counter overflow (NAK 0x4)
|
||||
self._counters[n] += incr
|
||||
return _ack()
|
||||
|
||||
def _handle_check_tearing(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2 or frame.data[1] > 2:
|
||||
return _nak(NAK_ARG)
|
||||
return RFFrame.from_bytes(bytes([self._tearing[frame.data[1]]]))
|
||||
|
||||
|
||||
class MF0UL11(MifareUltralightEV1):
|
||||
"""MIFARE Ultralight EV1 MF0UL11 — 48 bytes user (pages 04h-0Fh)."""
|
||||
_total_pages_cls = 20
|
||||
_version_bytes = b"\x00\x04\x03\x01\x01\x00\x0B\x03"
|
||||
_ndef_cc = b"\xE1\x10\x06\x00" # 48 bytes user
|
||||
_cfg0_page = 0x10
|
||||
_cfg1_page = 0x11
|
||||
_pwd_page = 0x12
|
||||
_pack_page = 0x13
|
||||
|
||||
|
||||
class MF0UL21(MifareUltralightEV1):
|
||||
"""MIFARE Ultralight EV1 MF0UL21 — 128 bytes user (pages 04h-23h)."""
|
||||
_total_pages_cls = 41
|
||||
_version_bytes = b"\x00\x04\x03\x01\x01\x00\x0E\x03"
|
||||
_ndef_cc = b"\xE1\x10\x10\x00" # 128 bytes user
|
||||
_cfg0_page = 0x25
|
||||
_cfg1_page = 0x26
|
||||
_pwd_page = 0x27
|
||||
_pack_page = 0x28
|
||||
1
pm3py/transponders/hf/iso14443a/st/__init__.py
Normal file
1
pm3py/transponders/hf/iso14443a/st/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""STMicroelectronics ISO 14443-A transponders (ST25TA Type 4, ST25TN Type 2)."""
|
||||
353
pm3py/transponders/hf/iso14443a/st/st25ta.py
Normal file
353
pm3py/transponders/hf/iso14443a/st/st25ta.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""ST25TA512B / ST25TA02KB / ST25TA16K / ST25TA64K — ST NFC Forum Type 4 tags.
|
||||
|
||||
Sourced from ST datasheets DS12365 Rev 2 (ST25TA512B/02KB) and DocID027786 Rev 4
|
||||
(ST25TA16K). ISO/IEC 14443 Type A, ISO-DEP, NDEF via ISO 7816 APDUs.
|
||||
|
||||
Three files: System (0xE101), CC (0xE103), NDEF (0x0001). Adds over the generic
|
||||
NfcType4Tag: the ST System file, the exact ST CC contents, 128-bit Read/Write
|
||||
password protection (VERIFY / Enable / Disable / Change Reference Data), the
|
||||
20-bit anti-tearing event counter (02KB family) and ExtendedReadBinary (CLA A2h).
|
||||
|
||||
ST25TA64K is not covered by a supplied datasheet; its constants below are
|
||||
extrapolated from the family (FLEN=0x2000) — flagged in the class docstring.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import struct
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from ..ndef import NfcType4Tag
|
||||
from ..base import State14443A
|
||||
|
||||
# File IDs
|
||||
SYSTEM_FILE_ID = b"\xE1\x01"
|
||||
CC_FILE_ID = b"\xE1\x03"
|
||||
NDEF_FILE_ID = b"\x00\x01"
|
||||
NDEF_AID = b"\xD2\x76\x00\x00\x85\x01\x01"
|
||||
|
||||
# ISO 7816 instructions
|
||||
INS_SELECT = 0xA4
|
||||
INS_READ_BINARY = 0xB0
|
||||
INS_UPDATE_BINARY = 0xD6
|
||||
INS_VERIFY = 0x20
|
||||
INS_CHANGE_REF_DATA = 0x24
|
||||
INS_DISABLE_VERIF = 0x26
|
||||
INS_ENABLE_VERIF = 0x28
|
||||
|
||||
CLA_STD = 0x00
|
||||
CLA_ST = 0xA2
|
||||
|
||||
# Status words
|
||||
SW_OK = b"\x90\x00"
|
||||
SW_PWD_REQUIRED = b"\x63\x00"
|
||||
SW_FILE_NOT_FOUND = b"\x6A\x82"
|
||||
SW_WRONG_P1P2 = b"\x6A\x86"
|
||||
SW_WRONG_LENGTH = b"\x67\x00"
|
||||
SW_SEC_NOT_SATISFIED = b"\x69\x82"
|
||||
SW_REF_NOT_USABLE = b"\x69\x84"
|
||||
SW_COND_NOT_SATISFIED = b"\x69\x85"
|
||||
SW_WRONG_LE_LC = b"\x6A\x80"
|
||||
|
||||
|
||||
class ST25TA(NfcType4Tag):
|
||||
"""Base for the ST25TA family. Concrete ICs set the class attributes."""
|
||||
|
||||
_cc_bytes: bytes = b"" # 15-byte CC file
|
||||
_max_ndef: int = 256 # NDEF file size (FLEN)
|
||||
_ic_ref: int = 0x00
|
||||
_product_code: int = 0x00 # UID byte1
|
||||
_ats_bytes: bytes = b"\x05\x75\x80\x60\x02"
|
||||
_has_counter: bool = False # 20-bit event counter (02KB family)
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
read_password: bytes | None = None,
|
||||
write_password: bytes | None = None):
|
||||
if uid is None:
|
||||
uid = bytes([0x02, self._product_code]) + os.urandom(5)
|
||||
# Event counter state (read by _build_files inside super().__init__).
|
||||
self._counter = 0
|
||||
self._counter_cfg = 0x00 # b1 enable, b0 source(0=read,1=write)
|
||||
self._counter_bumped = False
|
||||
super().__init__(uid=uid, ndef_message=ndef_message,
|
||||
max_ndef_size=self._max_ndef,
|
||||
atqa=b"\x44\x00", sak=0x20, ats=self._ats_bytes)
|
||||
# 16-byte passwords; None → protection disabled for that operation.
|
||||
self._read_password = read_password
|
||||
self._write_password = write_password
|
||||
self._read_verified = read_password is None
|
||||
self._write_verified = write_password is None
|
||||
self._read_retries = 3
|
||||
self._write_retries = 3
|
||||
|
||||
# ----- file contents -----
|
||||
|
||||
def _build_files(self) -> None:
|
||||
self._cc_file = self._cc_bytes
|
||||
self._update_ndef_file()
|
||||
self._system_file = self._build_system_file()
|
||||
|
||||
def _build_system_file(self) -> bytes:
|
||||
sf = bytearray(0x12)
|
||||
struct.pack_into(">H", sf, 0x00, 0x12) # length
|
||||
sf[0x02] = 0x80 # GPO / ST reserved
|
||||
if self._has_counter:
|
||||
sf[0x03] = self._counter_cfg
|
||||
sf[0x08:0x0F] = self._uid # 7-byte UID
|
||||
struct.pack_into(">H", sf, 0x0F, self._max_ndef - 1) # memory size - 1
|
||||
sf[0x11] = self._ic_ref
|
||||
return bytes(sf)
|
||||
|
||||
def _current_system_file(self) -> bytes:
|
||||
sf = bytearray(self._build_system_file())
|
||||
if self._has_counter:
|
||||
sf[0x04:0x07] = self._counter.to_bytes(3, "big")
|
||||
return bytes(sf)
|
||||
|
||||
# ----- ISO-DEP reset -----
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._app_selected = False
|
||||
self._selected_file = None
|
||||
self._read_verified = self._read_password is None
|
||||
self._write_verified = self._write_password is None
|
||||
self._read_retries = 3
|
||||
self._write_retries = 3
|
||||
self._counter_bumped = False
|
||||
|
||||
def _handle_rats(self, frame: RFFrame) -> RFFrame | None:
|
||||
# App/file selection state resets on a new activation.
|
||||
self._app_selected = False
|
||||
self._selected_file = None
|
||||
self._counter_bumped = False
|
||||
return super()._handle_rats(frame)
|
||||
|
||||
# ----- APDU dispatch -----
|
||||
|
||||
def _handle_apdu(self, apdu: bytes) -> bytes:
|
||||
if len(apdu) < 4: # need at least CLA INS P1 P2
|
||||
return SW_WRONG_LENGTH
|
||||
cla, ins = apdu[0], apdu[1]
|
||||
|
||||
if cla == CLA_ST and ins == INS_READ_BINARY:
|
||||
return self._handle_read_binary(apdu, extended=True)
|
||||
if cla != CLA_STD and cla != CLA_ST:
|
||||
return b"\x6E\x00" # class not supported
|
||||
if ins == INS_SELECT:
|
||||
return self._handle_select(apdu)
|
||||
if ins == INS_READ_BINARY:
|
||||
return self._handle_read_binary(apdu)
|
||||
if ins == INS_UPDATE_BINARY:
|
||||
return self._handle_update_binary(apdu)
|
||||
if ins == INS_VERIFY:
|
||||
return self._handle_verify(apdu)
|
||||
if ins == INS_ENABLE_VERIF:
|
||||
return self._handle_toggle_verif(apdu, enable=True)
|
||||
if ins == INS_DISABLE_VERIF:
|
||||
return self._handle_toggle_verif(apdu, enable=False)
|
||||
if ins == INS_CHANGE_REF_DATA:
|
||||
return self._handle_change_ref(apdu)
|
||||
return b"\x6D\x00" # INS not supported
|
||||
|
||||
def _handle_select(self, apdu: bytes) -> bytes:
|
||||
p1, p2 = apdu[2], apdu[3]
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc] if lc else b""
|
||||
|
||||
if p1 == 0x04: # select by AID
|
||||
if data == NDEF_AID:
|
||||
self._app_selected = True
|
||||
self._selected_file = None
|
||||
return SW_OK
|
||||
return SW_FILE_NOT_FOUND
|
||||
if p1 == 0x00 and p2 == 0x0C: # select by file id
|
||||
if not self._app_selected:
|
||||
return SW_FILE_NOT_FOUND
|
||||
if data in (CC_FILE_ID, NDEF_FILE_ID, SYSTEM_FILE_ID):
|
||||
self._selected_file = data
|
||||
return SW_OK
|
||||
return SW_FILE_NOT_FOUND
|
||||
return SW_WRONG_P1P2
|
||||
|
||||
def _selected_bytes(self) -> bytes | None:
|
||||
if self._selected_file == CC_FILE_ID:
|
||||
return self._cc_file
|
||||
if self._selected_file == NDEF_FILE_ID:
|
||||
return self._ndef_file
|
||||
if self._selected_file == SYSTEM_FILE_ID:
|
||||
return self._current_system_file()
|
||||
return None
|
||||
|
||||
def _handle_read_binary(self, apdu: bytes, extended: bool = False) -> bytes:
|
||||
content = self._selected_bytes()
|
||||
if content is None:
|
||||
return SW_FILE_NOT_FOUND
|
||||
if self._selected_file == NDEF_FILE_ID and not self._read_verified:
|
||||
return SW_SEC_NOT_SATISFIED
|
||||
offset = (apdu[2] << 8) | apdu[3]
|
||||
le = apdu[4] if len(apdu) > 4 else 0
|
||||
if offset >= len(content):
|
||||
return SW_WRONG_P1P2
|
||||
self._maybe_bump_counter(is_write=False)
|
||||
return content[offset:offset + le] + SW_OK
|
||||
|
||||
def _handle_update_binary(self, apdu: bytes) -> bytes:
|
||||
if self._selected_file != NDEF_FILE_ID:
|
||||
return SW_FILE_NOT_FOUND
|
||||
if not self._write_verified:
|
||||
return SW_SEC_NOT_SATISFIED
|
||||
offset = (apdu[2] << 8) | apdu[3]
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
if offset + len(data) > len(self._ndef_file):
|
||||
return SW_WRONG_LE_LC # write past end of file
|
||||
ndef = bytearray(self._ndef_file)
|
||||
ndef[offset:offset + len(data)] = data
|
||||
self._ndef_file = bytes(ndef)
|
||||
self._maybe_bump_counter(is_write=True)
|
||||
return SW_OK
|
||||
|
||||
# ----- password protection -----
|
||||
|
||||
def _pwd_for(self, p2: int):
|
||||
if p2 == 0x01:
|
||||
return "read"
|
||||
if p2 == 0x02:
|
||||
return "write"
|
||||
return None
|
||||
|
||||
def _handle_verify(self, apdu: bytes) -> bytes:
|
||||
p2 = apdu[3]
|
||||
which = self._pwd_for(p2)
|
||||
if which is None:
|
||||
return SW_WRONG_LE_LC
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
pwd = self._read_password if which == "read" else self._write_password
|
||||
verified_attr = "_read_verified" if which == "read" else "_write_verified"
|
||||
|
||||
if lc == 0: # query: is a password required?
|
||||
if pwd is None:
|
||||
return SW_OK
|
||||
return SW_OK if getattr(self, verified_attr) else SW_PWD_REQUIRED
|
||||
if lc != 0x10:
|
||||
return SW_WRONG_LE_LC
|
||||
if pwd is None:
|
||||
return SW_OK
|
||||
retries_attr = "_read_retries" if which == "read" else "_write_retries"
|
||||
if getattr(self, retries_attr) == 0:
|
||||
return SW_COND_NOT_SATISFIED
|
||||
supplied = apdu[5:5 + 0x10]
|
||||
if supplied == pwd:
|
||||
setattr(self, verified_attr, True)
|
||||
setattr(self, retries_attr, 3)
|
||||
return SW_OK
|
||||
n = getattr(self, retries_attr) - 1
|
||||
setattr(self, retries_attr, n)
|
||||
return bytes([0x63, 0xC0 | n])
|
||||
|
||||
def _handle_toggle_verif(self, apdu: bytes, enable: bool) -> bytes:
|
||||
which = self._pwd_for(apdu[3])
|
||||
if which is None:
|
||||
return SW_WRONG_LE_LC
|
||||
if enable:
|
||||
if which == "read":
|
||||
self._read_password = self._read_password or b"\x00" * 16
|
||||
self._read_verified = False
|
||||
else:
|
||||
self._write_password = self._write_password or b"\x00" * 16
|
||||
self._write_verified = False
|
||||
else:
|
||||
# disabling requires the write password to have been verified
|
||||
if not self._write_verified:
|
||||
return SW_SEC_NOT_SATISFIED
|
||||
if which == "read":
|
||||
self._read_password = None
|
||||
self._read_verified = True
|
||||
else:
|
||||
self._write_password = None
|
||||
self._write_verified = True
|
||||
return SW_OK
|
||||
|
||||
def _handle_change_ref(self, apdu: bytes) -> bytes:
|
||||
which = self._pwd_for(apdu[3])
|
||||
if which is None:
|
||||
return SW_WRONG_LE_LC
|
||||
if not self._write_verified:
|
||||
return SW_SEC_NOT_SATISFIED
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
if lc != 0x10:
|
||||
return SW_WRONG_LE_LC
|
||||
new_pwd = apdu[5:5 + 0x10]
|
||||
if which == "read":
|
||||
self._read_password = new_pwd
|
||||
else:
|
||||
self._write_password = new_pwd
|
||||
return SW_OK
|
||||
|
||||
# ----- event counter -----
|
||||
|
||||
def _maybe_bump_counter(self, is_write: bool) -> None:
|
||||
if not self._has_counter or not (self._counter_cfg & 0x02):
|
||||
return
|
||||
source_is_write = bool(self._counter_cfg & 0x01)
|
||||
if is_write != source_is_write:
|
||||
return
|
||||
if not self._counter_bumped:
|
||||
self._counter_bumped = True
|
||||
if self._counter < 0xFFFFF:
|
||||
self._counter += 1
|
||||
|
||||
@property
|
||||
def event_counter(self) -> int:
|
||||
return self._counter
|
||||
|
||||
def enable_counter(self, on_write: bool = False) -> None:
|
||||
"""Enable the 20-bit event counter (02KB family)."""
|
||||
self._counter_cfg = 0x02 | (0x01 if on_write else 0x00)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Concrete ICs
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ST25TA512B(ST25TA):
|
||||
"""ST25TA512B — 64-byte NDEF file."""
|
||||
_cc_bytes = bytes.fromhex("000F2000400036040600010040 0000".replace(" ", ""))
|
||||
_max_ndef = 0x40
|
||||
_ic_ref = 0xE5
|
||||
_product_code = 0xE4
|
||||
_ats_bytes = b"\x05\x75\x80\x60\x02"
|
||||
|
||||
|
||||
class ST25TA02KB(ST25TA):
|
||||
"""ST25TA02KB — 256-byte NDEF file, 20-bit event counter + TruST25."""
|
||||
_cc_bytes = bytes.fromhex("000F2000FF0036040600010100 0000".replace(" ", ""))
|
||||
_max_ndef = 0x100
|
||||
_ic_ref = 0xE2
|
||||
_product_code = 0xE3
|
||||
_ats_bytes = b"\x05\x75\x80\x60\x02"
|
||||
_has_counter = True
|
||||
|
||||
|
||||
class ST25TA16K(ST25TA):
|
||||
"""ST25TA16K — 2048-byte NDEF file."""
|
||||
_cc_bytes = bytes.fromhex("000F2000F600F6040600010800 0000".replace(" ", ""))
|
||||
_max_ndef = 0x800
|
||||
_ic_ref = 0xC5
|
||||
_product_code = 0xC5
|
||||
_ats_bytes = b"\x05\x78\x80\x90\x02"
|
||||
|
||||
|
||||
class ST25TA64K(ST25TA):
|
||||
"""ST25TA64K — 8192-byte NDEF file.
|
||||
|
||||
Not covered by a supplied datasheet; CC/memory-size constants are
|
||||
extrapolated from the ST25TA family (FLEN=0x2000). Verify against
|
||||
hardware before relying on the exact CC bytes.
|
||||
"""
|
||||
_cc_bytes = bytes.fromhex("000F2000F600F6040600012000 0000".replace(" ", ""))
|
||||
_max_ndef = 0x2000
|
||||
_ic_ref = 0xC5
|
||||
_product_code = 0xC5
|
||||
_ats_bytes = b"\x05\x78\x80\x90\x02"
|
||||
99
pm3py/transponders/hf/iso14443a/st/st25tn.py
Normal file
99
pm3py/transponders/hf/iso14443a/st/st25tn.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""ST25TN512 / ST25TN01K — STMicroelectronics NFC Forum Type 2 tags.
|
||||
|
||||
Sourced from ST datasheet DS13433 Rev 5. ISO/IEC 14443 Type A, 7-byte UID with
|
||||
ST manufacturer code 0x02. 64 blocks (256 bytes) of 4 bytes each.
|
||||
|
||||
Commands: READ (0x30), WRITE (0xA2) only — no GET_VERSION / FAST_READ / PWD_AUTH.
|
||||
Write protection is via OTP lock bits. The only password is the 32-bit Kill
|
||||
password (block 2Fh); a WRITE of that password to the kill keyhole (block 30h)
|
||||
permanently mutes the RF interface.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from ..nxp.type2 import NxpType2Tag, _ack, _nak, NAK_ARG, READ_CMD, WRITE_CMD
|
||||
|
||||
SYSBLOCK_PAGE = 0x02 # block 2, byte 1 holds the SYSBLOCK value
|
||||
PRODUCT_ID_PAGE = 0x2D
|
||||
ANDEF_CFG_PAGE = 0x2E
|
||||
KILL_PWD_PAGE = 0x2F
|
||||
KILL_KEYHOLE_PAGE = 0x30
|
||||
|
||||
SYSBLOCK_VALUE = 0x2C # points at the product-identification block
|
||||
|
||||
|
||||
class ST25TN(NxpType2Tag):
|
||||
"""Base for ST25TN. Concrete ICs set memory size / CC / product code."""
|
||||
|
||||
_version_bytes = None
|
||||
_has_fast_read = False
|
||||
_has_read_sig = False
|
||||
_has_pwd_auth = False
|
||||
_has_cc = True
|
||||
_product_code = b"\x00\x00" # LE product code in product-id block
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
kill_password: bytes = b"\x00\x00\x00\x00",
|
||||
kill_enabled: bool = True):
|
||||
if uid is None:
|
||||
uid = b"\x02" + os.urandom(6) # ST manufacturer code 0x02
|
||||
self._kill_armed = False
|
||||
self._killed = False
|
||||
self._kill_enabled = kill_enabled
|
||||
super().__init__(uid=uid, cc=self._cc_default, ndef_message=ndef_message,
|
||||
total_pages=64, atqa=b"\x44\x00", sak=0x00)
|
||||
# System blocks: SYSBLOCK pointer, product identification, kill password.
|
||||
p2 = bytearray(self._get_page(SYSBLOCK_PAGE))
|
||||
p2[1] = SYSBLOCK_VALUE
|
||||
self._set_page(SYSBLOCK_PAGE, bytes(p2))
|
||||
self._set_page(PRODUCT_ID_PAGE, self._product_code + b"\x13\x05") # PC, REV, KID
|
||||
self._set_page(KILL_PWD_PAGE, kill_password)
|
||||
|
||||
@property
|
||||
def killed(self) -> bool:
|
||||
return self._killed
|
||||
|
||||
def _is_config_page(self, page: int) -> bool:
|
||||
# KILL_PWD (2Fh) and kill keyhole (30h) always read back as 00h.
|
||||
return page in (KILL_PWD_PAGE, KILL_KEYHOLE_PAGE)
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
if self._kill_armed:
|
||||
self._killed = True
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
if self._killed:
|
||||
return None # KILLED state: mute forever
|
||||
return await super().handle_frame(frame)
|
||||
|
||||
def _handle_write(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 6:
|
||||
return _nak(NAK_ARG)
|
||||
page = frame.data[1]
|
||||
# Kill keyhole: WRITE the kill password here to permanently kill.
|
||||
if page == KILL_KEYHOLE_PAGE:
|
||||
if not self._kill_enabled:
|
||||
return _nak(NAK_ARG)
|
||||
if bytes(frame.data[2:6]) == self._get_page(KILL_PWD_PAGE):
|
||||
self._kill_armed = True
|
||||
return _ack()
|
||||
return _nak(NAK_ARG)
|
||||
# Product identification block is read-only.
|
||||
if page == PRODUCT_ID_PAGE:
|
||||
return _nak(NAK_ARG)
|
||||
return super()._handle_write(frame)
|
||||
|
||||
|
||||
class ST25TN512(ST25TN):
|
||||
"""ST25TN512 — 64 bytes user (blocks 04h-13h)."""
|
||||
_cc_default = b"\xE1\x10\x08\x00"
|
||||
_product_code = b"\x91\x90" # PC = 0x9091, LE on wire
|
||||
|
||||
|
||||
class ST25TN01K(ST25TN):
|
||||
"""ST25TN01K — 160 bytes user (blocks 04h-2Bh)."""
|
||||
_cc_default = b"\xE1\x10\x14\x00"
|
||||
_product_code = b"\x90\x90" # PC = 0x9090
|
||||
1
pm3py/transponders/hf/iso14443b/__init__.py
Normal file
1
pm3py/transponders/hf/iso14443b/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""ISO 14443 Type B transponders (ST25TB / SRIX family)."""
|
||||
270
pm3py/transponders/hf/iso14443b/base.py
Normal file
270
pm3py/transponders/hf/iso14443b/base.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""ISO 14443 Type B transponder base — SRIX / ST25TB slot-marker family.
|
||||
|
||||
This family does NOT use the standard ISO 14443-3 Type B REQB/ATQB/ATTRIB flow.
|
||||
It uses the simplified SRIX anticollision built from Initiate / Pcall16 /
|
||||
Slot_marker with an 8-bit Chip_ID, plus Select(Chip_ID) / Get_UID / Read_block /
|
||||
Write_block / Completion / Reset_to_inventory. RF framing (SOF/EOF/CRC_B) is
|
||||
handled at the wire layer; this model works on the command payload like the
|
||||
14443-A and 15693 models.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from enum import IntEnum
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.transponder import Transponder
|
||||
from pm3py.trace.decode_iso14b import decode_14443b
|
||||
|
||||
# Command opcodes
|
||||
CMD_INITIATE = 0x06 # 06 00
|
||||
CMD_PCALL16 = 0x06 # 06 04 (same first byte)
|
||||
CMD_READ_BLOCK = 0x08
|
||||
CMD_WRITE_BLOCK = 0x09
|
||||
CMD_GET_UID = 0x0B
|
||||
CMD_RESET_TO_INVENTORY = 0x0C
|
||||
CMD_SELECT = 0x0E
|
||||
CMD_COMPLETION = 0x0F
|
||||
|
||||
|
||||
class StateB(IntEnum):
|
||||
POWER_OFF = 0
|
||||
READY = 1
|
||||
INVENTORY = 2
|
||||
SELECTED = 3
|
||||
DEACTIVATED = 4
|
||||
|
||||
|
||||
class Tag14443B(Transponder):
|
||||
"""SRIX-style ISO 14443 Type B tag: anticollision + block memory."""
|
||||
|
||||
def __init__(self, uid: bytes):
|
||||
super().__init__()
|
||||
if len(uid) != 8:
|
||||
raise ValueError(f"Type B UID must be 8 bytes, got {len(uid)}")
|
||||
self._uid = uid # MSB-first (uid[0]=D0h, uid[7]=LS byte)
|
||||
self._state = StateB.POWER_OFF
|
||||
self._chip_id = 0x00
|
||||
self._slot = 0 # slot drawn on last Pcall16
|
||||
|
||||
def decode_trace(self, direction: int, payload: bytes) -> str | None:
|
||||
return decode_14443b(direction, payload)
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
return self._state.name
|
||||
|
||||
@property
|
||||
def chip_id(self) -> int:
|
||||
return self._chip_id
|
||||
|
||||
async def power_on(self) -> None:
|
||||
self._state = StateB.READY
|
||||
self._chip_id = os.urandom(1)[0]
|
||||
|
||||
async def power_off(self) -> None:
|
||||
self._state = StateB.POWER_OFF
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
data = frame.data
|
||||
if not data:
|
||||
return None
|
||||
b0 = data[0]
|
||||
|
||||
# Anticollision opcode 0x06 carries a sub-command byte.
|
||||
if b0 == 0x06 and len(data) >= 2:
|
||||
if data[1] == 0x00:
|
||||
return self._handle_initiate()
|
||||
if data[1] == 0x04:
|
||||
return self._handle_pcall16()
|
||||
return None
|
||||
|
||||
# Slot marker: low nibble 6, high nibble = slot number (1..15).
|
||||
if (b0 & 0x0F) == 0x06 and (b0 >> 4) != 0:
|
||||
return self._handle_slot_marker(b0 >> 4)
|
||||
|
||||
if b0 == CMD_SELECT and len(data) >= 2:
|
||||
return self._handle_select(data[1])
|
||||
if b0 == CMD_COMPLETION:
|
||||
return self._handle_completion()
|
||||
if b0 == CMD_RESET_TO_INVENTORY:
|
||||
return self._handle_reset_to_inventory()
|
||||
|
||||
# Memory commands work only in Selected state.
|
||||
if self._state != StateB.SELECTED:
|
||||
return None
|
||||
if b0 == CMD_GET_UID:
|
||||
return RFFrame.from_bytes(self._uid[::-1])
|
||||
if b0 == CMD_READ_BLOCK and len(data) >= 2:
|
||||
return self._handle_read_block(data[1])
|
||||
if b0 == CMD_WRITE_BLOCK and len(data) >= 6:
|
||||
self._handle_write_block(data[1], bytes(data[2:6]))
|
||||
return None # Write_block has no response
|
||||
return None
|
||||
|
||||
# ----- anticollision -----
|
||||
|
||||
def _handle_initiate(self) -> RFFrame | None:
|
||||
if self._state in (StateB.READY, StateB.INVENTORY):
|
||||
self._state = StateB.INVENTORY
|
||||
self._chip_id = os.urandom(1)[0]
|
||||
return RFFrame.from_bytes(bytes([self._chip_id]))
|
||||
return None
|
||||
|
||||
def _handle_pcall16(self) -> RFFrame | None:
|
||||
if self._state != StateB.INVENTORY:
|
||||
return None
|
||||
self._slot = os.urandom(1)[0] & 0x0F
|
||||
if self._slot == 0:
|
||||
return RFFrame.from_bytes(bytes([self._chip_id]))
|
||||
return None
|
||||
|
||||
def _handle_slot_marker(self, slot: int) -> RFFrame | None:
|
||||
if self._state != StateB.INVENTORY:
|
||||
return None
|
||||
if slot == self._slot:
|
||||
return RFFrame.from_bytes(bytes([self._chip_id]))
|
||||
return None
|
||||
|
||||
def _handle_select(self, chip_id: int) -> RFFrame | None:
|
||||
if chip_id == self._chip_id:
|
||||
self._state = StateB.SELECTED
|
||||
return RFFrame.from_bytes(bytes([self._chip_id]))
|
||||
if self._state == StateB.SELECTED:
|
||||
self._state = StateB.INVENTORY # deselected
|
||||
return None
|
||||
|
||||
def _handle_completion(self) -> RFFrame | None:
|
||||
if self._state == StateB.SELECTED:
|
||||
self._state = StateB.DEACTIVATED
|
||||
return None
|
||||
|
||||
def _handle_reset_to_inventory(self) -> RFFrame | None:
|
||||
if self._state == StateB.SELECTED:
|
||||
self._state = StateB.INVENTORY
|
||||
return None
|
||||
|
||||
# ----- memory (override in subclasses) -----
|
||||
|
||||
def _handle_read_block(self, addr: int) -> RFFrame | None:
|
||||
return None
|
||||
|
||||
def _handle_write_block(self, addr: int, data: bytes) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Standard ISO 14443-3/4 Type B (REQB / ATQB / ATTRIB / ISO-DEP)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
APF = 0x05 # REQB/WUPB/Slot-MARKER anticollision prefix
|
||||
ATTRIB_CMD = 0x1D
|
||||
HLTB_CMD = 0x50
|
||||
|
||||
|
||||
class StateB4(IntEnum):
|
||||
IDLE = 0
|
||||
READY = 1 # answered REQB/WUPB (ATQB sent)
|
||||
ACTIVE = 2 # ATTRIB done, ISO-DEP running
|
||||
HALT = 3
|
||||
|
||||
|
||||
class Tag14443B_4(Transponder):
|
||||
"""Standard ISO 14443 Type B tag with ISO-DEP (ISO 14443-4).
|
||||
|
||||
Implements the REQB/WUPB -> ATQB, Slot-MARKER, ATTRIB -> answer flow and
|
||||
an I-block APDU transport, with a ``_handle_apdu`` hook for the higher
|
||||
layer (e.g. NFC Forum Type 4 NDEF). Used by the TI RF430CL330H.
|
||||
"""
|
||||
|
||||
def __init__(self, pupi: bytes | None = None, afi: int = 0x00,
|
||||
app_data: bytes = b"\x00\x00\x00\x00",
|
||||
fsci: int = 0x08, fwi: int = 0x04):
|
||||
super().__init__()
|
||||
self._pupi = pupi if pupi is not None else os.urandom(4)
|
||||
self._afi = afi
|
||||
self._app_data = app_data
|
||||
self._fsci = fsci & 0x0F
|
||||
self._fwi = fwi & 0x0F
|
||||
self._state = StateB4.IDLE
|
||||
|
||||
def decode_trace(self, direction: int, payload: bytes) -> str | None:
|
||||
return decode_14443b(direction, payload)
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
return self._state.name
|
||||
|
||||
@property
|
||||
def pupi(self) -> bytes:
|
||||
return self._pupi
|
||||
|
||||
async def power_on(self) -> None:
|
||||
self._state = StateB4.IDLE
|
||||
|
||||
async def power_off(self) -> None:
|
||||
self._state = StateB4.IDLE
|
||||
|
||||
def _atqb(self) -> RFFrame:
|
||||
# 50 | PUPI(4) | AppData(4) | ProtInfo(3)
|
||||
prot_info = bytes([
|
||||
0x00, # bit-rate capability: 106 kbps only (default)
|
||||
(self._fsci << 4) | 0x01, # max frame size | protocol type (ISO 14443-4)
|
||||
(self._fwi << 4), # FWI | ADC(00) | FO(00)
|
||||
])
|
||||
return RFFrame.from_bytes(bytes([0x50]) + self._pupi + self._app_data + prot_info)
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
data = frame.data
|
||||
if not data:
|
||||
return None
|
||||
b0 = data[0]
|
||||
|
||||
# REQB / WUPB / Slot-MARKER (all start with APf=0x05)
|
||||
if b0 == APF and len(data) >= 3:
|
||||
afi = data[1]
|
||||
param = data[2]
|
||||
is_wupb = bool(param & 0x08)
|
||||
if self._state == StateB4.HALT and not is_wupb:
|
||||
return None # halted tags answer only WUPB
|
||||
if afi != 0x00 and afi != self._afi:
|
||||
return None # AFI filter
|
||||
self._state = StateB4.READY
|
||||
return self._atqb()
|
||||
|
||||
# Slot-MARKER: APn = (slot<<4) | 0x05
|
||||
if (b0 & 0x0F) == APF and (b0 >> 4) != 0:
|
||||
if self._state == StateB4.READY:
|
||||
return self._atqb()
|
||||
return None
|
||||
|
||||
if b0 == ATTRIB_CMD and len(data) >= 5:
|
||||
if bytes(data[1:5]) != self._pupi:
|
||||
return None
|
||||
self._state = StateB4.ACTIVE
|
||||
cid = data[8] & 0x0F if len(data) > 8 else 0
|
||||
return RFFrame.from_bytes(bytes([cid])) # Answer-to-ATTRIB: MBLI|CID
|
||||
|
||||
if b0 == HLTB_CMD and len(data) >= 5:
|
||||
if bytes(data[1:5]) == self._pupi:
|
||||
self._state = StateB4.HALT
|
||||
return RFFrame.from_bytes(b"\x00")
|
||||
return None
|
||||
|
||||
# ISO-DEP I-block in Active state
|
||||
if self._state == StateB4.ACTIVE:
|
||||
return self._handle_layer4(frame)
|
||||
return None
|
||||
|
||||
def _handle_layer4(self, frame: RFFrame) -> RFFrame | None:
|
||||
data = frame.data
|
||||
pcb = data[0]
|
||||
if pcb & 0xC0 == 0x00: # I-block
|
||||
resp = self._handle_apdu(bytes(data[1:]))
|
||||
resp_pcb = 0x02 | (pcb & 0x01)
|
||||
return RFFrame.from_bytes(bytes([resp_pcb]) + resp)
|
||||
return None
|
||||
|
||||
def _handle_apdu(self, apdu: bytes) -> bytes:
|
||||
"""Override in subclasses. Default: 6A82 (file not found)."""
|
||||
return b"\x6A\x82"
|
||||
1
pm3py/transponders/hf/iso14443b/st/__init__.py
Normal file
1
pm3py/transponders/hf/iso14443b/st/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""STMicroelectronics ISO 14443 Type B transponders (ST25TB / SRIX)."""
|
||||
118
pm3py/transponders/hf/iso14443b/st/st25tb.py
Normal file
118
pm3py/transponders/hf/iso14443b/st/st25tb.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""ST25TB512-AC / ST25TB512-AT / ST25TB02K / ST25TB04K — ST ISO 14443 Type B.
|
||||
|
||||
Sourced from ST datasheets DS11469/DS11364/DS11495/DS11456. 32-bit blocks,
|
||||
64-bit UID (prefix D0 02 <product-code>), two decrement-only count-down counters
|
||||
at blocks 5 and 6, a resettable OTP area (blocks 0-4, except the -AT variant),
|
||||
and the OTP_Lock_Reg system block at address 255.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from ..base import Tag14443B
|
||||
|
||||
SYSTEM_BLOCK = 0xFF
|
||||
COUNTER_BLOCKS = (5, 6)
|
||||
OTP_BLOCKS = range(0, 5) # blocks 0-4
|
||||
|
||||
|
||||
class ST25TB(Tag14443B):
|
||||
"""Base for the ST25TB family. Concrete ICs set block count / product code."""
|
||||
|
||||
_num_blocks: int = 16
|
||||
_product_code: int = 0x1B
|
||||
_resettable_otp: bool = True # False for -AT (blocks 0-4 are plain EEPROM)
|
||||
|
||||
def __init__(self, uid: bytes | None = None):
|
||||
if uid is None:
|
||||
uid = bytes([0xD0, 0x02, self._product_code]) + os.urandom(5)
|
||||
super().__init__(uid=uid)
|
||||
self._memory = bytearray(self._num_blocks * 4)
|
||||
self._system = bytearray(4) # block 255: OTP_Lock_Reg + reserved
|
||||
# Resettable OTP area ships erased to all-1s (bits then only clear).
|
||||
if self._resettable_otp:
|
||||
for blk in OTP_BLOCKS:
|
||||
self._set_block(blk, b"\xFF\xFF\xFF\xFF")
|
||||
# Count-down counters factory values.
|
||||
self._set_block(5, (0xFFFFFFFE).to_bytes(4, "little"))
|
||||
self._set_block(6, (0xFFFFFFFF).to_bytes(4, "little"))
|
||||
|
||||
# ----- block helpers -----
|
||||
|
||||
def _get_block(self, addr: int) -> bytes:
|
||||
if addr == SYSTEM_BLOCK:
|
||||
return bytes(self._system)
|
||||
off = addr * 4
|
||||
return bytes(self._memory[off:off + 4])
|
||||
|
||||
def _set_block(self, addr: int, data: bytes) -> None:
|
||||
if addr == SYSTEM_BLOCK:
|
||||
self._system[:] = data[:4]
|
||||
return
|
||||
off = addr * 4
|
||||
self._memory[off:off + 4] = data[:4]
|
||||
|
||||
def _valid_addr(self, addr: int) -> bool:
|
||||
return addr < self._num_blocks or addr == SYSTEM_BLOCK
|
||||
|
||||
# ----- read / write -----
|
||||
|
||||
def _handle_read_block(self, addr: int) -> RFFrame | None:
|
||||
if not self._valid_addr(addr):
|
||||
return None
|
||||
return RFFrame.from_bytes(self._get_block(addr))
|
||||
|
||||
def _handle_write_block(self, addr: int, data: bytes) -> None:
|
||||
if not self._valid_addr(addr):
|
||||
return
|
||||
new_val = int.from_bytes(data, "little")
|
||||
|
||||
if addr in COUNTER_BLOCKS:
|
||||
# Decrement-only: accept only a strictly lower value.
|
||||
cur = int.from_bytes(self._get_block(addr), "little")
|
||||
if new_val < cur:
|
||||
self._set_block(addr, new_val.to_bytes(4, "little"))
|
||||
return
|
||||
|
||||
if self._resettable_otp and addr in OTP_BLOCKS:
|
||||
# OTP: no auto-erase, bits can only clear 1 -> 0.
|
||||
cur = int.from_bytes(self._get_block(addr), "little")
|
||||
self._set_block(addr, (cur & new_val).to_bytes(4, "little"))
|
||||
return
|
||||
|
||||
if addr == SYSTEM_BLOCK:
|
||||
# OTP_Lock_Reg: bit-clear only.
|
||||
cur = int.from_bytes(self._get_block(addr), "little")
|
||||
self._set_block(addr, (cur & new_val).to_bytes(4, "little"))
|
||||
return
|
||||
|
||||
self._set_block(addr, data)
|
||||
|
||||
|
||||
class ST25TB512AC(ST25TB):
|
||||
"""ST25TB512-AC — 16 blocks, resettable OTP area."""
|
||||
_num_blocks = 16
|
||||
_product_code = 0x1B
|
||||
_resettable_otp = True
|
||||
|
||||
|
||||
class ST25TB512AT(ST25TB):
|
||||
"""ST25TB512-AT — 16 blocks, blocks 0-4 are plain EEPROM (no OTP reset)."""
|
||||
_num_blocks = 16
|
||||
_product_code = 0x33
|
||||
_resettable_otp = False
|
||||
|
||||
|
||||
class ST25TB02K(ST25TB):
|
||||
"""ST25TB02K — 64 blocks (2 Kbit)."""
|
||||
_num_blocks = 64
|
||||
_product_code = 0x3F
|
||||
_resettable_otp = True
|
||||
|
||||
|
||||
class ST25TB04K(ST25TB):
|
||||
"""ST25TB04K — 128 blocks (4 Kbit)."""
|
||||
_num_blocks = 128
|
||||
_product_code = 0x1F
|
||||
_resettable_otp = True
|
||||
1
pm3py/transponders/hf/iso14443b/ti/__init__.py
Normal file
1
pm3py/transponders/hf/iso14443b/ti/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Texas Instruments ISO 14443 Type B transponders (RF430CL330H NFC Type 4B)."""
|
||||
152
pm3py/transponders/hf/iso14443b/ti/rf430cl330h.py
Normal file
152
pm3py/transponders/hf/iso14443b/ti/rf430cl330h.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""RF430CL330H — Texas Instruments Dynamic NFC Interface Transponder.
|
||||
|
||||
Sourced from TI SLAS916C. NFC Forum Type 4 tag over ISO/IEC 14443 Type B
|
||||
(standard REQB/ATQB/ATTRIB + ISO-DEP), backed by 3 KB of volatile SRAM that a
|
||||
host controller fills over SPI/I2C. This models the RF/NFC side: Type B
|
||||
activation (via Tag14443B_4) plus the ISO 7816 Type 4 NDEF file system.
|
||||
|
||||
The datasheet defers the low-level ISO 14443-3 Type B byte layouts to the ISO
|
||||
standard; the CC contents (MLe=00F9, MLc=00F6), the AID, and the APDU flow are
|
||||
taken from SLAS916C §5.8-5.9. The NDEF file ID is host-programmable; this model
|
||||
defaults it to the NFC Forum well-known E104 for phone compatibility.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from ..base import Tag14443B_4
|
||||
|
||||
NDEF_AID = b"\xD2\x76\x00\x00\x85\x01\x01"
|
||||
CC_FILE_ID = b"\xE1\x03"
|
||||
NDEF_FILE_ID = b"\xE1\x04"
|
||||
|
||||
INS_SELECT = 0xA4
|
||||
INS_READ_BINARY = 0xB0
|
||||
INS_UPDATE_BINARY = 0xD6
|
||||
|
||||
SW_OK = b"\x90\x00"
|
||||
SW_FILE_NOT_FOUND = b"\x6A\x82"
|
||||
SW_WRONG_P1P2 = b"\x6A\x86"
|
||||
SW_WRONG_LENGTH = b"\x67\x00"
|
||||
SW_SEC_NOT_SATISFIED = b"\x69\x82"
|
||||
|
||||
|
||||
class RF430CL330H(Tag14443B_4):
|
||||
"""TI RF430CL330H NFC Type 4 dynamic tag (ISO 14443 Type B)."""
|
||||
|
||||
def __init__(self, ndef_message: bytes = b"", max_ndef_size: int = 2048,
|
||||
pupi: bytes | None = None, ndef_file_id: bytes = NDEF_FILE_ID):
|
||||
# ISO-DEP: FSCI=8 (256-byte frame), FWI=4. AppData=0.
|
||||
super().__init__(pupi=pupi, afi=0x00, fsci=0x08, fwi=0x04)
|
||||
self._max_ndef = max_ndef_size
|
||||
self._ndef_file_id = ndef_file_id
|
||||
self._ndef_message = ndef_message
|
||||
self._app_selected = False
|
||||
self._selected_file: bytes | None = None
|
||||
self._build_files()
|
||||
|
||||
def _build_files(self) -> None:
|
||||
# CC file (15 bytes) — MLe=00F9, MLc=00F6 per SLAS916C.
|
||||
self._cc_file = (
|
||||
struct.pack(">H", 0x000F) # CCLEN
|
||||
+ b"\x20" # mapping version 2.0
|
||||
+ struct.pack(">H", 0x00F9) # MLe (max R-APDU)
|
||||
+ struct.pack(">H", 0x00F6) # MLc (max C-APDU)
|
||||
+ b"\x04\x06" # NDEF file control TLV: T=04, L=06
|
||||
+ self._ndef_file_id # NDEF file id
|
||||
+ struct.pack(">H", self._max_ndef) # max NDEF file size
|
||||
+ b"\x00" # read access: granted
|
||||
+ b"\x00" # write access: granted
|
||||
)
|
||||
self._update_ndef_file()
|
||||
|
||||
def _update_ndef_file(self) -> None:
|
||||
nlen = len(self._ndef_message)
|
||||
self._ndef_file = struct.pack(">H", nlen) + self._ndef_message
|
||||
if len(self._ndef_file) < self._max_ndef:
|
||||
self._ndef_file += b"\x00" * (self._max_ndef - len(self._ndef_file))
|
||||
|
||||
def set_ndef(self, message: bytes) -> None:
|
||||
"""Set the NDEF message (takes effect immediately, no sync needed)."""
|
||||
if len(message) > self._max_ndef - 2:
|
||||
raise ValueError(
|
||||
f"NDEF message too large: {len(message)} bytes, "
|
||||
f"max is {self._max_ndef - 2} bytes")
|
||||
self._ndef_message = message
|
||||
self._update_ndef_file()
|
||||
|
||||
def clear_ndef(self) -> None:
|
||||
self._ndef_message = b""
|
||||
self._update_ndef_file()
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._app_selected = False
|
||||
self._selected_file = None
|
||||
|
||||
# ----- ISO 7816 Type 4 APDU handling -----
|
||||
|
||||
def _handle_apdu(self, apdu: bytes) -> bytes:
|
||||
if len(apdu) < 4: # need at least CLA INS P1 P2
|
||||
return SW_WRONG_LENGTH
|
||||
ins = apdu[1]
|
||||
if ins == INS_SELECT:
|
||||
return self._handle_select(apdu)
|
||||
if ins == INS_READ_BINARY:
|
||||
return self._handle_read_binary(apdu)
|
||||
if ins == INS_UPDATE_BINARY:
|
||||
return self._handle_update_binary(apdu)
|
||||
return b"\x6D\x00" # INS not supported
|
||||
|
||||
def _handle_select(self, apdu: bytes) -> bytes:
|
||||
p1, p2 = apdu[2], apdu[3]
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc] if lc else b""
|
||||
if p1 == 0x04: # select by AID
|
||||
if data == NDEF_AID:
|
||||
self._app_selected = True
|
||||
self._selected_file = None
|
||||
return SW_OK
|
||||
return SW_FILE_NOT_FOUND
|
||||
if p1 == 0x00 and p2 == 0x0C: # select by file id
|
||||
if not self._app_selected:
|
||||
return SW_FILE_NOT_FOUND
|
||||
if data in (CC_FILE_ID, self._ndef_file_id):
|
||||
self._selected_file = data
|
||||
return SW_OK
|
||||
return SW_FILE_NOT_FOUND
|
||||
return SW_WRONG_P1P2
|
||||
|
||||
def _selected_bytes(self) -> bytes | None:
|
||||
if self._selected_file == CC_FILE_ID:
|
||||
return self._cc_file
|
||||
if self._selected_file == self._ndef_file_id:
|
||||
return self._ndef_file
|
||||
return None
|
||||
|
||||
def _handle_read_binary(self, apdu: bytes) -> bytes:
|
||||
content = self._selected_bytes()
|
||||
if content is None:
|
||||
return SW_FILE_NOT_FOUND
|
||||
offset = (apdu[2] << 8) | apdu[3]
|
||||
le = apdu[4] if len(apdu) > 4 else 0
|
||||
if offset >= len(content):
|
||||
return SW_WRONG_P1P2
|
||||
return content[offset:offset + le] + SW_OK
|
||||
|
||||
def _handle_update_binary(self, apdu: bytes) -> bytes:
|
||||
if self._selected_file != self._ndef_file_id:
|
||||
return SW_FILE_NOT_FOUND
|
||||
offset = (apdu[2] << 8) | apdu[3]
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
if offset + len(data) > len(self._ndef_file):
|
||||
return SW_WRONG_P1P2 # write past end of file
|
||||
ndef = bytearray(self._ndef_file)
|
||||
ndef[offset:offset + len(data)] = data
|
||||
self._ndef_file = bytes(ndef)
|
||||
# keep _ndef_message in sync with NLEN
|
||||
nlen = (self._ndef_file[0] << 8) | self._ndef_file[1]
|
||||
self._ndef_message = self._ndef_file[2:2 + nlen]
|
||||
return SW_OK
|
||||
1
pm3py/transponders/hf/iso15693/infineon/__init__.py
Normal file
1
pm3py/transponders/hf/iso15693/infineon/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Infineon ISO 15693 transponders (my-d vicinity SRF55VxxP)."""
|
||||
150
pm3py/transponders/hf/iso15693/infineon/myd_vicinity.py
Normal file
150
pm3py/transponders/hf/iso15693/infineon/myd_vicinity.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Infineon my-d vicinity — SRF55V02P / SRF55V10P (ISO 15693 / ISO 18000-3 M1).
|
||||
|
||||
Sourced from the Infineon my-d vicinity plain Extended Datasheet Rev 4.0.
|
||||
8-byte UID with Infineon manufacturer code 0x05 (prefix E0 05 <chip-id>).
|
||||
|
||||
Notable differences from the NXP/ST 15693 parts:
|
||||
- **No GET SYSTEM INFO (0x2B)** — identification is via the Chip-ID byte (UID
|
||||
byte 5). A reader must not rely on 0x2B.
|
||||
- Two memory views: ISO block mode (4-byte blocks) and a custom page mode
|
||||
(8-byte pages) reached through the single custom opcode 0xA0. Plain variants
|
||||
have no password/authentication (open access).
|
||||
- Any user page can be a decrement-only value counter (custom Restricted Write).
|
||||
This model implements the ISO block interface plus the custom Read/Write page
|
||||
commands; the secure (SRF55VxxS) authentication protocol is not modelled.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from ..base import Tag15693, FLAG_ADDRESS, FLAG_INVENTORY
|
||||
|
||||
INFINEON_MFG = 0x05
|
||||
|
||||
# Custom sub-commands (inside the 0xA0 custom frame)
|
||||
SUB_READ = 0x10 # read one 8-byte page
|
||||
SUB_WRITE = 0x30 # write one 8-byte page
|
||||
SUB_WRITE_BYTE = 0x90 # write one byte (admin bytes included)
|
||||
SUB_WRITE_REREAD = 0xB0 # write then return the page
|
||||
SUB_RESTRICTED_WRITE = 0x00 # value-counter decrement
|
||||
SUB_RESTRICTED_WRITE_REREAD = 0x80
|
||||
|
||||
|
||||
class MydVicinity(Tag15693):
|
||||
"""Infineon my-d vicinity plain. Concrete ICs set the memory size."""
|
||||
|
||||
_uid_prefix = b"\xE0\x05" # E0 + Infineon mfg 0x05; chip-id appended
|
||||
_chip_id: int = 0x40 # UID byte 5 (also encodes EEPROM size)
|
||||
|
||||
def __init__(self, uid: bytes | str | None = None, dsfid: int = 0, afi: int = 0):
|
||||
super().__init__(uid=uid, dsfid=dsfid, afi=afi,
|
||||
block_size=4, num_blocks=self._num_blocks_cls,
|
||||
ic_reference=0x00)
|
||||
# Page-mode view: 8-byte data pages (custom addressing). Physical
|
||||
# memory is shared with the block view (block N ↔ its bytes).
|
||||
self._page_size = 8
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2:
|
||||
return None
|
||||
cmd = frame.data[1]
|
||||
if cmd == 0x2B:
|
||||
# my-d vicinity does not implement Get System Info.
|
||||
return self._make_error(0x01)
|
||||
if cmd == 0xA0:
|
||||
return self._handle_custom(frame)
|
||||
return await super().handle_frame(frame)
|
||||
|
||||
# ----- custom page commands (opcode 0xA0, mfg 0x05) -----
|
||||
|
||||
def _handle_custom(self, frame: RFFrame) -> RFFrame | None:
|
||||
data = frame.data
|
||||
flags = data[0]
|
||||
if len(data) < 3 or data[2] != INFINEON_MFG:
|
||||
return None
|
||||
addressed = bool(flags & FLAG_ADDRESS) and not (flags & FLAG_INVENTORY)
|
||||
# flags, cmd(A0), mfg [+uid], then <sub-cmd, addr0(page), addr1(00), data...>
|
||||
off = 3 + (8 if addressed else 0)
|
||||
if addressed and (len(data) < 11 or bytes(reversed(data[3:11])) != self._uid):
|
||||
return None
|
||||
if len(data) < off + 3:
|
||||
return self._make_error(0x0F)
|
||||
sub = data[off]
|
||||
page = data[off + 1]
|
||||
params = data[off + 3:]
|
||||
|
||||
if sub == SUB_READ:
|
||||
return self._read_page(page)
|
||||
if sub == SUB_WRITE:
|
||||
return self._write_page(page, params, reread=False)
|
||||
if sub == SUB_WRITE_REREAD:
|
||||
return self._write_page(page, params, reread=True)
|
||||
if sub == SUB_WRITE_BYTE:
|
||||
return self._write_byte(page, params)
|
||||
if sub in (SUB_RESTRICTED_WRITE, SUB_RESTRICTED_WRITE_REREAD):
|
||||
return self._restricted_write(page, params,
|
||||
reread=(sub == SUB_RESTRICTED_WRITE_REREAD))
|
||||
return self._make_error(0x01)
|
||||
|
||||
def _page_offset(self, page: int) -> int:
|
||||
return page * self._page_size
|
||||
|
||||
def _num_pages(self) -> int:
|
||||
return (self._num_blocks * self._block_size) // self._page_size
|
||||
|
||||
def _read_page(self, page: int) -> RFFrame | None:
|
||||
if page >= self._num_pages():
|
||||
return self._make_error(0x10)
|
||||
off = self._page_offset(page)
|
||||
return self._make_response(bytes(self._memory[off:off + self._page_size]))
|
||||
|
||||
def _write_page(self, page: int, data: bytes, reread: bool) -> RFFrame | None:
|
||||
if page >= self._num_pages():
|
||||
return self._make_error(0x10)
|
||||
if len(data) < self._page_size:
|
||||
return self._make_error(0x0F)
|
||||
off = self._page_offset(page)
|
||||
self._memory[off:off + self._page_size] = data[:self._page_size]
|
||||
if reread:
|
||||
return self._make_response(bytes(self._memory[off:off + self._page_size]))
|
||||
return self._make_response(b"")
|
||||
|
||||
def _write_byte(self, page: int, params: bytes) -> RFFrame | None:
|
||||
# params = byte-number(1) + value(1)
|
||||
if len(params) < 2:
|
||||
return self._make_error(0x0F)
|
||||
byte_no, value = params[0], params[1]
|
||||
if page >= self._num_pages() or byte_no > 0x09:
|
||||
return self._make_error(0x10)
|
||||
# Only the 8 data bytes live in _memory; admin bytes 8/9 are accepted
|
||||
# but not stored in the block view.
|
||||
if byte_no < 8:
|
||||
self._memory[self._page_offset(page) + byte_no] = value
|
||||
return self._make_response(b"")
|
||||
|
||||
def _restricted_write(self, page: int, params: bytes, reread: bool) -> RFFrame | None:
|
||||
# Decrement-only value counter: accept only a strictly lower 32-bit value.
|
||||
if page >= self._num_pages() or len(params) < 4:
|
||||
return self._make_error(0x0F)
|
||||
off = self._page_offset(page)
|
||||
new_val = int.from_bytes(params[0:4], "little")
|
||||
cur = int.from_bytes(self._memory[off:off + 4], "little")
|
||||
if new_val >= cur and cur != 0:
|
||||
return self._make_error(0x0F) # not lower → rejected
|
||||
self._memory[off:off + 4] = new_val.to_bytes(4, "little")
|
||||
if reread:
|
||||
return self._make_response(bytes(self._memory[off:off + self._page_size]))
|
||||
return self._make_response(b"")
|
||||
|
||||
|
||||
class SRF55V02P(MydVicinity):
|
||||
"""my-d vicinity SRF55V02P — 2.5 kbit, 56 ISO blocks (00h-37h)."""
|
||||
_uid_prefix = b"\xE0\x05\x40"
|
||||
_chip_id = 0x40
|
||||
_num_blocks_cls = 56
|
||||
|
||||
|
||||
class SRF55V10P(MydVicinity):
|
||||
"""my-d vicinity SRF55V10P — 10 kbit, 248 ISO blocks (00h-F7h)."""
|
||||
_uid_prefix = b"\xE0\x05\x00"
|
||||
_chip_id = 0x00
|
||||
_num_blocks_cls = 248
|
||||
@@ -12,6 +12,8 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pm3py.transponders.bitfield import BitField, Register
|
||||
|
||||
try:
|
||||
from Crypto.Cipher import AES as _AES
|
||||
_HAS_AES = True
|
||||
@@ -34,6 +36,39 @@ PRIV_CRYPTO_CONFIG = 0x20
|
||||
PRIV_AREA1_READ = 0x40
|
||||
PRIV_AREA1_WRITE = 0x80
|
||||
|
||||
|
||||
class NxpKeyPrivileges(Register):
|
||||
"""NXP ICODE DNA / NTAG 5 AES key-privilege mask (Table 5) — the 8 bits defining what a
|
||||
successful authentication with a key slot is allowed to do. Build one to configure a slot,
|
||||
or decode a stored byte::
|
||||
|
||||
NxpKeyPrivileges.build(read=True, write=True, crypto_config=True)
|
||||
NxpKeyPrivileges(0x21).crypto_config # -> True
|
||||
"""
|
||||
|
||||
_width_bits = 8
|
||||
_label = "NXP key privileges"
|
||||
|
||||
read = BitField(0, doc="read user memory")
|
||||
write = BitField(1, doc="write user memory")
|
||||
privacy = BitField(2, doc="toggle privacy (enable/disable) mode")
|
||||
destroy = BitField(3, doc="permanently destroy the tag")
|
||||
eas_afi = BitField(4, doc="set/reset EAS and write the AFI")
|
||||
crypto_config = BitField(5, doc="change crypto configuration — keys, privileges, GCH")
|
||||
area1_read = BitField(6, doc="read AREA1 (Link/Boost second memory area)")
|
||||
area1_write = BitField(7, doc="write AREA1")
|
||||
|
||||
@classmethod
|
||||
def build(cls, **flags: bool) -> "NxpKeyPrivileges":
|
||||
"""Compose a privilege mask from named flags, e.g. ``build(read=True, write=True)``."""
|
||||
valid = {name for name, _ in cls.fields()}
|
||||
reg = cls(0)
|
||||
for name, value in flags.items():
|
||||
if name not in valid:
|
||||
raise TypeError(f"unknown privilege {name!r}; valid: {sorted(valid)}")
|
||||
setattr(reg, name, bool(value))
|
||||
return reg
|
||||
|
||||
# NFC Global Crypto Header values
|
||||
GCH_DEACTIVATED = 0x81
|
||||
GCH_DEACTIVATED_PRIV_LOCKED = 0x87
|
||||
@@ -83,6 +118,14 @@ class NxpAesAuth:
|
||||
return False
|
||||
return bool(self._key_privileges[key_id] & privilege)
|
||||
|
||||
def key_privileges(self, key_id: int) -> NxpKeyPrivileges:
|
||||
"""Key slot ``key_id``'s privilege mask as a self-describing :class:`NxpKeyPrivileges`."""
|
||||
return NxpKeyPrivileges(self._key_privileges[key_id])
|
||||
|
||||
def set_key_privileges(self, key_id: int, value: "NxpKeyPrivileges | int") -> None:
|
||||
"""Program key slot ``key_id``'s privilege mask (accepts a register or a raw byte)."""
|
||||
self._key_privileges[key_id] = int(value) & 0xFF
|
||||
|
||||
def is_access_enforced(self) -> bool:
|
||||
"""Check if GCH is activated (>= 0xC1), meaning access conditions are enforced."""
|
||||
return self._nfc_gch >= GCH_ACTIVATED
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user