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:
@@ -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())
|
||||
Reference in New Issue
Block a user