canbus: solve IDS-CAN command-auth cipher; add reference implementations

The CAN write gate (page-42/43 challenge/response) is a 32-round TEA/XTEA-family
Feistel keyed by a per-session 32-bit key; REMOTE_CONTROL = 0xB16B00B5. Verified
51/51 against captured challenge/response pairs across nodes 2A/61/75/F8 (one
global key, not per-node), so the CAN path can now actuate, not just sense.

- ids_can_auth.py         Python reference + self-test (51/51)
- esphome/ids_can_auth.h  C++ port for the ESP32 node (host-tested 8/8)
- sniff/analyze_auth.py   structural analysis (rules out affine; confirms keyed cipher)
- sniff/auth-pairs-multinode-2026-06-11.txt   +9 pairs across 4 nodes
- README                  document the cipher, session keys, unlock sequence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-06-12 00:22:09 -04:00
co-authored by Claude Fable 5
parent 85455e8631
commit 840cfaf5fc
5 changed files with 387 additions and 19 deletions
+104
View File
@@ -0,0 +1,104 @@
#!/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 <CC CC CC CC>" # module's challenge
01->node page43 DLC6 "00 04 <RR RR RR RR>" # RR = remote_control_response(CC)
node->01 page43 DLC2 "00 04" # ack
01->node 0x0006<node><op> 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())