feat(lf): FSK stack + HID Prox demod (faithful lfdemod.c port)

New pm3py/lf/fsk.py — a function-for-function Python port of the Proxmark FSK
stack (firmware/common/lfdemod.c): fsk_wave_demod (count samples between 0->1
transitions: short wave = fc/8, long = fc/10) + aggregate_bits (runs -> clock-
resolution bits) = fskdemod, plus preamble_search, HIDdemodFSK, detectAWID hooks.
Ported to track the C reference (and thus real-tag output), not reinvented.

protocols.decode_hid: FSK2a + Manchester -> Wiegand. Format is told by frame
structure, not parity alone — the 26-bit H10301 wire frame carries a 0x20 header
at bit 37 + a sentinel 1 at bit 26 (per firmware add_HID_preamble); the 37-bit
H10304 frame is header-less (37-bit Manchester count, no 0x20). Each candidate is
still parity-validated by re-encoding through the shipped HIDProxTag, so a false
alignment can't slip through. Added to the identify decoder chain after EM4100.

Synthetic HID tests build the real add_HID_preamble framing (header+sentinel for
26-bit, bare 37-bit) and render fc/8/fc/10 square waves, so a green test means the
demod inverts what a real HID card / PM3 sim emits. Full suite 1232 green.

AWID (96-bit) framing decode + PSK/Indala + biphase/FDX-B queued next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-14 14:14:54 -07:00
parent 3c56d40667
commit e4a3aec46f
4 changed files with 323 additions and 2 deletions

View File

@@ -16,6 +16,7 @@ _SAMPLE_COUNT = 15000
# every emitted-credential decoder, tried in order (headered/most-specific first)
_DECODERS = (
protocols.decode_em4100,
protocols.decode_hid,
)

207
pm3py/lf/fsk.py Normal file
View File

@@ -0,0 +1,207 @@
"""FSK demodulation — a faithful Python port of the Proxmark FSK stack.
Ported function-for-function from ``firmware/common/lfdemod.c`` (``fsk_wave_demod`` +
``aggregate_bits`` = ``fskdemod``, ``preambleSearch``, ``HIDdemodFSK``, ``detectAWID``) so it
tracks the C reference — and therefore real-tag output — rather than being a fresh reinvention.
The C thresholds against the signal mean and counts samples between 0->1 transitions: a short
wave (~fc/8) is one symbol, a long wave (~fc/10) the other; ``aggregate_bits`` then collapses
runs of like waves into clock-resolution bits. HID/AWID are FSK2a (fc/10 high, fc/8 low, RF/50).
Everything downstream (Wiegand fields, parity) is validated by the shipped ``HIDProxTag`` model.
"""
from __future__ import annotations
# lfdemod.h NOISE_AMPLITUDE_THRESHOLD — below this peak-to-mean swing the capture is "noise".
# Conservative; a real hardware capture tunes this alongside the ASK path.
NOISE_AMPLITUDE_THRESHOLD = 8
def _mean(samples) -> float:
return sum(samples) / len(samples) if samples else 0.0
def is_noise(samples) -> bool:
"""Port of signalprop.isnoise: too little amplitude above the mean to carry data."""
if len(samples) < 64:
return True
return (max(samples) - _mean(samples)) < NOISE_AMPLITUDE_THRESHOLD
def find_mod_start(src, mean: float, exp_wave: int) -> int:
"""Port of ``findModStart`` — skip leading noise/slow chip start-up to the first clean wave."""
n = len(src)
if n < 21:
return 0
i = 1
wave_cnt = 0
thresh_cnt = 0
above = src[0] >= mean
while i < n - 20:
if src[i] < mean and above:
thresh_cnt += 1
if thresh_cnt > 2 and wave_cnt < exp_wave + 1:
break
above = False
wave_cnt = 0
elif src[i] >= mean and not above:
thresh_cnt += 1
if thresh_cnt > 2 and wave_cnt < exp_wave + 1:
break
above = True
wave_cnt = 0
else:
wave_cnt += 1
if thresh_cnt > 10:
break
i += 1
return i
def fsk_wave_demod(samples, fchigh: int = 10, fclow: int = 8):
"""Port of ``fsk_wave_demod``: envelope -> one bit per FSK wave (1 = short/fc-low wave,
0 = long/fc-high wave), counting samples between consecutive 0->1 transitions. Returns
``(bits, start_idx)``."""
n = len(samples)
if n < 1024:
return [], 0
mean = _mean(samples)
thr = [0 if s < mean else 1 for s in samples]
idx = find_mod_start(samples, mean, fchigh)
last_transition = idx
idx += 1
out: list[int] = []
start_idx = 0
pre_last = last = curr = 0
while idx < n - 20:
if thr[idx - 1] < thr[idx]: # 0 -> 1 transition
pre_last, last, curr = last, curr, idx - last_transition
if curr < fclow - 2:
pass # garbage noise
elif curr < fchigh - 1: # short wave -> 1
if len(out) > 1 and last > fchigh - 2 and pre_last < fchigh - 1:
out[-1] = 1 # fix a 9 surrounded by 8s
out.append(1)
if out and start_idx == 0:
start_idx = idx - fclow
elif curr > fchigh + 1 and len(out) < 3:
out = [] # leading garbage -> reset
elif curr == fclow + 1 and last == fclow - 1: # 7 then 9 -> two 8s
out.append(1)
if out and start_idx == 0:
start_idx = idx - fclow
else: # long wave -> 0
out.append(0)
if out and start_idx == 0:
start_idx = idx - fchigh
last_transition = idx
idx += 1
return out, start_idx
def aggregate_bits(bits, clk: int, invert: int, fchigh: int, fclow: int):
"""Port of ``aggregate_bits``: collapse runs of like waves into clock-resolution data bits
(a run of n waves at field-clock fc spans n*fc/clk bits)."""
if not bits:
return []
out: list[int] = []
lastval = bits[0]
n = 1
hclk = clk // 2
i = 1
for i in range(1, len(bits)):
n += 1
if bits[i] == lastval:
continue
if bits[i - 1] == 1:
n = (n * fclow + hclk) // clk
else:
n = (n * fchigh + hclk) // clk
if n == 0:
n = 1
out.extend([bits[i - 1] ^ invert] * n)
n = 0
lastval = bits[i]
if n > clk // fchigh and len(bits) >= 2: # trailing same-frequency run
if bits[i - 1] == 1:
n = (n * fclow + hclk) // clk
else:
n = (n * fchigh + hclk) // clk
out.extend([bits[-1] ^ invert] * n)
return out
def fskdemod(samples, rflen: int = 50, invert: int = 1, fchigh: int = 10, fclow: int = 8):
"""Full FSK demod: envelope -> decoded 1s/0s (``fsk_wave_demod`` + ``aggregate_bits``)."""
if is_noise(samples):
return []
waves, _ = fsk_wave_demod(list(samples), fchigh, fclow)
return aggregate_bits(waves, rflen, invert, fchigh, fclow)
def preamble_search(bits, preamble):
"""Port of ``preambleSearch``: find ``preamble`` in ``bits``. Returns ``(found, start_idx,
size)`` where ``size`` is the gap to the second occurrence (the frame length) when there is
one, else the remaining length."""
plen = len(preamble)
size = len(bits)
if size <= plen:
return (False, 0, size)
found = 0
start = 0
for idx in range(size - plen):
if bits[idx:idx + plen] == preamble:
found += 1
if found == 1:
start = idx
elif found == 2:
return (True, start, idx - start)
return (found > 0, start, size)
# HID: 00011101 start-of-frame, then Manchester (10 -> 1, 01 -> 0)
HID_PREAMBLE = [0, 0, 0, 1, 1, 1, 0, 1]
# AWID: 00000001 preamble, 96-bit frame
AWID_PREAMBLE = [0, 0, 0, 0, 0, 0, 0, 1]
_M32 = 0xFFFFFFFF
def hid_demod_fsk(samples):
"""Port of ``HIDdemodFSK``: FSK2a demod + preamble + Manchester -> ``(hi2, hi, lo, nbits)``
96-bit shift register plus the number of data bits Manchester-decoded (needed to tell the
header-less 37-bit format from the 26-bit one), or ``None``. Field extraction is the caller's."""
bits = fskdemod(samples, 50, 1, 10, 8)
if len(bits) < 96 * 2:
return None
found, start, size = preamble_search(bits, HID_PREAMBLE)
if not found:
return None
num_start = start + len(HID_PREAMBLE)
hi2 = hi = lo = 0
nbits = 0
idx = num_start
while (idx - num_start) < size - len(HID_PREAMBLE) and idx + 1 < len(bits):
a, b = bits[idx], bits[idx + 1]
if a == b:
break # not Manchester -> end of frame
hi2 = ((hi2 << 1) | (hi >> 31)) & _M32
hi = ((hi << 1) | (lo >> 31)) & _M32
lo = ((lo << 1) | (1 if (a and not b) else 0)) & _M32
nbits += 1
idx += 2
if hi == 0 and lo == 0:
return None
return (hi2, hi, lo, nbits)
def awid_demod_fsk(samples):
"""Port of ``detectAWID``: FSK2a demod + 00000001 preamble -> the 96 frame bits, or ``None``."""
bits = fskdemod(samples, 50, 1, 10, 8)
if len(bits) < 96:
return None
found, start, size = preamble_search(bits, AWID_PREAMBLE)
if not found or size != 96:
return None
return bits[start:start + 96]

View File

@@ -8,11 +8,12 @@ exactly one place. A decoder returns a structured dict on a clean, validated fra
from __future__ import annotations
from . import dsp
from . import fsk
# Import the models through the ``pm3py.sim`` package (not their deep module paths): sim's
# __init__ re-exports them, and going through it forces sim to fully initialize first, avoiding
# a circular import (em4100 -> pm3py.sim.frame -> sim.__init__ -> em4100) when pm3py.lf is the
# first thing loaded.
from pm3py.sim import EM4100Code, T5577Config
from pm3py.sim import EM4100Code, T5577Config, HIDProxTag
# config word -> preset name (invert T5577Config._PRESETS) for "emulating ..." labelling
_PRESET_BY_WORD = {word: name for name, word in T5577Config._PRESETS.items()}
@@ -64,6 +65,44 @@ def _find_em4100(bits) -> EM4100Code | None:
return None
def _hid_field(full: int, fmt: str, nbits: int) -> dict | None:
"""Extract the low ``nbits`` as a ``fmt`` Wiegand credential, accepting only if re-encoding
the decoded facility+card reproduces the bits exactly (proves both parities)."""
wiegand = [(full >> (nbits - 1 - i)) & 1 for i in range(nbits)]
dec = HIDProxTag.decode_wiegand(wiegand, fmt)
fc, cn = dec["facility_code"], dec["card_number"]
if HIDProxTag._encode_wiegand(fc, cn, fmt) != wiegand:
return None
return {
"protocol": "HID Prox", "format": fmt, "facility_code": fc, "card_number": cn,
"wiegand": f"{full & ((1 << nbits) - 1):0{(nbits + 3) // 4}X}",
}
def decode_hid(samples) -> dict | None:
"""Demodulate an HID Prox envelope (FSK2a + Manchester) to its Wiegand credential.
Format is told by frame *structure*, not parity alone (2 parity bits can't separate 26- from
37-bit): the 26-bit H10301 wire frame carries a 0x20 header at bit 37 and a sentinel 1 at
bit 26 with the Wiegand below; the 37-bit H10304 frame is header-less, so a 37-bit Manchester
count with no 0x20 marks it. Each candidate is still parity-validated by re-encoding through
the shipped ``HIDProxTag``."""
r = fsk.hid_demod_fsk(samples)
if r is None:
return None
hi2, hi, lo, nbits = r
full = (hi << 32) | lo # low 64 bits cover 26- and 37-bit formats
if (hi & 0x20) and (lo & (1 << 26)): # 26-bit standard header + sentinel
got = _hid_field(full, "H10301", 26)
if got:
return got
if nbits == 37 and not (hi & 0x20): # header-less 37-bit frame
got = _hid_field(full, "H10304", 37)
if got:
return got
return None
def decode_t55xx_config(samples) -> dict | None:
"""Recover a T55xx block-0 config word from a block-0 read response (ASK/Manchester modes).