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:
301
tests/test_bitfield.py
Normal file
301
tests/test_bitfield.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Bit-field register infrastructure + the T5577 config register. Hardware-free."""
|
||||
import pytest
|
||||
|
||||
from pm3py.transponders.bitfield import BitField, Register
|
||||
from pm3py.sim import (
|
||||
T5577Config, T5577Tag,
|
||||
Ntag21xConfig, NTAG213, MF0UL21, MifareUltralightC,
|
||||
MifareAccessConditions, MifareClassicTag,
|
||||
EM4100Code, EM4100Tag,
|
||||
)
|
||||
|
||||
|
||||
class Demo(Register):
|
||||
_width_bits = 16
|
||||
_label = "demo"
|
||||
top = BitField(15, 12, doc="top nibble")
|
||||
flag = BitField(3, doc="a flag")
|
||||
mode = BitField(1, 0, values={0: "a", 1: "b", 2: "c"}, doc="a labelled mode")
|
||||
|
||||
|
||||
class TestBitFieldCore:
|
||||
def test_read_fields(self):
|
||||
d = Demo(0xF008) # top=0xF, flag(bit3)=1, mode=0
|
||||
assert d.top == 0xF
|
||||
assert d.flag is True
|
||||
assert d.mode == "a"
|
||||
|
||||
def test_single_bit_is_bool(self):
|
||||
assert Demo(0).flag is False
|
||||
assert Demo(0x8).flag is True
|
||||
|
||||
def test_write_by_field_is_isolated(self):
|
||||
d = Demo(0)
|
||||
d.top = 0xA
|
||||
assert d.value == 0xA000
|
||||
d.flag = True
|
||||
assert d.value == 0xA008
|
||||
d.top = 0x5 # must not disturb the flag bit
|
||||
assert d.value == 0x5008
|
||||
|
||||
def test_values_map_both_directions(self):
|
||||
d = Demo(0)
|
||||
d.mode = "c"
|
||||
assert d.mode == "c" and d.value == 0x0002
|
||||
d.mode = 1 # raw code still accepted
|
||||
assert d.mode == "b"
|
||||
|
||||
def test_overflow_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
Demo(0).top = 0x1F # 5 bits into a 4-bit field
|
||||
|
||||
def test_int_and_index(self):
|
||||
d = Demo(0x1234)
|
||||
assert int(d) == 0x1234
|
||||
assert hex(d) == "0x1234"
|
||||
|
||||
def test_fields_ordered_high_to_low(self):
|
||||
assert [n for n, _ in Demo.fields()] == ["top", "flag", "mode"]
|
||||
|
||||
def test_descriptor_doc_visible(self):
|
||||
# the completion tooltip / `field?` help comes from the descriptor docstring
|
||||
assert Demo.top.__doc__ == "top nibble"
|
||||
|
||||
|
||||
# The three anchor constants below are lifted from the Proxmark3 config-block table
|
||||
# (client/src/cmdlft55xx.h) with their documented meanings — they pin the bit layout.
|
||||
class TestT5577Decode:
|
||||
def test_em4100(self):
|
||||
cfg = T5577Config(0x00148040)
|
||||
assert cfg.modulation == "ASK"
|
||||
assert cfg.data_bit_rate == 64
|
||||
assert cfg.max_block == 2
|
||||
assert cfg.x_mode is False
|
||||
assert cfg.st is False
|
||||
assert cfg.master_key == "0 (normal)"
|
||||
|
||||
def test_default_ask_manchester(self):
|
||||
cfg = T5577Config(0x000880E8) # ASK, RF/32, STT, 7 data blocks
|
||||
assert cfg.modulation == "ASK"
|
||||
assert cfg.data_bit_rate == 32
|
||||
assert cfg.max_block == 7
|
||||
assert cfg.st is True
|
||||
|
||||
def test_pac_is_nrz(self):
|
||||
cfg = T5577Config(0x00080080) # NRZ, RF/32, 4 data blocks
|
||||
assert cfg.modulation == "direct"
|
||||
assert cfg.max_block == 4
|
||||
|
||||
def test_hid_fsk(self):
|
||||
cfg = T5577Config(0x00107060) # FSK2a, RF/50, 3 blocks
|
||||
assert cfg.modulation == "FSK2a"
|
||||
assert cfg.data_bit_rate == 50
|
||||
assert cfg.max_block == 3
|
||||
|
||||
def test_xmode_data_rate(self):
|
||||
cfg = T5577Config(0x903F0082) # fdx-b, x-mode
|
||||
assert cfg.x_mode is True
|
||||
assert cfg.modulation == "biphase"
|
||||
assert cfg.data_bit_rate == 32 # 2*code+2 over the 6-bit field
|
||||
|
||||
|
||||
class TestT5577Build:
|
||||
def test_preset_roundtrip(self):
|
||||
assert int(T5577Config.preset("em4100")) == 0x00148040
|
||||
|
||||
def test_unknown_preset(self):
|
||||
with pytest.raises(ValueError):
|
||||
T5577Config.preset("nope")
|
||||
|
||||
def test_edit_by_field(self):
|
||||
cfg = T5577Config.preset("em4100")
|
||||
cfg.max_block = 3
|
||||
cfg.st = True
|
||||
# max_block bits 7:5 -> 3<<5 = 0x60; st bit3 -> 0x08; base had maxblk 2 (0x40)
|
||||
assert cfg.max_block == 3 and cfg.st is True
|
||||
assert int(cfg) == (0x00148040 & ~(0x07 << 5)) | (3 << 5) | (1 << 3)
|
||||
|
||||
def test_set_data_rate_standard(self):
|
||||
cfg = T5577Config(0x00148040)
|
||||
cfg.data_bit_rate = 32
|
||||
assert cfg.data_bit_rate == 32
|
||||
with pytest.raises(ValueError):
|
||||
cfg.data_bit_rate = 33 # not a legal standard divider
|
||||
|
||||
def test_set_modulation_by_name(self):
|
||||
cfg = T5577Config(0)
|
||||
cfg.modulation = "FSK2"
|
||||
assert cfg.modulation == "FSK2"
|
||||
assert cfg.modulation != "ASK"
|
||||
|
||||
def test_decode_mapping_and_repr(self):
|
||||
cfg = T5577Config(0x00148040)
|
||||
d = cfg.decode()
|
||||
assert d["modulation"] == "ASK"
|
||||
assert d["data_bit_rate"] == "RF/64"
|
||||
assert d["max_block"] == "2"
|
||||
text = repr(cfg) # not a tty in tests -> plain text
|
||||
assert "T5577 config (block0) = 0x00148040" in text
|
||||
assert "modulation" in text and "ASK" in text
|
||||
assert "data_bit_rate" in text and "RF/64" in text
|
||||
|
||||
|
||||
class TestT5577TagBinding:
|
||||
def test_config_reads_block0(self):
|
||||
tag = T5577Tag()
|
||||
tag.blocks[0] = 0x00148040
|
||||
assert tag.config.modulation == "ASK"
|
||||
assert tag.config.max_block == 2
|
||||
|
||||
def test_assign_config_programs_block0(self):
|
||||
tag = T5577Tag()
|
||||
cfg = T5577Config.preset("hid")
|
||||
tag.config = cfg
|
||||
assert tag.blocks[0] == 0x00107060
|
||||
# accepts a raw int too
|
||||
tag.config = 0x00148040
|
||||
assert tag.config.modulation == "ASK"
|
||||
|
||||
|
||||
class TestNtag21xConfig:
|
||||
def test_fields_span_both_pages(self):
|
||||
cfg = Ntag21xConfig(0)
|
||||
cfg.auth0 = 0x04 # CFG0 byte 3
|
||||
cfg.prot = True # CFG1 byte 0 bit 7 (ACCESS.PROT)
|
||||
# CFG0 = 00 00 00 04, CFG1 = 80 00 00 00
|
||||
assert bytes(cfg) == b"\x00\x00\x00\x04\x80\x00\x00\x00"
|
||||
assert cfg.auth0 == 0x04 and cfg.prot is True
|
||||
|
||||
def test_mirror_and_counter_fields(self):
|
||||
cfg = Ntag21xConfig(0)
|
||||
cfg.mirror_conf = "UID+counter"
|
||||
cfg.mirror_page = 0x10
|
||||
cfg.nfc_cnt_en = True
|
||||
cfg.authlim = 5
|
||||
assert cfg.mirror_conf == "UID+counter"
|
||||
assert cfg.mirror_page == 0x10
|
||||
assert cfg.nfc_cnt_en is True and cfg.authlim == 5
|
||||
# re-decoding the raw word must reproduce everything
|
||||
assert Ntag21xConfig(int(cfg)).decode() == cfg.decode()
|
||||
|
||||
def test_tag_binding_roundtrip(self):
|
||||
tag = NTAG213() # fresh: auth0=0xFF, prot off
|
||||
assert tag.config.auth0 == 0xFF
|
||||
assert tag.config.prot is False
|
||||
cfg = tag.config
|
||||
cfg.auth0 = 0x10
|
||||
cfg.prot = True
|
||||
tag.config = cfg # program both config pages
|
||||
assert tag.config.auth0 == 0x10 and tag.config.prot is True
|
||||
|
||||
|
||||
class TestUltralightConfig:
|
||||
def test_ev1_uses_shared_fields(self):
|
||||
tag = MF0UL21()
|
||||
cfg = tag.config
|
||||
cfg.auth0 = 0x08
|
||||
cfg.cfglck = True
|
||||
tag.config = cfg
|
||||
assert tag.config.auth0 == 0x08 and tag.config.cfglck is True
|
||||
assert "NTAG21x" not in repr(cfg) # EV1 has no mirror/counter fields
|
||||
|
||||
def test_ulc_auth_config(self):
|
||||
tag = MifareUltralightC(auth0=0x30, auth1_read_protect=True)
|
||||
cfg = tag.config
|
||||
assert cfg.auth0 == 0x30
|
||||
assert cfg.auth1_prot == "read+write" # AUTH1 bit0 == 0
|
||||
cfg.auth1_prot = "write-only"
|
||||
cfg.auth0 = 0x2A
|
||||
tag.config = cfg
|
||||
assert tag.config.auth0 == 0x2A
|
||||
assert tag.config.auth1_prot == "write-only"
|
||||
|
||||
|
||||
class TestMifareAccessConditions:
|
||||
def test_factory_default_trailer(self):
|
||||
# FF 07 80 access bytes + GPB 0x69 = the transport/default trailer
|
||||
ac = MifareAccessConditions(0xFF078069)
|
||||
assert ac.valid is True
|
||||
assert ac.block0 == 0 and ac.block1 == 0 and ac.block2 == 0
|
||||
assert ac.trailer == 1
|
||||
assert ac.gpb == 0x69
|
||||
|
||||
def test_build_matches_default(self):
|
||||
ac = MifareAccessConditions.build(0, 0, 0, trailer=1, gpb=0x69)
|
||||
assert int(ac) == 0xFF078069
|
||||
assert bytes(ac) == b"\xFF\x07\x80\x69" # trailer bytes 6..9
|
||||
|
||||
def test_build_is_always_valid(self):
|
||||
for t in range(8):
|
||||
for d in range(8):
|
||||
assert MifareAccessConditions.build(d, d, d, trailer=t).valid
|
||||
|
||||
def test_all_locked(self):
|
||||
ac = MifareAccessConditions.build(7, 7, 7, trailer=7)
|
||||
assert ac.trailer == 7 and ac.block0 == 7
|
||||
assert "no access" in ac.describe(0)
|
||||
assert "locked" in ac.describe(3)
|
||||
|
||||
def test_inconsistent_copy_flagged(self):
|
||||
ac = MifareAccessConditions(0x00000000) # C == ~C == 0, impossible
|
||||
assert ac.valid is False
|
||||
assert "INVALID" in repr(ac)
|
||||
|
||||
def test_bad_condition_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
MifareAccessConditions.build(trailer=8)
|
||||
|
||||
def test_describe_and_decode(self):
|
||||
ac = MifareAccessConditions(0xFF078069)
|
||||
assert "transport" in ac.describe(0)
|
||||
assert "transport/default" in ac.describe(3)
|
||||
d = ac.decode()
|
||||
assert d["block0"].startswith("0")
|
||||
assert d["trailer"].startswith("1")
|
||||
|
||||
def test_tag_access_reads_trailer(self):
|
||||
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04")
|
||||
# write a full trailer for sector 1: KeyA(6) + FF0780 + GPB69 + KeyB(6)
|
||||
trailer_blk = tag._sector_trailer_block(1)
|
||||
trailer = b"\xA0\xA1\xA2\xA3\xA4\xA5" + b"\xFF\x07\x80\x69" + b"\xB0\xB1\xB2\xB3\xB4\xB5"
|
||||
tag.write_block_raw(trailer_blk, trailer)
|
||||
ac = tag.access(1)
|
||||
assert ac.trailer == 1 and ac.gpb == 0x69 and ac.valid
|
||||
|
||||
|
||||
class TestEM4100Code:
|
||||
def test_build_roundtrip(self):
|
||||
code = EM4100Code.build(0x0102030405)
|
||||
assert code.tag_id == 0x0102030405
|
||||
assert code.customer_id == 0x01
|
||||
assert code.card_number == 0x02030405
|
||||
assert code.valid
|
||||
|
||||
def test_build_is_always_valid(self):
|
||||
for tid in (0x0, 0xFFFFFFFFFF, 0x1234567890, 0xDEADBEEF00, 0xA5A5A5A5A5):
|
||||
assert EM4100Code.build(tid).valid
|
||||
|
||||
def test_matches_model_encoding(self):
|
||||
# the register must agree bit-for-bit with the tag's own encoder
|
||||
tag = EM4100Tag(0xCAFEBABE99)
|
||||
assert tag.code.valid
|
||||
assert tag.code.tag_id == 0xCAFEBABE99
|
||||
assert int(tag.code) == int(EM4100Code.build(0xCAFEBABE99))
|
||||
|
||||
def test_flipped_data_bit_breaks_row_parity(self):
|
||||
code = EM4100Code.build(0x0102030405)
|
||||
broken = EM4100Code(int(code) ^ (1 << 40)) # flip a data bit in the rows region
|
||||
assert broken.valid is False
|
||||
assert broken._bad_rows() # at least one row parity fails
|
||||
|
||||
def test_bad_header_and_stop_detected(self):
|
||||
good = EM4100Code.build(0)
|
||||
assert good.valid
|
||||
assert EM4100Code(int(good) | 1).valid is False # stop bit set
|
||||
assert EM4100Code(int(good) & ~(0x1FF << 55)).valid is False # header zeroed
|
||||
|
||||
def test_repr_shows_id_and_validity(self):
|
||||
text = repr(EM4100Code.build(0x0102030405))
|
||||
assert "EM4100 64-bit code" in text
|
||||
assert "0102030405" in text
|
||||
assert "valid" in text and "yes" in text
|
||||
Reference in New Issue
Block a user