Files
pm3py/tests/test_bitfield.py
michael 2063a33a2b 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>
2026-07-14 08:36:40 -07:00

302 lines
11 KiB
Python

"""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