keeloq-check.py
Files / cc1101-scanner / keeloq-check.py
Files in cc1101-scanner/
Directorycc1101-scanner/
- 70-ftdi-ft232h.rules 196 bytes
- keeloq-capture-ft232h.py 9.5 KB
- keeloq-check.py 9.6 KB
- LICENSE 1.0 KB
- rf-scan-ft232h.py 41.0 KB
From CC1101 on an FT232H: a 433 MHz scanner in the terminal. MIT licence: LICENSE.
#!/usr/bin/env python3"""Check captured KeeLoq presses (Richmond gate remote, HCS300) against the public key list.
Reads an ESPHome log from the CC1101 capture rig (keeloq-capture.yaml): the rig's own"KEELOQ serial=... button=... hop=..." lines, and "Received Raw:" timings, which this scriptdecodes itself (ESPHome's decoder gives up if noise sits before the preamble).
For every key in keeloq_mfcodes_user it tries the learning schemes that work without a seed(the HCS300 sends none): simple, normal, magic XOR 1, magic serial 1/2/3, each with the keyas written and byte-reversed. A key matches when two or more presses of one remote (anybuttons) all decrypt with the right button bits, the same discrimination bits, and a counterthat goes up by 1 to 32 from press to press. A single press whose discrimination bits equalthe serial's low 10 bits (Microchip's default) is only shown as a candidate.
Usage: python3 -I keeloq-check.py capture.log [keyfile] python3 -I keeloq-check.py --self-test"""import reimport statisticsimport sysfrom pathlib import Path
NLF = 0x3A5C742EM32 = 0xFFFFFFFFM64 = (1 << 64) - 1KEYFILE = Path(__file__).with_name("keeloq_mfcodes_user")TEST_KEYS = [(0x0123456789ABCDEF, 1, "made-up test key")] # self-tests without the key list
def bit(x, n): return (x >> n) & 1
def encrypt(data, key): x = data for r in range(528): nlf = bit(NLF, bit(x, 1) | bit(x, 9) << 1 | bit(x, 20) << 2 | bit(x, 26) << 3 | bit(x, 31) << 4) x = (x >> 1) | ((bit(x, 0) ^ bit(x, 16) ^ bit(key, r & 63) ^ nlf) << 31) return x
def decrypt(data, key): x = data for r in range(528): nlf = bit(NLF, bit(x, 0) | bit(x, 8) << 1 | bit(x, 19) << 2 | bit(x, 25) << 3 | bit(x, 30) << 4) x = ((x << 1) & M32) | (bit(x, 31) ^ bit(x, 15) ^ bit(key, (15 - r) & 63) ^ nlf) return x
# Device key from the maker's key, as Flipper's keeloq_common.c does it. fix = button << 28 | serialdef simple(fix, mk): return mk
def normal(fix, mk): s = fix & 0x0FFFFFFF return decrypt(s | 0x60000000, mk) << 32 | decrypt(s | 0x20000000, mk)
def magic_xor_1(fix, mk): s = fix & 0x0FFFFFFF return (s << 32 | s) ^ mk
def magic_serial_1(fix, mk): return (mk & M32) | ((fix << 40) & M64) | ((((fix & 0xFF) + ((fix >> 8) & 0xFF)) & 0xFF) << 32)
def magic_serial_2(fix, mk): return int.from_bytes(fix.to_bytes(4, "little"), "big") << 32 | (mk & M32)
def magic_serial_3(fix, mk): return (mk & 0xFFFFFFFFFF000000) | (fix & 0xFFFFFF)
SCHEMES = [simple, normal, magic_xor_1, magic_serial_1, magic_serial_2, magic_serial_3]
def load_keys(path): keys = [] for line in Path(path).read_text().splitlines(): m = re.match(r"([0-9A-Fa-f]{16}):(\d+):(.+)", line.strip()) if m: keys.append((int(m[1], 16), int(m[2]), m[3])) return keys
def decode_raw(durations): """Find KeeLoq frames in raw timings (+mark, -space, ยตs): 12-pulse preamble, 10 TE gap, 66 PWM bits. 11 preamble pulses are enough: noise just before the frame can merge with the first one.""" frames = [] i = 0 while i < len(durations) - 22: pre = durations[i : i + 21] te = statistics.median(abs(d) for d in pre) if not (60 < te < 800 and pre[0] > 0 and all(0.5 * te < abs(d) < 1.6 * te for d in pre)): i += 1 continue j = i + 21 # the long header space; a longer preamble matches further along if j >= len(durations) or durations[j] > -6 * te: i += 1 continue bits = [] k = j + 1 while len(bits) < 66 and k + 1 <= len(durations): mark = durations[k] space = -durations[k + 1] if k + 1 < len(durations) else 10 * te if mark <= 0 or not (0.5 * te < mark < 2.6 * te): break bits.append(1 if mark < 1.5 * te else 0) # short mark = 1, long mark = 0 k += 2 if space > 4 * te: break if len(bits) == 66: v = sum(b << n for n, b in enumerate(bits)) fix = (v >> 32) & M32 frames.append({"serial": fix & 0x0FFFFFFF, "button": fix >> 28, "hop": v & M32, "te": round(te)}) i = k else: i += 1 return frames
def read_log(path): frames, raw = [], None for line in Path(path).read_text(errors="replace").splitlines(): m = re.search(r"KEELOQ serial=(?:0x)?([0-9a-fA-F]+) button=(?:0x)?([0-9a-fA-F]+) hop=(?:0x)?([0-9a-fA-F]+)", line) if m: frames.append({"serial": int(m[1], 16), "button": int(m[2], 16), "hop": int(m[3], 16), "te": None}) continue if "remote.raw" not in line: continue text = line.rsplit("]: ", 1)[-1] if "Received Raw:" in text: if raw: frames += decode_raw(raw) raw = [] text = text.split("Received Raw:", 1)[1] if raw is not None: raw += [int(n) for n in re.findall(r"-?\d+", text)] if raw: frames += decode_raw(raw) # A held button repeats the same frame: keep each one once, in order seen, unique = set(), [] for f in frames: k = (f["serial"], f["button"], f["hop"]) if k not in seen: seen.add(k) unique.append(f) return unique
def check(frames, keys): """Return (confirmed, candidates). One press alone is only a candidate: with about 1,500 key and scheme pairs, a random press passes the 14-bit check about 1 time in 11.""" confirmed, candidates = [], [] for mk, ktype, name in keys: for order, key in (("as written", mk), ("byte-reversed", int.from_bytes(mk.to_bytes(8, "big"), "little"))): for scheme in SCHEMES: by_serial = {} # the HCS300 has one counter for all its buttons for f in frames: fix = f["button"] << 28 | f["serial"] d = decrypt(f["hop"], scheme(fix, key)) by_serial.setdefault(f["serial"], []).append((d >> 28 == f["button"], (d >> 16) & 0x3FF, d & 0xFFFF)) for serial, presses in by_serial.items(): hit = (name, ktype, scheme.__name__, order, serial, [p[2] for p in presses]) counters = hit[5] if ( len(presses) >= 2 and all(p[0] for p in presses) and len({p[1] for p in presses}) == 1 and all(0 < (b - a) % 0x10000 <= 32 for a, b in zip(counters, counters[1:])) ): confirmed.append(hit) elif any(p[0] and p[1] == serial & 0x3FF for p in presses): candidates.append(hit) return confirmed, candidates
def report(frames, keys): if not frames: print("๐ด no KeeLoq frames found in the log") return False print(f"๐ฑ {len(frames)} distinct press(es), {len(keys)} keys ร {len(SCHEMES)} schemes ร 2 byte orders") for f in frames: te = f" TE {f['te']} ยตs" if f["te"] else "" print(f"๐ชต serial 0x{f['serial']:07x} button 0x{f['button']:x} hop 0x{f['hop']:08x}{te}") confirmed, candidates = check(frames, keys) for name, ktype, scheme, order, serial, counters in confirmed: print(f"๐ข MATCH {name} (type {ktype}), {scheme}, key {order}: serial 0x{serial:07x}, counters {counters}") for name, ktype, scheme, order, serial, counters in candidates: print(f"๐ one press fits {name} (type {ktype}), {scheme}, key {order}: serial 0x{serial:07x}; chance unless a second press fits too") if not confirmed: print("๐ด no key in the list decrypts two presses of one remote") return bool(confirmed)
def self_test(): # Published KeeLoq test vector assert encrypt(0xF741E2DB, 0x5CEC6701B79FD949) == 0xE44F4CDF, "encrypt vector" assert decrypt(0xE44F4CDF, 0x5CEC6701B79FD949) == 0xF741E2DB, "decrypt vector" print("๐ข KeeLoq test vector") keys = load_keys(KEYFILE) if KEYFILE.exists() else TEST_KEYS mk, _, name = keys[len(keys) // 2] serial, button = 0x0ABCDEF, 0x2 dev = normal(button << 28 | serial, mk) frames = [] for cnt in (0x0101, 0x0102): hop = encrypt(button << 28 | (serial & 0x3FF) << 16 | cnt, dev) v = (button << 28 | serial) << 32 | hop raw = [400, -400] * 11 + [400, -4000] for n in range(66): raw += [400, -800] if bit(v, n) else [800, -400] raw[-1] = -15000 frames += decode_raw([120, -90, 300] + raw) # noise before the preamble assert len(frames) == 2 and frames[0]["serial"] == serial, f"raw decode: {frames}" print("๐ข raw decode of two synthetic presses") confirmed, _ = check(frames, keys) assert [(h[0], h[2], h[3]) for h in confirmed] == [(name, "normal", "as written")], f"wrong matches: {confirmed}" print(f"๐ข two synthetic presses made with '{name}' (normal learning) match it, and nothing else") random_presses = [{"serial": 0x0ABCDEF, "button": 2, "hop": encrypt(n, 0x1234567890ABCDEF), "te": None} for n in range(4)] assert not check(random_presses, keys)[0], "random presses matched" print("๐ข 4 presses from an unknown key match nothing") print("\n๐ข SUCCESS")
if __name__ == "__main__": if sys.argv[1:2] == ["--self-test"]: self_test() sys.exit(0) if len(sys.argv) < 2: print(__doc__) sys.exit(2) ok = report(read_log(sys.argv[1]), load_keys(sys.argv[2] if len(sys.argv) > 2 else KEYFILE)) print("\n๐ข SUCCESS: a known key fits" if ok else "\n๐ด FAILED: no known key fits") sys.exit(0 if ok else 1)Text, photos and drawings: CC BY 4.0. Code: MIT. Share and adapt them, crediting HomeLab Series (blog.jacq.cc). Licence and privacy