feat(registers): self-describing config/frame registers
Add a BitField descriptor + Register base and per-IC config/frame
registers so opaque words decode themselves and build correctly:
- T5577Config LF block-0 config word, mode-dependent data rate
- EM4100Code 64-bit frame + all parities (EM4102/EM4200 too)
- Type2Config shared NXP CFG0/CFG1 base (auth0/prot/cfglck/authlim)
- Ntag21xConfig + mirror / MIRROR_PAGE / NFC counter fields
- UltralightEV1Config EV1 CFG0/CFG1
- UltralightCConfig UL-C AUTH0 + AUTH1 protect scope
- MifareAccessConditions per-block C1/C2/C3 -> English, inverted-copy
validation, build() that fills the inverted copy
Fields are documented attributes, so `<tab>` completes them in the shell
(bpython/ipython show each field's doc as the tooltip) and `repr` prints
the decode table. Bound to tags via `tag.config` / `tag.access(sector)`
/ `tag.code`. Bit layouts cross-checked against the firmware submodule
(cmdlft55xx, mifare4.c) and the NXP/EM datasheets. 40 new tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,10 @@ 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
|
||||
# registers (T5577Config, Ntag21xConfig, UL EV1/C, MFC access
|
||||
# bits). Fields are documented attrs -> shell completion + repr
|
||||
# decode table. Layouts cross-checked vs the firmware submodule.
|
||||
hf/iso14443a/ # Tag14443A_3/4, MifareClassic, DESFire, NfcType2/4
|
||||
# nxp/type2,ntag21x,ultralight,ntag_i2c
|
||||
# (NTAG210-216, Ultralight/C/EV1, NTAG I2C plus)
|
||||
|
||||
@@ -20,16 +20,18 @@ 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.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
|
||||
from pm3py.transponders.hf.iso14443a.nxp.type2 import NxpType2Tag, Type2Config
|
||||
from pm3py.transponders.hf.iso14443a.nxp.ntag21x import (
|
||||
Ntag21x, NTAG210, NTAG212, NTAG213, NTAG215, NTAG216,
|
||||
Ntag21x, NTAG210, NTAG212, NTAG213, NTAG215, NTAG216, Ntag21xConfig,
|
||||
)
|
||||
from pm3py.transponders.hf.iso14443a.nxp.ultralight import (
|
||||
MifareUltralight, MifareUltralightC, MifareUltralightEV1, MF0UL11, MF0UL21,
|
||||
UL_C_DEFAULT_KEY,
|
||||
UL_C_DEFAULT_KEY, UltralightEV1Config, UltralightCConfig,
|
||||
)
|
||||
from pm3py.transponders.hf.iso14443a.nxp.ntag_i2c import NtagI2C, NT3H2111, NT3H2211
|
||||
from pm3py.transponders.hf.iso14443a.st.st25tn import ST25TN, ST25TN512, ST25TN01K
|
||||
@@ -63,9 +65,10 @@ 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
|
||||
from pm3py.transponders.lf.em.em4100 import EM4100Tag, EM4100Reader, EM4100Code
|
||||
from pm3py.transponders.lf.hid.hid import HIDProxTag, HIDReader
|
||||
from pm3py.transponders.lf.atmel.t5577 import T5577Tag, T5577Reader
|
||||
from pm3py.transponders.bitfield import BitField, Register
|
||||
from pm3py.transponders.lf.atmel.t5577 import T5577Tag, T5577Reader, T5577Config
|
||||
from pm3py.transponders.implants import xEM, xNT, xM1, FlexDF, NExT, MagicMifareClassicTag
|
||||
|
||||
__all__ = [
|
||||
@@ -76,17 +79,19 @@ __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",
|
||||
"BitField", "Register",
|
||||
"NfcType2Tag", "NfcType4Tag", "NfcType5Tag",
|
||||
"NxpType2Tag",
|
||||
"Ntag21x", "NTAG210", "NTAG212", "NTAG213", "NTAG215", "NTAG216",
|
||||
"NxpType2Tag", "Type2Config",
|
||||
"Ntag21x", "NTAG210", "NTAG212", "NTAG213", "NTAG215", "NTAG216", "Ntag21xConfig",
|
||||
"MifareUltralight", "MifareUltralightC", "MifareUltralightEV1",
|
||||
"MF0UL11", "MF0UL21", "UL_C_DEFAULT_KEY",
|
||||
"UltralightEV1Config", "UltralightCConfig",
|
||||
"NtagI2C", "NT3H2111", "NT3H2211",
|
||||
"ST25TN", "ST25TN512", "ST25TN01K",
|
||||
"ST25TA", "ST25TA512B", "ST25TA02KB", "ST25TA16K", "ST25TA64K",
|
||||
|
||||
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:])
|
||||
@@ -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
|
||||
|
||||
@@ -14,7 +14,8 @@ Feature summary:
|
||||
from __future__ import annotations
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from .type2 import NxpType2Tag, READ_CNT, FAST_READ, READ_CMD, _nak, NAK_ARG
|
||||
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
|
||||
@@ -23,11 +24,26 @@ 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",
|
||||
|
||||
@@ -13,6 +13,7 @@ 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) ----
|
||||
@@ -42,6 +43,25 @@ 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.
|
||||
|
||||
@@ -65,6 +85,26 @@ class NxpType2Tag(NfcType2Tag):
|
||||
_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",
|
||||
|
||||
@@ -14,14 +14,35 @@ 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, NAK_AUTH,
|
||||
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")
|
||||
|
||||
@@ -109,6 +130,20 @@ class MifareUltralightC(NxpType2Tag):
|
||||
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"
|
||||
@@ -196,6 +231,7 @@ class MifareUltralightEV1(NxpType2Tag):
|
||||
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",
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import struct
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.bitfield import BitField, Register
|
||||
from ..base import TagLF, ReaderLF, Modulation
|
||||
from ..em.em4100 import EM4100Tag
|
||||
|
||||
@@ -16,6 +17,108 @@ CMD_PWD_WRITE = 0x04
|
||||
# T5577 block 0 config bits for common modes
|
||||
CONFIG_EM4100 = 0x00148040 # ASK/Manchester, RF/64, 64 bits, maxblk=2
|
||||
|
||||
# modulation codes (config bits 16:12) — datasheet / ATA5577C
|
||||
_MODULATION = {
|
||||
0x00: "direct", 0x01: "PSK1", 0x02: "PSK2", 0x03: "PSK3",
|
||||
0x04: "FSK1", 0x05: "FSK2", 0x06: "FSK1a", 0x07: "FSK2a",
|
||||
0x08: "ASK", 0x10: "biphase", 0x18: "biphase'",
|
||||
}
|
||||
# PSK carrier frequency (config bits 11:10), PSK modes only
|
||||
_PSK_CF = {0x00: "RF/2", 0x01: "RF/4", 0x02: "RF/8", 0x03: "reserved"}
|
||||
# master key nibble (config bits 31:28)
|
||||
_MASTER_KEY = {0x0: "0 (normal)", 0x6: "6 (extended)", 0x9: "9 (extended)"}
|
||||
# standard-mode data bit rate: 3-bit code (bits 20:18) <-> RF/n divider
|
||||
_RF_BY_CODE = {0: 8, 1: 16, 2: 32, 3: 40, 4: 50, 5: 64, 6: 100, 7: 128}
|
||||
_CODE_BY_RF = {v: k for k, v in _RF_BY_CODE.items()}
|
||||
|
||||
|
||||
class T5577Config(Register):
|
||||
"""T5577 / ATA5577C configuration word — page 0, block 0.
|
||||
|
||||
Decode a value you read, or build one to write::
|
||||
|
||||
T5577Config(0x00148040) # -> decoded table (EM4100/unique)
|
||||
cfg = T5577Config.preset("em4100")
|
||||
cfg.max_block = 3
|
||||
cfg.st = True
|
||||
lf.t55.writebl(0, int(cfg)) # int(cfg) is the raw word to program
|
||||
|
||||
Fields (high->low): master_key, data_bit_rate, x_mode, modulation, psk_cf, aor, otp,
|
||||
max_block, pwd, st, fast_write, inverse, por_delay. ``data_bit_rate`` is mode-dependent
|
||||
(see its docstring), so it is a computed property rather than a plain ``BitField``.
|
||||
"""
|
||||
|
||||
_width_bits = 32
|
||||
_label = "T5577 config (block0)"
|
||||
|
||||
master_key = BitField(31, 28, values=_MASTER_KEY,
|
||||
doc="lock/extended-mode key: 0=normal, 6/9 enable extended (x-mode)")
|
||||
x_mode = BitField(17, doc="extended mode: 6-bit data rate + fast-write / AOR / OTP semantics")
|
||||
modulation = BitField(16, 12, values=_MODULATION, doc="modulation scheme (ASK, FSK, PSK, ...)")
|
||||
psk_cf = BitField(11, 10, values=_PSK_CF, doc="PSK sub-carrier frequency (PSK modes only)")
|
||||
aor = BitField(9, doc="answer-on-request: stay silent until woken by a read command")
|
||||
otp = BitField(8, doc="one-time-programmable: block further writes once set (irreversible)")
|
||||
max_block = BitField(7, 5, doc="number of data blocks the tag streams back (1..7)")
|
||||
pwd = BitField(4, doc="password enabled: reads/writes require the block-7 password")
|
||||
st = BitField(3, doc="sequence terminator: marks end of data for reader auto-detect")
|
||||
fast_write = BitField(2, doc="fast downlink write timing (x-mode)")
|
||||
inverse = BitField(1, doc="invert the data output")
|
||||
por_delay = BitField(0, doc="power-on-reset delay (ride through brief field dropouts)")
|
||||
|
||||
#: known block-0 words (from the Proxmark3 config-block table) for common tag types
|
||||
_PRESETS = {
|
||||
"em4100": 0x00148040, # ASK/Manchester, RF/64, 2 blocks
|
||||
"default": 0x000880E8, # ASK/Manchester, RF/32, ST, 7 blocks
|
||||
"hid": 0x00107060, # FSK2a, RF/50, 3 blocks (HID 26-bit)
|
||||
"indala": 0x00081040, # PSK1, RF/32, 2 blocks
|
||||
"fdxb": 0x903F0082, # biphase, x-mode, RF/32, 4 blocks
|
||||
"viking": 0x00088040, # ASK/Manchester, RF/32, 2 blocks
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def preset(cls, name: str) -> "T5577Config":
|
||||
try:
|
||||
return cls(cls._PRESETS[name])
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"unknown preset {name!r}; known: {sorted(cls._PRESETS)}"
|
||||
) from None
|
||||
|
||||
@property
|
||||
def data_bit_rate(self) -> int:
|
||||
"""Data bit rate as ``RF/n`` (the carrier is divided by this before sampling).
|
||||
|
||||
Standard mode uses a 3-bit code -> RF/8, 16, 32, 40, 50, 64, 100 or 128. When
|
||||
``x_mode`` is set the field is 6 bits and the rate is ``2*code + 2`` (any even RF/n)."""
|
||||
raw6 = (self.value >> 18) & 0x3F
|
||||
if self.x_mode:
|
||||
return 2 * raw6 + 2
|
||||
return _RF_BY_CODE.get(raw6 & 0x07, raw6 & 0x07)
|
||||
|
||||
@data_bit_rate.setter
|
||||
def data_bit_rate(self, rf: int) -> None:
|
||||
if self.x_mode:
|
||||
if rf < 2 or rf % 2:
|
||||
raise ValueError("x-mode data rate must be an even RF/n >= 2")
|
||||
code = (rf - 2) // 2
|
||||
if code >> 6:
|
||||
raise ValueError(f"RF/{rf} too large for x-mode (6-bit field)")
|
||||
self.value = (self.value & ~(0x3F << 18)) | (code << 18)
|
||||
else:
|
||||
if rf not in _CODE_BY_RF:
|
||||
raise ValueError(
|
||||
f"standard data rate must be RF/n with n in {sorted(_CODE_BY_RF)}; got RF/{rf}"
|
||||
)
|
||||
self.value = (self.value & ~(0x07 << 18)) | (_CODE_BY_RF[rf] << 18)
|
||||
|
||||
def _extra_rows(self) -> list[dict]:
|
||||
hi = 23 if self.x_mode else 20
|
||||
return [{
|
||||
"name": "data_bit_rate", "value": f"RF/{self.data_bit_rate}",
|
||||
"bits": f"[{hi}:18]", "lo": 18,
|
||||
"doc": "carrier divider RF/n (see data_bit_rate)",
|
||||
}]
|
||||
|
||||
|
||||
class T5577Tag(TagLF):
|
||||
"""T5577 programmable LF transponder.
|
||||
@@ -35,6 +138,16 @@ class T5577Tag(TagLF):
|
||||
def blocks(self) -> list[int]:
|
||||
return self._blocks
|
||||
|
||||
@property
|
||||
def config(self) -> T5577Config:
|
||||
"""Block 0 decoded as a self-describing :class:`T5577Config`. Assign one back
|
||||
(``tag.config = cfg``) to program block 0."""
|
||||
return T5577Config(self._blocks[0])
|
||||
|
||||
@config.setter
|
||||
def config(self, value: "T5577Config | int") -> None:
|
||||
self._blocks[0] = int(value)
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
if not self._powered or not frame.data:
|
||||
return None
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.bitfield import Register
|
||||
from ..base import TagLF, ReaderLF, Modulation
|
||||
|
||||
|
||||
@@ -34,6 +35,14 @@ class EM4100Tag(TagLF):
|
||||
"""Return 64-bit encoded data as list of 0/1 values."""
|
||||
return list(self._encoded)
|
||||
|
||||
@property
|
||||
def code(self) -> "EM4100Code":
|
||||
"""The tag's 64-bit frame decoded as a self-describing :class:`EM4100Code`."""
|
||||
value = 0
|
||||
for bit in self._encoded:
|
||||
value = (value << 1) | bit
|
||||
return EM4100Code(value)
|
||||
|
||||
def _get_response(self) -> RFFrame:
|
||||
"""Return encoded data as RFFrame."""
|
||||
from bitarray import bitarray
|
||||
@@ -85,6 +94,109 @@ class EM4100Tag(TagLF):
|
||||
return tag_id
|
||||
|
||||
|
||||
class EM4100Code(Register):
|
||||
"""The 64-bit EM4100 code, decoded — header, the ten 4-bit data rows with their even
|
||||
row parities, the four column parities, and the stop bit. Covers EM4102 and the EM4200's
|
||||
64-bit mode (same frame). Build from a 40-bit ID (parities computed for you) or decode a
|
||||
captured frame and check it::
|
||||
|
||||
EM4100Code.build(0x0102030405) # -> frame with correct parities
|
||||
EM4100Code(0xFF00...).valid # header + every parity + stop bit consistent?
|
||||
code.tag_id # 40-bit ID (customer_id byte + card_number)
|
||||
|
||||
The redundant parities are the classic EM footgun — this validates all of them and, when
|
||||
you build a code, gets them right so you never program a tag a reader won't accept.
|
||||
"""
|
||||
|
||||
_width_bits = 64
|
||||
_label = "EM4100 64-bit code"
|
||||
|
||||
def _bits(self) -> list[int]:
|
||||
"""The frame as 64 bits, MSB (first transmitted) first — matches ``_encode`` order."""
|
||||
return [(self.value >> (63 - i)) & 1 for i in range(64)]
|
||||
|
||||
@property
|
||||
def header(self) -> int:
|
||||
return (self.value >> 55) & 0x1FF
|
||||
|
||||
@property
|
||||
def header_ok(self) -> bool:
|
||||
return self.header == 0x1FF
|
||||
|
||||
@property
|
||||
def tag_id(self) -> int:
|
||||
"""The 40-bit ID carried in the data rows (parities stripped)."""
|
||||
return EM4100Tag.decode_data(self._bits())
|
||||
|
||||
@property
|
||||
def customer_id(self) -> int:
|
||||
"""First data byte — the EM 'customer'/version field."""
|
||||
return (self.tag_id >> 32) & 0xFF
|
||||
|
||||
@property
|
||||
def card_number(self) -> int:
|
||||
"""Low 32 bits of the ID — the card number readers usually display."""
|
||||
return self.tag_id & 0xFFFFFFFF
|
||||
|
||||
def _bad_rows(self) -> list[int]:
|
||||
b = self._bits()
|
||||
bad = []
|
||||
for row in range(10):
|
||||
start = 9 + row * 5
|
||||
if b[start + 4] != sum(b[start:start + 4]) % 2:
|
||||
bad.append(row)
|
||||
return bad
|
||||
|
||||
def _bad_cols(self) -> list[int]:
|
||||
b = self._bits()
|
||||
return [c for c in range(4)
|
||||
if b[59 + c] != sum(b[9 + r * 5 + c] for r in range(10)) % 2]
|
||||
|
||||
@property
|
||||
def rows_ok(self) -> bool:
|
||||
return not self._bad_rows()
|
||||
|
||||
@property
|
||||
def cols_ok(self) -> bool:
|
||||
return not self._bad_cols()
|
||||
|
||||
@property
|
||||
def stop_ok(self) -> bool:
|
||||
return (self.value & 1) == 0
|
||||
|
||||
@property
|
||||
def valid(self) -> bool:
|
||||
return self.header_ok and self.rows_ok and self.cols_ok and self.stop_ok
|
||||
|
||||
@classmethod
|
||||
def build(cls, tag_id: int) -> "EM4100Code":
|
||||
"""Encode a 40-bit ID into a full, correctly-parity'd 64-bit code."""
|
||||
value = 0
|
||||
for bit in EM4100Tag._encode(tag_id):
|
||||
value = (value << 1) | bit
|
||||
return cls(value)
|
||||
|
||||
def _rows(self) -> list[dict]:
|
||||
_bad_rows = self._bad_rows()
|
||||
_bad_cols = self._bad_cols()
|
||||
return [
|
||||
{"name": "header", "bits": "[63:55]", "doc": "",
|
||||
"value": f"0x{self.header:03X}" + ("" if self.header_ok else " BAD (expect 1FF)")},
|
||||
{"name": "tag_id", "bits": "40-bit", "doc": "", "value": f"{self.tag_id:010X}"},
|
||||
{"name": "customer_id", "bits": "", "doc": "", "value": f"0x{self.customer_id:02X}"},
|
||||
{"name": "card_number", "bits": "", "doc": "",
|
||||
"value": f"0x{self.card_number:08X} ({self.card_number})"},
|
||||
{"name": "row_parity", "bits": "", "doc": "",
|
||||
"value": "ok" if self.rows_ok else f"BAD rows {_bad_rows}"},
|
||||
{"name": "col_parity", "bits": "[4:1]", "doc": "",
|
||||
"value": "ok" if self.cols_ok else f"BAD cols {_bad_cols}"},
|
||||
{"name": "stop", "bits": "[0]", "doc": "",
|
||||
"value": f"{self.value & 1}" + ("" if self.stop_ok else " BAD (expect 0)")},
|
||||
{"name": "valid", "bits": "", "doc": "",
|
||||
"value": "yes" if self.valid else "NO — header/parity/stop error"},
|
||||
]
|
||||
|
||||
|
||||
class EM4100Reader(ReaderLF):
|
||||
"""EM4100 reader — decode Manchester ASK response."""
|
||||
|
||||
|
||||
301
tests/test_bitfield.py
Normal file
301
tests/test_bitfield.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Bit-field register infrastructure + the T5577 config register. Hardware-free."""
|
||||
import pytest
|
||||
|
||||
from pm3py.transponders.bitfield import BitField, Register
|
||||
from pm3py.sim import (
|
||||
T5577Config, T5577Tag,
|
||||
Ntag21xConfig, NTAG213, MF0UL21, MifareUltralightC,
|
||||
MifareAccessConditions, MifareClassicTag,
|
||||
EM4100Code, EM4100Tag,
|
||||
)
|
||||
|
||||
|
||||
class Demo(Register):
|
||||
_width_bits = 16
|
||||
_label = "demo"
|
||||
top = BitField(15, 12, doc="top nibble")
|
||||
flag = BitField(3, doc="a flag")
|
||||
mode = BitField(1, 0, values={0: "a", 1: "b", 2: "c"}, doc="a labelled mode")
|
||||
|
||||
|
||||
class TestBitFieldCore:
|
||||
def test_read_fields(self):
|
||||
d = Demo(0xF008) # top=0xF, flag(bit3)=1, mode=0
|
||||
assert d.top == 0xF
|
||||
assert d.flag is True
|
||||
assert d.mode == "a"
|
||||
|
||||
def test_single_bit_is_bool(self):
|
||||
assert Demo(0).flag is False
|
||||
assert Demo(0x8).flag is True
|
||||
|
||||
def test_write_by_field_is_isolated(self):
|
||||
d = Demo(0)
|
||||
d.top = 0xA
|
||||
assert d.value == 0xA000
|
||||
d.flag = True
|
||||
assert d.value == 0xA008
|
||||
d.top = 0x5 # must not disturb the flag bit
|
||||
assert d.value == 0x5008
|
||||
|
||||
def test_values_map_both_directions(self):
|
||||
d = Demo(0)
|
||||
d.mode = "c"
|
||||
assert d.mode == "c" and d.value == 0x0002
|
||||
d.mode = 1 # raw code still accepted
|
||||
assert d.mode == "b"
|
||||
|
||||
def test_overflow_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
Demo(0).top = 0x1F # 5 bits into a 4-bit field
|
||||
|
||||
def test_int_and_index(self):
|
||||
d = Demo(0x1234)
|
||||
assert int(d) == 0x1234
|
||||
assert hex(d) == "0x1234"
|
||||
|
||||
def test_fields_ordered_high_to_low(self):
|
||||
assert [n for n, _ in Demo.fields()] == ["top", "flag", "mode"]
|
||||
|
||||
def test_descriptor_doc_visible(self):
|
||||
# the completion tooltip / `field?` help comes from the descriptor docstring
|
||||
assert Demo.top.__doc__ == "top nibble"
|
||||
|
||||
|
||||
# The three anchor constants below are lifted from the Proxmark3 config-block table
|
||||
# (client/src/cmdlft55xx.h) with their documented meanings — they pin the bit layout.
|
||||
class TestT5577Decode:
|
||||
def test_em4100(self):
|
||||
cfg = T5577Config(0x00148040)
|
||||
assert cfg.modulation == "ASK"
|
||||
assert cfg.data_bit_rate == 64
|
||||
assert cfg.max_block == 2
|
||||
assert cfg.x_mode is False
|
||||
assert cfg.st is False
|
||||
assert cfg.master_key == "0 (normal)"
|
||||
|
||||
def test_default_ask_manchester(self):
|
||||
cfg = T5577Config(0x000880E8) # ASK, RF/32, STT, 7 data blocks
|
||||
assert cfg.modulation == "ASK"
|
||||
assert cfg.data_bit_rate == 32
|
||||
assert cfg.max_block == 7
|
||||
assert cfg.st is True
|
||||
|
||||
def test_pac_is_nrz(self):
|
||||
cfg = T5577Config(0x00080080) # NRZ, RF/32, 4 data blocks
|
||||
assert cfg.modulation == "direct"
|
||||
assert cfg.max_block == 4
|
||||
|
||||
def test_hid_fsk(self):
|
||||
cfg = T5577Config(0x00107060) # FSK2a, RF/50, 3 blocks
|
||||
assert cfg.modulation == "FSK2a"
|
||||
assert cfg.data_bit_rate == 50
|
||||
assert cfg.max_block == 3
|
||||
|
||||
def test_xmode_data_rate(self):
|
||||
cfg = T5577Config(0x903F0082) # fdx-b, x-mode
|
||||
assert cfg.x_mode is True
|
||||
assert cfg.modulation == "biphase"
|
||||
assert cfg.data_bit_rate == 32 # 2*code+2 over the 6-bit field
|
||||
|
||||
|
||||
class TestT5577Build:
|
||||
def test_preset_roundtrip(self):
|
||||
assert int(T5577Config.preset("em4100")) == 0x00148040
|
||||
|
||||
def test_unknown_preset(self):
|
||||
with pytest.raises(ValueError):
|
||||
T5577Config.preset("nope")
|
||||
|
||||
def test_edit_by_field(self):
|
||||
cfg = T5577Config.preset("em4100")
|
||||
cfg.max_block = 3
|
||||
cfg.st = True
|
||||
# max_block bits 7:5 -> 3<<5 = 0x60; st bit3 -> 0x08; base had maxblk 2 (0x40)
|
||||
assert cfg.max_block == 3 and cfg.st is True
|
||||
assert int(cfg) == (0x00148040 & ~(0x07 << 5)) | (3 << 5) | (1 << 3)
|
||||
|
||||
def test_set_data_rate_standard(self):
|
||||
cfg = T5577Config(0x00148040)
|
||||
cfg.data_bit_rate = 32
|
||||
assert cfg.data_bit_rate == 32
|
||||
with pytest.raises(ValueError):
|
||||
cfg.data_bit_rate = 33 # not a legal standard divider
|
||||
|
||||
def test_set_modulation_by_name(self):
|
||||
cfg = T5577Config(0)
|
||||
cfg.modulation = "FSK2"
|
||||
assert cfg.modulation == "FSK2"
|
||||
assert cfg.modulation != "ASK"
|
||||
|
||||
def test_decode_mapping_and_repr(self):
|
||||
cfg = T5577Config(0x00148040)
|
||||
d = cfg.decode()
|
||||
assert d["modulation"] == "ASK"
|
||||
assert d["data_bit_rate"] == "RF/64"
|
||||
assert d["max_block"] == "2"
|
||||
text = repr(cfg) # not a tty in tests -> plain text
|
||||
assert "T5577 config (block0) = 0x00148040" in text
|
||||
assert "modulation" in text and "ASK" in text
|
||||
assert "data_bit_rate" in text and "RF/64" in text
|
||||
|
||||
|
||||
class TestT5577TagBinding:
|
||||
def test_config_reads_block0(self):
|
||||
tag = T5577Tag()
|
||||
tag.blocks[0] = 0x00148040
|
||||
assert tag.config.modulation == "ASK"
|
||||
assert tag.config.max_block == 2
|
||||
|
||||
def test_assign_config_programs_block0(self):
|
||||
tag = T5577Tag()
|
||||
cfg = T5577Config.preset("hid")
|
||||
tag.config = cfg
|
||||
assert tag.blocks[0] == 0x00107060
|
||||
# accepts a raw int too
|
||||
tag.config = 0x00148040
|
||||
assert tag.config.modulation == "ASK"
|
||||
|
||||
|
||||
class TestNtag21xConfig:
|
||||
def test_fields_span_both_pages(self):
|
||||
cfg = Ntag21xConfig(0)
|
||||
cfg.auth0 = 0x04 # CFG0 byte 3
|
||||
cfg.prot = True # CFG1 byte 0 bit 7 (ACCESS.PROT)
|
||||
# CFG0 = 00 00 00 04, CFG1 = 80 00 00 00
|
||||
assert bytes(cfg) == b"\x00\x00\x00\x04\x80\x00\x00\x00"
|
||||
assert cfg.auth0 == 0x04 and cfg.prot is True
|
||||
|
||||
def test_mirror_and_counter_fields(self):
|
||||
cfg = Ntag21xConfig(0)
|
||||
cfg.mirror_conf = "UID+counter"
|
||||
cfg.mirror_page = 0x10
|
||||
cfg.nfc_cnt_en = True
|
||||
cfg.authlim = 5
|
||||
assert cfg.mirror_conf == "UID+counter"
|
||||
assert cfg.mirror_page == 0x10
|
||||
assert cfg.nfc_cnt_en is True and cfg.authlim == 5
|
||||
# re-decoding the raw word must reproduce everything
|
||||
assert Ntag21xConfig(int(cfg)).decode() == cfg.decode()
|
||||
|
||||
def test_tag_binding_roundtrip(self):
|
||||
tag = NTAG213() # fresh: auth0=0xFF, prot off
|
||||
assert tag.config.auth0 == 0xFF
|
||||
assert tag.config.prot is False
|
||||
cfg = tag.config
|
||||
cfg.auth0 = 0x10
|
||||
cfg.prot = True
|
||||
tag.config = cfg # program both config pages
|
||||
assert tag.config.auth0 == 0x10 and tag.config.prot is True
|
||||
|
||||
|
||||
class TestUltralightConfig:
|
||||
def test_ev1_uses_shared_fields(self):
|
||||
tag = MF0UL21()
|
||||
cfg = tag.config
|
||||
cfg.auth0 = 0x08
|
||||
cfg.cfglck = True
|
||||
tag.config = cfg
|
||||
assert tag.config.auth0 == 0x08 and tag.config.cfglck is True
|
||||
assert "NTAG21x" not in repr(cfg) # EV1 has no mirror/counter fields
|
||||
|
||||
def test_ulc_auth_config(self):
|
||||
tag = MifareUltralightC(auth0=0x30, auth1_read_protect=True)
|
||||
cfg = tag.config
|
||||
assert cfg.auth0 == 0x30
|
||||
assert cfg.auth1_prot == "read+write" # AUTH1 bit0 == 0
|
||||
cfg.auth1_prot = "write-only"
|
||||
cfg.auth0 = 0x2A
|
||||
tag.config = cfg
|
||||
assert tag.config.auth0 == 0x2A
|
||||
assert tag.config.auth1_prot == "write-only"
|
||||
|
||||
|
||||
class TestMifareAccessConditions:
|
||||
def test_factory_default_trailer(self):
|
||||
# FF 07 80 access bytes + GPB 0x69 = the transport/default trailer
|
||||
ac = MifareAccessConditions(0xFF078069)
|
||||
assert ac.valid is True
|
||||
assert ac.block0 == 0 and ac.block1 == 0 and ac.block2 == 0
|
||||
assert ac.trailer == 1
|
||||
assert ac.gpb == 0x69
|
||||
|
||||
def test_build_matches_default(self):
|
||||
ac = MifareAccessConditions.build(0, 0, 0, trailer=1, gpb=0x69)
|
||||
assert int(ac) == 0xFF078069
|
||||
assert bytes(ac) == b"\xFF\x07\x80\x69" # trailer bytes 6..9
|
||||
|
||||
def test_build_is_always_valid(self):
|
||||
for t in range(8):
|
||||
for d in range(8):
|
||||
assert MifareAccessConditions.build(d, d, d, trailer=t).valid
|
||||
|
||||
def test_all_locked(self):
|
||||
ac = MifareAccessConditions.build(7, 7, 7, trailer=7)
|
||||
assert ac.trailer == 7 and ac.block0 == 7
|
||||
assert "no access" in ac.describe(0)
|
||||
assert "locked" in ac.describe(3)
|
||||
|
||||
def test_inconsistent_copy_flagged(self):
|
||||
ac = MifareAccessConditions(0x00000000) # C == ~C == 0, impossible
|
||||
assert ac.valid is False
|
||||
assert "INVALID" in repr(ac)
|
||||
|
||||
def test_bad_condition_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
MifareAccessConditions.build(trailer=8)
|
||||
|
||||
def test_describe_and_decode(self):
|
||||
ac = MifareAccessConditions(0xFF078069)
|
||||
assert "transport" in ac.describe(0)
|
||||
assert "transport/default" in ac.describe(3)
|
||||
d = ac.decode()
|
||||
assert d["block0"].startswith("0")
|
||||
assert d["trailer"].startswith("1")
|
||||
|
||||
def test_tag_access_reads_trailer(self):
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04")
|
||||
# write a full trailer for sector 1: KeyA(6) + FF0780 + GPB69 + KeyB(6)
|
||||
trailer_blk = tag._sector_trailer_block(1)
|
||||
trailer = b"\xA0\xA1\xA2\xA3\xA4\xA5" + b"\xFF\x07\x80\x69" + b"\xB0\xB1\xB2\xB3\xB4\xB5"
|
||||
tag.write_block_raw(trailer_blk, trailer)
|
||||
ac = tag.access(1)
|
||||
assert ac.trailer == 1 and ac.gpb == 0x69 and ac.valid
|
||||
|
||||
|
||||
class TestEM4100Code:
|
||||
def test_build_roundtrip(self):
|
||||
code = EM4100Code.build(0x0102030405)
|
||||
assert code.tag_id == 0x0102030405
|
||||
assert code.customer_id == 0x01
|
||||
assert code.card_number == 0x02030405
|
||||
assert code.valid
|
||||
|
||||
def test_build_is_always_valid(self):
|
||||
for tid in (0x0, 0xFFFFFFFFFF, 0x1234567890, 0xDEADBEEF00, 0xA5A5A5A5A5):
|
||||
assert EM4100Code.build(tid).valid
|
||||
|
||||
def test_matches_model_encoding(self):
|
||||
# the register must agree bit-for-bit with the tag's own encoder
|
||||
tag = EM4100Tag(0xCAFEBABE99)
|
||||
assert tag.code.valid
|
||||
assert tag.code.tag_id == 0xCAFEBABE99
|
||||
assert int(tag.code) == int(EM4100Code.build(0xCAFEBABE99))
|
||||
|
||||
def test_flipped_data_bit_breaks_row_parity(self):
|
||||
code = EM4100Code.build(0x0102030405)
|
||||
broken = EM4100Code(int(code) ^ (1 << 40)) # flip a data bit in the rows region
|
||||
assert broken.valid is False
|
||||
assert broken._bad_rows() # at least one row parity fails
|
||||
|
||||
def test_bad_header_and_stop_detected(self):
|
||||
good = EM4100Code.build(0)
|
||||
assert good.valid
|
||||
assert EM4100Code(int(good) | 1).valid is False # stop bit set
|
||||
assert EM4100Code(int(good) & ~(0x1FF << 55)).valid is False # header zeroed
|
||||
|
||||
def test_repr_shows_id_and_validity(self):
|
||||
text = repr(EM4100Code.build(0x0102030405))
|
||||
assert "EM4100 64-bit code" in text
|
||||
assert "0102030405" in text
|
||||
assert "valid" in text and "yes" in text
|
||||
Reference in New Issue
Block a user