#!/usr/bin/env python3 """IDS-CAN command-auth cipher — the gate on the CAN write path. SOLVED 2026-06-12. The challenge->response transform is a TEA/XTEA-family 32-round Feistel keyed by a per-session 32-bit key ("Cypher"). `response = encrypt(challenge, REMOTE_CONTROL)`, both 32-bit **big-endian** (the 4 payload bytes after the "00 04" prefix in the page-42 challenge / page-43 response frames). Verified 51/51 against the captured pairs in sniff/2A-auth-pairs.txt + sniff/auth-pairs-multinode-2026-06-11.txt, across nodes 2A/61/75/F8 — one global session key, not per-node. The session "Cypher" is the only key; the round constants are baked in. Five sessions exist (the joke hex values confirm they're the genuine keys): MANUFACTURING 0xB16BA115 DIAGNOSTIC 0xBABECAFE REPROGRAMMING 0xDEADBEEF REMOTE_CONTROL 0xB16B00B5 DAQ 0x0B00B135 REMOTE_CONTROL is the one that gates on/off/move commands. Unlock sequence on the bus (controller 01 <-> module, 29-bit extended frames): 01->node page42 DLC2 "00 04" # arm node->01 page42 DLC6 "00 04 " # module's challenge 01->node page43 DLC6 "00 04 " # RR = remote_control_response(CC) node->01 page43 DLC2 "00 04" # ack 01->node 0x0006 x3 # opcode now honored (01=on,00=off,02=retract) """ from __future__ import annotations MASK = 0xFFFFFFFF DELTA = 0x9E3779B9 # 2654435769 — the TEA golden-ratio delta # Per-session key constants ("Cypher") SESSION_CYPHER = { "MANUFACTURING": 0xB16BA115, "DIAGNOSTIC": 0xBABECAFE, "REPROGRAMMING": 0xDEADBEEF, "REMOTE_CONTROL": 0xB16B00B5, "DAQ": 0x0B00B135, } def encrypt(seed: int, cypher: int) -> int: """32-round TEA-family Feistel. seed=challenge, cypher=session key.""" num = cypher & MASK seed &= MASK sum_ = DELTA rounds = 32 while True: seed = (seed + ((((num << 4) & MASK) + 1131376761) ^ ((num + sum_) & MASK) ^ (((num >> 5) + 1919510376) & MASK))) & MASK rounds -= 1 if rounds <= 0: break num = (num + ((((seed << 4) & MASK) + 1948272964) ^ ((seed + sum_) & MASK) ^ (((seed >> 5) + 1400073827) & MASK))) & MASK sum_ = (sum_ + DELTA) & MASK return seed def remote_control_response(challenge: int) -> int: """Response uint for a REMOTE_CONTROL (on/off/move) command challenge.""" return encrypt(challenge, SESSION_CYPHER["REMOTE_CONTROL"]) def response_bytes(challenge: bytes, session: str = "REMOTE_CONTROL") -> bytes: """4 challenge bytes (big-endian, as on the wire) -> 4 response bytes.""" if len(challenge) != 4: raise ValueError("challenge must be 4 bytes") r = encrypt(int.from_bytes(challenge, "big"), SESSION_CYPHER[session]) return r.to_bytes(4, "big") def _selftest() -> int: import os here = os.path.dirname(os.path.abspath(__file__)) files = [os.path.join(here, "sniff", "2A-auth-pairs.txt"), os.path.join(here, "sniff", "auth-pairs-multinode-2026-06-11.txt")] total = bad = 0 for path in files: if not os.path.exists(path): continue for ln in open(path): ln = ln.strip() if not ln or ln.startswith("#"): continue tok = ln.split() c, r = int(tok[-2], 16), int(tok[-1], 16) total += 1 if remote_control_response(c) != r: bad += 1 print(f" MISS {c:08X} -> got {remote_control_response(c):08X}, want {r:08X}") print(f"self-test: {total - bad}/{total} pairs verified" f" — {'PASS' if bad == 0 and total else 'FAIL'}") return 0 if bad == 0 and total else 1 if __name__ == "__main__": import sys if len(sys.argv) == 2: # one-shot: compute response for a hex challenge ch = int(sys.argv[1], 16) print(f"{remote_control_response(ch):08X}") else: sys.exit(_selftest())