Add self-describing registers for the NTAG I2C plus (NT3H2111/2211),
decoded bit-exact from the NT3H2111 datasheet (Tables 10-14):
- NtagI2CConfig config registers E8h/E9h (NC_REG: pass-through, FD_ON/
FD_OFF, SRAM mirror, transfer dir; LAST_NDEF_BLOCK;
SRAM_MIRROR_BLOCK; watchdog; I2C_CLOCK_STR; REG_LOCK)
- NtagI2CSession session registers ECh/EDh — same NC_REG copy plus the
NS_REG status byte and NEG_AUTH_REACHED
- NtagI2CPtI2C PT_I2C (E7h): 2K_PROT / SRAM_PROT / I2C_PROT
Config and session share one _NtagI2CRegBlock base (byte 6 differs:
REG_LOCK vs NS_REG). Bound via tag.config / tag.session / tag.pt_i2c;
the fresh-tag decode matches the shipped defaults. 7 new tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
378 lines
14 KiB
Python
378 lines
14 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,
|
|
NtagI2CConfig, NtagI2CSession, NtagI2CPtI2C, NT3H2111, NT3H2211,
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
class TestNtagI2CRegisters:
|
|
def test_fresh_config_defaults(self):
|
|
# NT3H2111 ships E8=01 00 F8 48, E9=08 01 00 00 (datasheet defaults)
|
|
cfg = NT3H2111().config
|
|
assert cfg.transfer_dir == "NFC->I2C" # NC_REG bit0 = 1
|
|
assert cfg.pthru_on is False
|
|
assert cfg.sram_mirror_on is False
|
|
assert cfg.sram_mirror_block == 0xF8
|
|
assert cfg.watchdog == 0x0848
|
|
assert cfg.i2c_clock_str is True
|
|
assert cfg.reg_lock_i2c is False and cfg.reg_lock_nfc is False
|
|
|
|
def test_nc_reg_fields_roundtrip(self):
|
|
tag = NT3H2111()
|
|
cfg = tag.config
|
|
cfg.pthru_on = True
|
|
cfg.sram_mirror_on = True
|
|
cfg.fd_on = "tag selected" # 0b10
|
|
cfg.fd_off = "field off / last NDEF read" # 0b10
|
|
cfg.last_ndef_block = 0x2A
|
|
tag.config = cfg # program E8/E9
|
|
back = tag.config
|
|
assert back.pthru_on is True and back.sram_mirror_on is True
|
|
assert back.fd_on == "tag selected"
|
|
assert back.fd_off == "field off / last NDEF read"
|
|
assert back.last_ndef_block == 0x2A
|
|
|
|
def test_reg_lock(self):
|
|
cfg = NtagI2CConfig(0)
|
|
cfg.reg_lock_nfc = True
|
|
# REG_LOCK is byte 6 (bits 15:8); bit0 = NFC lock -> byte6 == 0x01
|
|
assert bytes(cfg)[6] == 0x01
|
|
assert cfg.reg_lock_nfc is True and cfg.reg_lock_i2c is False
|
|
|
|
def test_session_ns_reg_decode(self):
|
|
# NS_REG is byte 6 of the EC/ED block; set NDEF_DATA_READ + RF_FIELD_PRESENT + busy
|
|
sess = NtagI2CSession(0)
|
|
assert sess.rf_field_present is False
|
|
raw = int(NtagI2CSession(0))
|
|
# byte6 bit7 (ndef_data_read)=bit15, bit0 (rf_field_present)=bit8, bit1 (busy)=bit9
|
|
sess = NtagI2CSession((1 << 15) | (1 << 8) | (1 << 9))
|
|
assert sess.ndef_data_read is True
|
|
assert sess.rf_field_present is True
|
|
assert sess.eeprom_wr_busy is True
|
|
assert sess.rf_locked is False
|
|
|
|
def test_session_binding(self):
|
|
tag = NT3H2111()
|
|
sess = tag.session
|
|
# session copy shares NC_REG defaults (transfer_dir=1)
|
|
assert sess.transfer_dir == "NFC->I2C"
|
|
assert sess.rf_field_present is False # NS_REG starts clear
|
|
|
|
def test_pt_i2c(self):
|
|
pt = NtagI2CPtI2C(0)
|
|
pt.sram_prot = True
|
|
pt.i2c_prot = "protected area read-only" # 0b01, unambiguous
|
|
assert pt.sram_prot is True
|
|
assert pt.i2c_prot == "protected area read-only"
|
|
assert int(pt) == (1 << 2) | (1 << 0)
|
|
# datasheet 1Xb == "no access to protected area" for both codes 2 and 3
|
|
assert NtagI2CPtI2C(2).i2c_prot == "protected area no access"
|
|
assert NtagI2CPtI2C(3).i2c_prot == "protected area no access"
|
|
# tag binding
|
|
tag = NT3H2211()
|
|
tag.pt_i2c = pt
|
|
assert tag.pt_i2c.i2c_prot == "protected area read-only"
|
|
|
|
def test_repr_is_decode_table(self):
|
|
text = repr(NT3H2111().config)
|
|
assert "NTAG I2C config registers" in text
|
|
assert "transfer_dir" in text and "NFC->I2C" in text
|
|
assert "sram_mirror_block" in text
|