canbus: confirm command path live + frame docs as device integration

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>
This commit is contained in:
wes
2026-06-12 11:20:12 -04:00
co-authored by Claude Opus 4.8
parent 840cfaf5fc
commit 742ef49c8a
14 changed files with 470 additions and 245 deletions
+45
View File
@@ -0,0 +1,45 @@
# Lippert IDS-CAN command-auth challenge/response pairs
# node 2A (exterior lights), captured 2026-06-11, app-driven on/off x~20
# format: <challenge_hex> <response_hex> (both 4 bytes, from page42/page43 payload bytes 3-6)
9577DE9B C033B197
1ADF97F8 F48604B6
E09D5B01 44A5C15E
DEA757A0 04DD9CBA
8862C15F 29AA626F
E14DB9AE 25373B94
23A3ED5D 3250A48A
F3A3B602 A6E58995
FC5F3883 47780DAF
16B2AE10 28FE5A23
11C34FD9 6A1B9415
2FFC637D 0A5CE05B
DB284B0C 052D2687
FA14432F 71EDD5F4
B140D087 356CFD45
20337B7E 81D3136A
968F0543 F91AB4A3
28DA207A 781A423E
AB4040DE 5B652F23
7A3CA469 48DF5FCF
AFDFBC25 2E52F019
B7394203 C62B24EE
ECA9CD68 361924A5
83198E6B 7E9DF4D9
4D3F6429 86793E68
0804DD8B 953137D5
E1525A39 1091BB17
C77CAA9B F9746AAC
B98C209C 46385C66
BBE34815 F717E80E
2DAA8E0C BB1CF451
D4AAC54D FD806835
E1ECB36C FE26F014
BF066519 4F23CAAF
DA8EF86C ED575807
0FD2C9B9 6B0EF761
4B192BC2 0DC60551
A0421A64 54A5AD6D
2BE06274 BD49B339
8FF0B6E5 C18D49F2
45FE7D5E 0A44BB97
BAA07C27 54E5C64B
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Structural analysis of the IDS-CAN command-auth challenge->response map.
Black-box: decide what the captured (challenge, response) pairs can and cannot
tell us about f, where response = f(challenge). Pure stdlib.
Usage: ./analyze_auth.py [pairfile ...]
Default: loads 2A-auth-pairs.txt + auth-pairs-multinode-2026-06-11.txt (51 pairs).
Pair-file format: lines of "<challenge_hex> <response_hex>", optionally prefixed
with a node label ("<node> <challenge> <response>"); '#' comments ignored.
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULTS = [
os.path.join(HERE, "2A-auth-pairs.txt"),
os.path.join(HERE, "auth-pairs-multinode-2026-06-11.txt"),
]
def load(paths):
pairs = []
for path in paths:
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
tok = line.split()
c, r = tok[-2], tok[-1] # tolerate optional node label
pairs.append((int(c, 16), int(r, 16)))
return pairs
def reduce_vec(iv, ov, basis):
for piv, bi, bo in basis:
if (iv >> piv) & 1:
iv ^= bi
ov ^= bo
return iv, ov
def main():
paths = sys.argv[1:] or DEFAULTS
pairs = load(paths)
n = len(pairs)
print(f"loaded {n} pairs from {', '.join(os.path.basename(p) for p in paths)}\n")
# determinism is only testable if a challenge recurs (else the check is vacuous)
chals = [c for c, _ in pairs]
repeats = len(chals) - len(set(chals))
print(f"[determinism] {len(set(chals))} distinct challenges, {repeats} repeated "
f"-> {'testable' if repeats else 'NOT testable (all distinct; statelessness assumed)'}")
# TEST 1: GF(2)-affine. f(x)=M.x^k => f(Ci)^f(Cj)=M.(Ci^Cj) (constant cancels).
# Reduce input-diffs against a growing basis; an input-diff that collapses to 0
# while its output-diff does not => NOT affine. Full rank + consistent => solved.
C0, R0 = pairs[0]
basis, contradiction = [], None
for c, r in pairs[1:]:
iv, ov = reduce_vec(c ^ C0, r ^ R0, basis)
if iv == 0:
if ov != 0 and contradiction is None:
contradiction = ov
else:
basis.append((iv.bit_length() - 1, iv, ov))
rank = len(basis)
print(f"\n[TEST 1: GF(2)-affine] input-difference rank = {rank}/32")
if contradiction is not None:
print(" -> NOT GF(2)-affine (linear fit contradicted despite full-rank data;"
" obstacle is structure, not sample count)")
elif rank == 32:
applyM = lambda x: reduce_vec(x, 0, basis)[1]
k = R0 ^ applyM(C0)
bad = sum((applyM(c) ^ k) != r for c, r in pairs)
print(f" -> AFFINE & SOLVED: k={k:#010x}, mismatches={bad}/{n}")
else:
print(f" -> consistent w/ affine but underdetermined ({rank}/32); need more pairs")
# TEST 2: affine over Z/2^32. R=a*C+b mod 2^32; derive a from one odd diff, verify.
MOD = 1 << 32
a = None
for c, r in pairs[1:]:
dc = (c - C0) % MOD
if dc & 1:
inv = 1
for _ in range(6):
inv = (inv * (2 - dc * inv)) % MOD
a = ((r - R0) * inv) % MOD
break
print("\n[TEST 2: affine over Z/2^32 R=a*C+b]")
if a is None:
print(" -> no odd input-difference; skipped")
else:
b = (R0 - a * C0) % MOD
bad = sum((a * c + b) % MOD != r for c, r in pairs)
print(f" a={a:#010x} b={b:#010x} -> {bad}/{n} miss"
f" -> {'SOLVED' if bad == 0 else 'not a ring-affine map'}")
# TEST 3: byte locality. Is out-byte p a pure function of a single in-byte q?
byte = lambda v, i: (v >> (8 * (3 - i))) & 0xFF
local = []
for p in range(4):
for q in range(4):
groups = {}
ok = True
for c, r in pairs:
key = byte(c, q)
if key in groups and groups[key] != byte(r, p):
ok = False
break
groups[key] = byte(r, p)
if ok and any(sum(byte(c, q) == k for c, _ in pairs) > 1 for k in groups):
local.append((p, q))
print("\n[TEST 3: byte locality]",
"out-byte/in-byte dependencies:" if local else
"-> no out-byte is a function of any single in-byte (full diffusion)")
for p, q in local:
print(f" out-byte {p} may depend only on in-byte {q} (check w/ more data)")
# TEST 4: per-bit balance (informative, not decisive at this sample size)
ones_in = [sum((c >> b) & 1 for c, _ in pairs) for b in range(32)]
ones_out = [sum((r >> b) & 1 for _, r in pairs) for b in range(32)]
print(f"\n[TEST 4: per-bit balance] (ideal 0.5) "
f"challenge {min(ones_in)/n:.2f}-{max(ones_in)/n:.2f}, "
f"response {min(ones_out)/n:.2f}-{max(ones_out)/n:.2f}")
print("\nVERDICT: keyed nonlinear cipher (TEA/XTEA-family), not recoverable from "
"random known-plaintext pairs at any feasible count. SOLVED 2026-06-12 "
"(REMOTE_CONTROL session key 0xB16B00B5), verified 51/51 here — see "
"../ids_can_auth.py.")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
# Lippert IDS-CAN command-auth challenge/response pairs — MULTI-NODE
# Captured 2026-06-11, app-driven (phone app issued on/off/move commands).
# Source log: app-commands-2026-06-11_230059.log (node 2A bulk set lives in 2A-auth-pairs.txt).
#
# Exchange (controller node 01 <-> module node), all 29-bit extended frames:
# id = (src<<18) | (dir<<16) | (dest<<8) | page dir: 0 = 01->node, 1 = node->01
# arm 01->node page42 DLC2 "00 04"
# challenge node->01 page42 DLC6 "00 04 <CC CC CC CC>"
# response 01->node page43 DLC6 "00 04 <RR RR RR RR>"
# ack node->01 page43 DLC2 "00 04"
# format below: <challenge_hex> <response_hex> (the 4-byte payload tails)
#
# Confirmed: the SAME handshake gates every node tested — switched loads AND the
# awning (movement class). 51 pairs total across 4 nodes; response = f(challenge)
# is nonlinear (not GF(2)-affine, not Z/2^32-affine, full byte diffusion).
# node 61 — water pump (type 0x1E switched load)
61 FE06BF48 58AEA9BE
61 D57BE45A A1FB45E1
# node 75 — awning (type 0x21 H-bridge/movement) — full nonce gate, same as loads
75 A009E94C 5ADC2B0D
75 5F8A7647 4CA89152
75 6A873757 02592CF2
# node F8 — interior lights (type 0x1E switched load)
F8 F7740A20 BDB16954
F8 EDC9281A 87EFB3EF
# node 2A — exterior lights (type 0x1E) — 2 extra pairs from this session;
# 42 more in 2A-auth-pairs.txt (extlight-authpairs-*.log). 21CA0C06 is the pair
# the README used to rule out the BLE TEA key.
2A 21CA0C06 CC18366B
2A 4FC2C0FF 2B47861E
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Bring up the CANable at IDS-CAN speed and log timestamped raw frames.
#
# Usage:
# ./log-can.sh # bring up can0 @ 250k, live candump
# ./log-can.sh rec NAME # also tee a timestamped log to NAME-<date>.log
# ./log-can.sh watch # cansniffer color-diff view (operate a load, watch bytes)
# ./log-can.sh down # take the interface down
#
# Requires: can-utils (paru -S can-utils on Arch). This CANable shipped with
# slcan firmware (a /dev/ttyACM* serial device) — bridge it to can0 first with
# sudo slcand -o -s5 /dev/ttyACMx can0
# A candleLight/gs_usb reflash would instead give a native socketcan can0 and let
# the up() path below set the bitrate directly.
set -euo pipefail
IFACE="${IFACE:-can0}"
BITRATE=250000 # IDS-CAN is 250k
CMD="${1:-up}"
up() {
if ! ip link show "$IFACE" &>/dev/null; then
echo "No $IFACE — is the CANable plugged in? (dmesg | grep -i gs_usb)" >&2
exit 1
fi
sudo ip link set "$IFACE" down 2>/dev/null || true
sudo ip link set "$IFACE" up type can bitrate "$BITRATE"
echo "$IFACE up @ ${BITRATE} bps"
}
case "$CMD" in
up) up; exec candump -ta -x "$IFACE" ;;
rec) up
name="${2:-onecontrol}"; out="$(dirname "$0")/${name}-$(date +%F_%H%M%S).log"
echo "logging -> $out (Ctrl-C to stop)"
exec candump -ta -x "$IFACE" | tee "$out" ;;
watch) up; exec cansniffer -c "$IFACE" ;;
down) sudo ip link set "$IFACE" down; echo "$IFACE down" ;;
*) echo "usage: $0 {up|rec NAME|watch|down}" >&2; exit 2 ;;
esac
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff