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:
michael
2026-07-14 08:36:40 -07:00
parent 3aabb64402
commit 2063a33a2b
10 changed files with 925 additions and 13 deletions

View File

@@ -57,6 +57,10 @@ pm3py/
replay.py # Trace replay replay.py # Trace replay
access_control/ # Wiegand, OSDP access_control/ # Wiegand, OSDP
transponders/ # tag/transponder models (extracted from sim/) 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 hf/iso14443a/ # Tag14443A_3/4, MifareClassic, DESFire, NfcType2/4
# nxp/type2,ntag21x,ultralight,ntag_i2c # nxp/type2,ntag21x,ultralight,ntag_i2c
# (NTAG210-216, Ultralight/C/EV1, NTAG I2C plus) # (NTAG210-216, Ultralight/C/EV1, NTAG I2C plus)

View File

@@ -20,16 +20,18 @@ from pm3py.transponders.hf.iso14443a.base import (
Tag14443A, Tag14443A_3, Tag14443A_4, Reader14443A, State14443A, Tag14443A, Tag14443A_3, Tag14443A_4, Reader14443A, State14443A,
) )
from pm3py.transponders.hf.iso14443a.nxp.crypto1 import Crypto1 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.nxp.desfire import DesfireTag, DesfireReader
from pm3py.transponders.hf.iso14443a.ndef import NfcType2Tag, NfcType4Tag 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 ( 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 ( from pm3py.transponders.hf.iso14443a.nxp.ultralight import (
MifareUltralight, MifareUltralightC, MifareUltralightEV1, MF0UL11, MF0UL21, 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.nxp.ntag_i2c import NtagI2C, NT3H2111, NT3H2211
from pm3py.transponders.hf.iso14443a.st.st25tn import ST25TN, ST25TN512, ST25TN01K 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, RF430FRL15xH, RF430FRL152H, RF430FRL153H, RF430FRL154H,
) )
from pm3py.transponders.lf.base import TagLF, ReaderLF, Modulation 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.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 from pm3py.transponders.implants import xEM, xNT, xM1, FlexDF, NExT, MagicMifareClassicTag
__all__ = [ __all__ = [
@@ -76,17 +79,19 @@ __all__ = [
"Reader", "ScriptedReader", "InteractiveReader", "ReaderStep", "StepResult", "Reader", "ScriptedReader", "InteractiveReader", "ReaderStep", "StepResult",
"Tag14443A", "Tag14443A_3", "Tag14443A_4", "Reader14443A", "State14443A", "Tag14443A", "Tag14443A_3", "Tag14443A_4", "Reader14443A", "State14443A",
"Crypto1", "Crypto1",
"MifareClassicTag", "MifareClassicReader", "MifareClassicTag", "MifareClassicReader", "MifareAccessConditions",
"Tag15693", "Reader15693", "State15693", "Tag15693", "Reader15693", "State15693",
"TagLF", "ReaderLF", "Modulation", "TagLF", "ReaderLF", "Modulation",
"EM4100Tag", "EM4100Reader", "EM4100Tag", "EM4100Reader", "EM4100Code",
"HIDProxTag", "HIDReader", "HIDProxTag", "HIDReader",
"T5577Tag", "T5577Reader", "T5577Tag", "T5577Reader", "T5577Config",
"BitField", "Register",
"NfcType2Tag", "NfcType4Tag", "NfcType5Tag", "NfcType2Tag", "NfcType4Tag", "NfcType5Tag",
"NxpType2Tag", "NxpType2Tag", "Type2Config",
"Ntag21x", "NTAG210", "NTAG212", "NTAG213", "NTAG215", "NTAG216", "Ntag21x", "NTAG210", "NTAG212", "NTAG213", "NTAG215", "NTAG216", "Ntag21xConfig",
"MifareUltralight", "MifareUltralightC", "MifareUltralightEV1", "MifareUltralight", "MifareUltralightC", "MifareUltralightEV1",
"MF0UL11", "MF0UL21", "UL_C_DEFAULT_KEY", "MF0UL11", "MF0UL21", "UL_C_DEFAULT_KEY",
"UltralightEV1Config", "UltralightCConfig",
"NtagI2C", "NT3H2111", "NT3H2211", "NtagI2C", "NT3H2111", "NT3H2211",
"ST25TN", "ST25TN512", "ST25TN01K", "ST25TN", "ST25TN512", "ST25TN01K",
"ST25TA", "ST25TA512B", "ST25TA02KB", "ST25TA16K", "ST25TA64K", "ST25TA", "ST25TA512B", "ST25TA02KB", "ST25TA16K", "ST25TA64K",

View 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:])

View File

@@ -5,6 +5,7 @@ import struct
from pm3py.sim.frame import RFFrame from pm3py.sim.frame import RFFrame
from pm3py.sim.medium import Medium from pm3py.sim.medium import Medium
from pm3py.transponders.bitfield import BitField, Register
from ..base import Tag14443A_3, Reader14443A, _compute_bcc from ..base import Tag14443A_3, Reader14443A, _compute_bcc
from .crypto1 import Crypto1 from .crypto1 import Crypto1
@@ -18,6 +19,118 @@ NACK = 0x00
BLOCK_SIZE = 16 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): class MifareClassicTag(Tag14443A_3):
"""MIFARE Classic 1K/4K transponder with Crypto-1 authentication.""" """MIFARE Classic 1K/4K transponder with Crypto-1 authentication."""
@@ -63,6 +176,12 @@ class MifareClassicTag(Tag14443A_3):
else: else:
return 128 + (sector - 32) * 16 + 15 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: def read_block_raw(self, block: int) -> bytes:
"""Direct read of block data (bypassing auth).""" """Direct read of block data (bypassing auth)."""
offset = block * BLOCK_SIZE offset = block * BLOCK_SIZE

View File

@@ -14,7 +14,8 @@ Feature summary:
from __future__ import annotations from __future__ import annotations
from pm3py.sim.frame import RFFrame 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 byte bits (CFG1 byte 0)
ACCESS_PROT = 0x80 ACCESS_PROT = 0x80
@@ -23,11 +24,26 @@ ACCESS_NFC_CNT_EN = 0x10
ACCESS_NFC_CNT_PWD_PROT = 0x08 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): class Ntag21x(NxpType2Tag):
"""Base for the NTAG21x family. Concrete ICs set the class attributes.""" """Base for the NTAG21x family. Concrete ICs set the class attributes."""
_has_nfc_counter: bool = False # True for 213/215/216 _has_nfc_counter: bool = False # True for 213/215/216
_cc_default: bytes = b"\xE1\x10\x00\x00" _cc_default: bytes = b"\xE1\x10\x00\x00"
_config_cls = Ntag21xConfig
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"", def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
password: bytes = b"\xFF\xFF\xFF\xFF", password: bytes = b"\xFF\xFF\xFF\xFF",

View File

@@ -13,6 +13,7 @@ from __future__ import annotations
from pm3py.sim.frame import RFFrame from pm3py.sim.frame import RFFrame
from pm3py.trace.decode_iso14a import decode_14443a_type2 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 from ..ndef import NfcType2Tag, PAGE_SIZE, READ_CMD, WRITE_CMD
# ---- Command codes (NFC Forum Type 2 / Ultralight / NTAG) ---- # ---- 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])) 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): class NxpType2Tag(NfcType2Tag):
"""NXP Type 2 platform: Ultralight EV1 / NTAG21x / NTAG I2C command core. """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) _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) _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, *, def __init__(self, uid: bytes, *,
password: bytes = b"\xFF\xFF\xFF\xFF", password: bytes = b"\xFF\xFF\xFF\xFF",
pack: bytes = b"\x00\x00", pack: bytes = b"\x00\x00",

View File

@@ -14,14 +14,35 @@ from __future__ import annotations
import os import os
from pm3py.sim.frame import RFFrame from pm3py.sim.frame import RFFrame
from pm3py.transponders.bitfield import BitField, Register
from .type2 import ( 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, READ_CNT, INCR_CNT, CHECK_TEARING, READ_CMD, WRITE_CMD,
) )
AUTHENTICATE = 0x1A # Ultralight C 3DES authenticate AUTHENTICATE = 0x1A # Ultralight C 3DES authenticate
AUTH_CONT = 0xAF # continuation byte (part 2) 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) # NXP factory default Ultralight C 3DES key (K1||K2, 16 bytes)
UL_C_DEFAULT_KEY = bytes.fromhex("49454D4B41455242214E4143554F5946") UL_C_DEFAULT_KEY = bytes.fromhex("49454D4B41455242214E4143554F5946")
@@ -109,6 +130,20 @@ class MifareUltralightC(NxpType2Tag):
def _auth1_read_protected(self) -> bool: def _auth1_read_protected(self) -> bool:
return (self._get_page(self.AUTH1_PAGE)[0] & 0x01) == 0x00 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: async def power_on(self) -> None:
await super().power_on() await super().power_on()
self._auth_state = "NONE" self._auth_state = "NONE"
@@ -196,6 +231,7 @@ class MifareUltralightEV1(NxpType2Tag):
CHECK_TEARING_EVENT). Concrete ICs: MF0UL11, MF0UL21.""" CHECK_TEARING_EVENT). Concrete ICs: MF0UL11, MF0UL21."""
_has_cc = False # page 3 is OTP; format for NDEF to add a CC _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"", def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
password: bytes = b"\xFF\xFF\xFF\xFF", pack: bytes = b"\x00\x00", password: bytes = b"\xFF\xFF\xFF\xFF", pack: bytes = b"\x00\x00",

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import struct import struct
from pm3py.sim.frame import RFFrame from pm3py.sim.frame import RFFrame
from pm3py.transponders.bitfield import BitField, Register
from ..base import TagLF, ReaderLF, Modulation from ..base import TagLF, ReaderLF, Modulation
from ..em.em4100 import EM4100Tag from ..em.em4100 import EM4100Tag
@@ -16,6 +17,108 @@ CMD_PWD_WRITE = 0x04
# T5577 block 0 config bits for common modes # T5577 block 0 config bits for common modes
CONFIG_EM4100 = 0x00148040 # ASK/Manchester, RF/64, 64 bits, maxblk=2 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): class T5577Tag(TagLF):
"""T5577 programmable LF transponder. """T5577 programmable LF transponder.
@@ -35,6 +138,16 @@ class T5577Tag(TagLF):
def blocks(self) -> list[int]: def blocks(self) -> list[int]:
return self._blocks 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: async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
if not self._powered or not frame.data: if not self._powered or not frame.data:
return None return None

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from pm3py.sim.frame import RFFrame from pm3py.sim.frame import RFFrame
from pm3py.transponders.bitfield import Register
from ..base import TagLF, ReaderLF, Modulation 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 64-bit encoded data as list of 0/1 values."""
return list(self._encoded) 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: def _get_response(self) -> RFFrame:
"""Return encoded data as RFFrame.""" """Return encoded data as RFFrame."""
from bitarray import bitarray from bitarray import bitarray
@@ -85,6 +94,109 @@ class EM4100Tag(TagLF):
return tag_id 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): class EM4100Reader(ReaderLF):
"""EM4100 reader — decode Manchester ASK response.""" """EM4100 reader — decode Manchester ASK response."""

301
tests/test_bitfield.py Normal file
View 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