#!/usr/bin/env python3 """IDS-CAN command authentication — challenge/response for the CAN command path. The OneControl modules require a per-command challenge/response before they act on an opcode. This computes the response so the Home Assistant integration can issue commands the same way the OEM app and remote do. The transform is a TEA/XTEA-family 32-round Feistel keyed by a per-session 32-bit value the protocol calls the "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). Validated 51/51 against the captured pairs in captures/2A-auth-pairs.txt + captures/auth-pairs-multinode-2026-06-11.txt, across nodes 2A/61/75/F8 — one global session key, shared by all nodes. The session "Cypher" is the only key; the round constants are baked in. The protocol defines five sessions (the memorable hex values are its own constants): MANUFACTURING 0xB16BA115 DIAGNOSTIC 0xBABECAFE REPROGRAMMING 0xDEADBEEF REMOTE_CONTROL 0xB16B00B5 DAQ 0x0B00B135 REMOTE_CONTROL is the session for on/off/move commands. Exchange on the bus (controller 01 <-> module, 29-bit extended frames): 01->node page42 DLC2 "00 04" # request a challenge 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 (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, "captures", "2A-auth-pairs.txt"), os.path.join(here, "captures", "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())