Command path proven end to end on the bus (node F8 interior lights, on/off/on), each answering a distinct fresh challenge; bare opcodes without the exchange are ignored. ids_can_auth.h verified bit-exact against ids_can_auth.py and the captured/live pairs. - idscan_cmd.py: stdlib socketcan tool running the full page-42/43 exchange - esphome/onecontrol-canbus.yaml: correct IDS-CAN read dispatch (was stale RV-C DGN code) + command path wired to the auth header - README/memory: document the read map + command authentication; rename sniff/ -> captures/; neutral device-integration framing throughout Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
109 lines
4.2 KiB
Python
109 lines
4.2 KiB
Python
#!/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 <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 (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())
|