fix(lf): T55xx config via rotation-to-preset match; label the emitted credential
Hardware-validated. Two real bugs in the T55xx config decode, both fixed by requiring a bit-rotation of the recovered repeating word to match a known preset: - Rotation ambiguity: a block read recovers the 32-bit config at an arbitrary bit offset. A real capture came back 0x000A4020 = the EM4100 word 0x00148040 rotated by one; the old "looks sane" scorer accepted the rotation verbatim and mislabeled it FSK1/RF-6. Now every rotation is tried and only a preset match (EM4100/HID/Indala/FDX-B/Viking/default) is accepted -> resolves to 0x00148040 = EM4100. - False positives: garbage config captures (all-0, all-1, a rotation of the tag's own emission) no longer pass -> decode returns None and the reliable emitted-stream decode carries the result. identify_lf label now names the chip + the actual decoded credential: "T5577 (EM4100 00FFFFFFFF)" instead of the config's guess. On real hardware this is now stable 4/4: config 00148040, emitted EM4100 00FFFFFFFF. Tests: rotation recovery (0x000A4020 -> EM4100), no-false-positive on all-1s/random, updated label expectation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -154,8 +154,11 @@ def identify_lf(device, emit=None) -> dict | None:
|
|||||||
if emitted is not None:
|
if emitted is not None:
|
||||||
emit(f" emitted: {credential_label(emitted)}")
|
emit(f" emitted: {credential_label(emitted)}")
|
||||||
|
|
||||||
# label: prefer the physical chip + what it emulates; else the emitted credential
|
# label: name the chip and what it's actually emitting (the reliable, decoded credential);
|
||||||
if chip:
|
# fall back to the config's emulation guess, or just the emitted credential.
|
||||||
|
if chip and emitted:
|
||||||
|
label = f"{chip} ({credential_label(emitted)})"
|
||||||
|
elif chip:
|
||||||
label = f"{chip} ({emulating})"
|
label = f"{chip} ({emulating})"
|
||||||
else:
|
else:
|
||||||
label = credential_label(emitted)
|
label = credential_label(emitted)
|
||||||
|
|||||||
@@ -240,61 +240,48 @@ def _fdxb_parse(frame) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def decode_t55xx_config(samples) -> dict | None:
|
def decode_t55xx_config(samples) -> dict | None:
|
||||||
"""Recover a T55xx block-0 config word from a block-0 read response (ASK/Manchester modes).
|
"""Recover a T55xx block-0 config from a block-0 read response (ASK/Manchester modes).
|
||||||
|
|
||||||
A block read streams the 32-bit word with no header, so alignment is found by the word
|
A block read streams the 32-bit config with no header, so the demod recovers it at *some* bit
|
||||||
repeating (block value at offset N equals offset N+32) and the candidate being a *sane*
|
rotation. We collect every 32-bit word that repeats one block-period later and accept one only
|
||||||
config (known modulation, valid master key, 1..7 data blocks). Best-effort by nature — the
|
when a bit-rotation of it exactly matches a known config preset (EM4100, HID, Indala, FDX-B,
|
||||||
reader labels it as a probable config, not gospel. Returns ``None`` for FSK/PSK-mode tags
|
Viking, default). That both resolves the rotation ambiguity (a real capture came back as
|
||||||
(handled elsewhere) or when nothing repeats cleanly."""
|
0x000A4020 = the EM4100 word 0x00148040 rotated by one) and rejects the spurious repeats
|
||||||
best = None
|
(all-0, all-1, a rotation of the tag's own emission) that a bare "looks like a sane config"
|
||||||
|
score waved through as false positives. Returns ``None`` when nothing rotates to a preset —
|
||||||
|
the emitted-stream decode carries the result instead."""
|
||||||
for bits in _manchester_bits(samples):
|
for bits in _manchester_bits(samples):
|
||||||
clean = [b for b in bits if b is not None]
|
clean = [b for b in bits if b is not None]
|
||||||
cand = _find_repeating_word(clean, 32)
|
for word in _repeating_words(clean, 32):
|
||||||
if cand is None:
|
for rot in range(32):
|
||||||
continue
|
rv = ((word << rot) | (word >> (32 - rot))) & 0xFFFFFFFF
|
||||||
cfg = T5577Config(cand)
|
if rv in _PRESET_BY_WORD:
|
||||||
score = _config_score(cfg)
|
cfg = T5577Config(rv)
|
||||||
if best is None or score > best[0]:
|
|
||||||
best = (score, cand, cfg)
|
|
||||||
if best is None or best[0] < 2: # need at least a known modulation + sane structure
|
|
||||||
return None
|
|
||||||
_, word, cfg = best
|
|
||||||
return {
|
return {
|
||||||
"protocol": "T5577",
|
"protocol": "T5577",
|
||||||
"config": word,
|
"config": rv,
|
||||||
"config_hex": f"{word:08X}",
|
"config_hex": f"{rv:08X}",
|
||||||
"modulation": cfg.modulation if isinstance(cfg.modulation, str) else f"0x{cfg.modulation:02X}",
|
"modulation": cfg.modulation if isinstance(cfg.modulation, str)
|
||||||
|
else f"0x{cfg.modulation:02X}",
|
||||||
"data_bit_rate": cfg.data_bit_rate,
|
"data_bit_rate": cfg.data_bit_rate,
|
||||||
"max_block": cfg.max_block,
|
"max_block": cfg.max_block,
|
||||||
"emulating": emulation_name(word, cfg),
|
"emulating": emulation_name(rv, cfg),
|
||||||
}
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _find_repeating_word(bits, width: int) -> int | None:
|
def _repeating_words(bits, width: int):
|
||||||
"""Smallest-offset ``width``-bit window that equals the next ``width`` bits (one block
|
"""Yield the distinct ``width``-bit windows that equal the next ``width`` bits (a block-read
|
||||||
period), scored by config sanity; returns the best repeating word's value or ``None``."""
|
candidate repeating one block-period later)."""
|
||||||
n = len(bits)
|
n = len(bits)
|
||||||
best = None
|
seen = set()
|
||||||
for start in range(max(0, n - 2 * width)):
|
for start in range(max(0, n - 2 * width)):
|
||||||
w = bits[start:start + width]
|
w = bits[start:start + width]
|
||||||
if w == bits[start + width:start + 2 * width]:
|
if w == bits[start + width:start + 2 * width]:
|
||||||
val = dsp.bits_to_int(w)
|
val = dsp.bits_to_int(w)
|
||||||
score = _config_score(T5577Config(val))
|
if val not in seen:
|
||||||
if best is None or score > best[0]:
|
seen.add(val)
|
||||||
best = (score, val)
|
yield val
|
||||||
return best[1] if best else None
|
|
||||||
|
|
||||||
|
|
||||||
def _config_score(cfg: T5577Config) -> int:
|
|
||||||
score = 0
|
|
||||||
if isinstance(cfg.modulation, str): # decoded to a known scheme
|
|
||||||
score += 1
|
|
||||||
if cfg.master_key in (0, 6, 9): # normal / extended-mode keys
|
|
||||||
score += 1
|
|
||||||
if 1 <= cfg.max_block <= 7:
|
|
||||||
score += 1
|
|
||||||
return score
|
|
||||||
|
|
||||||
|
|
||||||
def emulation_name(word: int, cfg: T5577Config | None = None) -> str:
|
def emulation_name(word: int, cfg: T5577Config | None = None) -> str:
|
||||||
|
|||||||
@@ -300,6 +300,19 @@ class TestT55xxConfig:
|
|||||||
assert r["modulation"] == "ASK"
|
assert r["modulation"] == "ASK"
|
||||||
assert r["emulating"] == "EM4100 / EM4102"
|
assert r["emulating"] == "EM4100 / EM4102"
|
||||||
|
|
||||||
|
def test_config_rotation_recovers_preset(self):
|
||||||
|
# a block read recovers the config at some bit rotation (hardware gave 0x000A4020 =
|
||||||
|
# 0x00148040 >> 1); rotate-matching must still resolve it to the EM4100 preset
|
||||||
|
rotated = ((CONFIG_EM4100 >> 1) | ((CONFIG_EM4100 & 1) << 31)) & 0xFFFFFFFF
|
||||||
|
assert rotated == 0x000A4020
|
||||||
|
r = protocols.decode_t55xx_config(_t55xx_config_env(rotated))
|
||||||
|
assert r and r["config"] == CONFIG_EM4100 and r["emulating"] == "EM4100 / EM4102"
|
||||||
|
|
||||||
|
def test_config_no_false_positive(self):
|
||||||
|
# repeating words that aren't any preset rotation (all-1s, random) -> None, not a bogus config
|
||||||
|
assert protocols.decode_t55xx_config(_t55xx_config_env(0xFFFFFFFF)) is None
|
||||||
|
assert protocols.decode_t55xx_config(_t55xx_config_env(0x12345678)) is None
|
||||||
|
|
||||||
def test_preset_labels(self):
|
def test_preset_labels(self):
|
||||||
assert protocols.emulation_name(T5577Config._PRESETS["hid"]) == "HID Prox"
|
assert protocols.emulation_name(T5577Config._PRESETS["hid"]) == "HID Prox"
|
||||||
assert protocols.emulation_name(T5577Config._PRESETS["indala"]) == "Indala"
|
assert protocols.emulation_name(T5577Config._PRESETS["indala"]) == "Indala"
|
||||||
@@ -332,7 +345,8 @@ class TestIdentifyLF:
|
|||||||
assert r["chip"] == "T5577"
|
assert r["chip"] == "T5577"
|
||||||
assert r["emulating"] == "EM4100 / EM4102"
|
assert r["emulating"] == "EM4100 / EM4102"
|
||||||
assert r["emitted"]["id_hex"] == "0102030405"
|
assert r["emitted"]["id_hex"] == "0102030405"
|
||||||
assert r["label"] == "T5577 (EM4100 / EM4102)"
|
# label names the chip + the actual decoded credential (not just the config's guess)
|
||||||
|
assert r["label"] == "T5577 (EM4100 0102030405)"
|
||||||
assert any("T55xx block 0" in l for l in lines)
|
assert any("T55xx block 0" in l for l in lines)
|
||||||
|
|
||||||
def test_native_em4100_no_config(self):
|
def test_native_em4100_no_config(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user