diff --git a/CLAUDE.md b/CLAUDE.md index 379c47a..3d7917d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/pm3py/sim/__init__.py b/pm3py/sim/__init__.py index a3b964f..678451b 100644 --- a/pm3py/sim/__init__.py +++ b/pm3py/sim/__init__.py @@ -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", diff --git a/pm3py/transponders/bitfield.py b/pm3py/transponders/bitfield.py new file mode 100644 index 0000000..be2f6dd --- /dev/null +++ b/pm3py/transponders/bitfield.py @@ -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`` + * ```` 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:]) diff --git a/pm3py/transponders/hf/iso14443a/nxp/mifare_classic.py b/pm3py/transponders/hf/iso14443a/nxp/mifare_classic.py index 6217fdd..8d23704 100644 --- a/pm3py/transponders/hf/iso14443a/nxp/mifare_classic.py +++ b/pm3py/transponders/hf/iso14443a/nxp/mifare_classic.py @@ -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 diff --git a/pm3py/transponders/hf/iso14443a/nxp/ntag21x.py b/pm3py/transponders/hf/iso14443a/nxp/ntag21x.py index 607b8d8..4687f09 100644 --- a/pm3py/transponders/hf/iso14443a/nxp/ntag21x.py +++ b/pm3py/transponders/hf/iso14443a/nxp/ntag21x.py @@ -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", diff --git a/pm3py/transponders/hf/iso14443a/nxp/type2.py b/pm3py/transponders/hf/iso14443a/nxp/type2.py index 1f46cd1..1977fa8 100644 --- a/pm3py/transponders/hf/iso14443a/nxp/type2.py +++ b/pm3py/transponders/hf/iso14443a/nxp/type2.py @@ -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", diff --git a/pm3py/transponders/hf/iso14443a/nxp/ultralight.py b/pm3py/transponders/hf/iso14443a/nxp/ultralight.py index ffe9d3b..f9120c5 100644 --- a/pm3py/transponders/hf/iso14443a/nxp/ultralight.py +++ b/pm3py/transponders/hf/iso14443a/nxp/ultralight.py @@ -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", diff --git a/pm3py/transponders/lf/atmel/t5577.py b/pm3py/transponders/lf/atmel/t5577.py index 5458243..b7648d5 100644 --- a/pm3py/transponders/lf/atmel/t5577.py +++ b/pm3py/transponders/lf/atmel/t5577.py @@ -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 diff --git a/pm3py/transponders/lf/em/em4100.py b/pm3py/transponders/lf/em/em4100.py index 4f6e380..40a57fb 100644 --- a/pm3py/transponders/lf/em/em4100.py +++ b/pm3py/transponders/lf/em/em4100.py @@ -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.""" diff --git a/tests/test_bitfield.py b/tests/test_bitfield.py new file mode 100644 index 0000000..4e435d5 --- /dev/null +++ b/tests/test_bitfield.py @@ -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