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
+58 -19
View File
@@ -86,8 +86,9 @@ The command opcode is a **zero-payload (DLC 0) extended frame** `0x0006<node><op
(`op`: `01`=on, `00`=off/stop, `02`=movement-retract). The BLE app's taps appear (`op`: `01`=on, `00`=off/stop, `02`=movement-retract). The BLE app's taps appear
on the bus as these, ~300 ms before the page-3 state flips. BUT — on the bus as these, ~300 ms before the page-3 state flips. BUT —
**⚠️ WRITE IS AUTH-GATED. Replay does NOT work.** Each command is wrapped in a **WRITE IS AUTH-GATED — and the gate is now CRACKED (2026-06-12, see below).**
**rolling challenge-response** the bare opcode won't pass: Each command is wrapped in a **rolling challenge-response** the bare opcode
won't pass:
``` ```
01 → node page42 "00 04" # controller: "arm me a challenge" 01 → node page42 "00 04" # controller: "arm me a challenge"
@@ -105,26 +106,64 @@ be replayed. **Verified empirically:** spoofing bare `cansend can0 00062A00#`
seen) but the load **did not actuate**. The module ignores an unauthenticated seen) but the load **did not actuate**. The module ignores an unauthenticated
opcode. opcode.
It is **not** the BLE TEA cipher (`tea(612643285, 0x21CA0C06) = 0x87AC5CBD ≠` It uses a **different key** from the BLE TEA auth (`tea(612643285, 0x21CA0C06) =
the observed `0xCC18366B`) — different key/algorithm. So Lippert put a second, 0x87AC5CBD ≠` the observed `0xCC18366B`) — but, as it turns out, the **same
separate auth on the CAN write path. Cracking it = its own reversing project. family**: a TEA/XTEA Feistel. Lippert put a second, separately-keyed auth on the
CAN write path.
**Dataset for the crack: `sniff/2A-auth-pairs.txt`** 42 challenge→response **Dataset for the crack: `sniff/2A-auth-pairs.txt`** (42 pairs, node `2A`) +
pairs from node `2A`, captured 2026-06-11 (app on/off ×~20). Analysis so far: `sniff/auth-pairs-multinode-2026-06-11.txt` (9 more across nodes `61`/`75`/`F8`
`response = f(challenge)` is **fully deterministic** (0 inconsistent responses +2 on `2A`) — **51 pairs / 4 nodes**, captured 2026-06-11 (app-driven).
across the set ⇒ no counter/timestamp/session state — pure 32→32-bit block
transform), but **not** a constant XOR or ADD (42/42 distinct ⇒ a real cipher,
likely TEA/XTEA-family w/ unknown constants). Stateless+deterministic = solvable
offline: lift the constants from the X180T or Lippert-app firmware and verify
against this file. Collect more pairs (other nodes) anytime to widen the attack.
> Movement nodes (awning `75`) showed the page42/43/45 frames as **commander→node Structural analysis of `response = f(challenge)` (script `sniff/analyze_auth.py`):
> only, with no nonce reply** — possibly a weaker/no gate. NOT spoof-tested **genuine keyed nonlinear block cipher.** Ruled out by the data — **not**
> (don't actuate a motor unattended). Worth a careful look later. GF(2)-affine (the 51 input-differences span the full 32-dim space yet contradict
a linear fit, so the obstacle is *structure, not too few pairs* — a linear map
would have over-solved at ~33), **not** affine over Z/2³² (49/51 miss), and no
output byte is a function of any single input byte (full byte diffusion). Bits
are balanced. ⇒ TEA/XTEA/Speck-family with an unknown key, exactly as the BLE
side uses TEA.
**Bottom line: READ is fully open and is the deliverable here** (all sensors + That structural read said the function was unrecoverable from random pairs and
states from broadcasts, zero auth). WRITE stays on the BLE integration for now pointed at recovering the key rather than cryptanalyzing the captures — which is
(laggy but works) until/unless the CAN challenge-response is cracked. exactly what happened.
#### ✅ SOLVED (2026-06-12) — `ids_can_auth.py`
The cipher is a **32-round TEA/XTEA Feistel** (delta `0x9E3779B9`) keyed by a
per-**session** 32-bit "Cypher", with the round constants baked in. There are
five sessions — the joke hex values confirm they're the genuine keys:
| Session | Cypher | Use |
|---------|--------|-----|
| MANUFACTURING | `0xB16BA115` | factory features |
| DIAGNOSTIC | `0xBABECAFE` | diagnostic tool (← likely unlocks the DSI fault path) |
| REPROGRAMMING | `0xDEADBEEF` | firmware reflash |
| **REMOTE_CONTROL** | **`0xB16B00B5`** | **on/off/move — this is the write gate** |
| DAQ | `0x0B00B135` | data acquisition |
`response = Encrypt(challenge, 0xB16B00B5)`, both 32-bit **big-endian** (the 4
payload bytes after `00 04`). **Verified 51/51** against every captured pair,
all four nodes (2A 44/44, 61 2/2, 75 3/3, F8 2/2) — REMOTE_CONTROL is unique
(every other key misses 51/51), and it's **one global key, not per-node**. So to
actuate a load: catch the module's page-42 challenge, compute the response, send
it on page-43, then send the opcode. Reference impl + self-test in
`ids_can_auth.py` (`python3 ids_can_auth.py <challenge_hex>`). No firmware dump
was needed; the 51 captures were the verification oracle.
> Movement nodes use the **same gate.** App-driven awning (`75`) commands in
> `sniff/app-commands-*.log` show the full nonce handshake (node→01 page42
> challenge `01D50142` + 01→node page43 response), identical to the switched
> loads — *not* the commander-only/no-reply pattern an earlier jog test
> suggested. NOT spoof-tested (don't actuate a motor unattended).
**Bottom line: READ is fully open** (all sensors + states from broadcasts, zero
auth) **and WRITE is now unlocked** — the command-auth cipher is cracked
(`ids_can_auth.py`), so the CAN path can both sense and actuate. The BLE
integration is no longer the only way to control loads; next step is wiring the
challenge-response into the ESPHome node's `switch`/`cover` actions (the bare
opcode in the command DGN now just needs the page-42/43 handshake in front of
it). Movement nodes (slides/jacks) still want a careful first actuation test.
Other app-session traffic (not control): `701` = controller heartbeat during a Other app-session traffic (not control): `701` = controller heartbeat during a
BLE session; src 01 → node pages `30/31` = paged descriptor/table reads the app BLE session; src 01 → node pages `30/31` = paged descriptor/table reads the app
+54
View File
@@ -0,0 +1,54 @@
#pragma once
// IDS-CAN command-auth response cipher for the OneControl write path.
//
// 32-round TEA/XTEA-family Feistel, delta 0x9E3779B9, keyed by a per-session
// 32-bit "Cypher" with the round constants baked in.
// Verified bit-exact against ids_can_auth.py and 51 captured bus pairs.
//
// uint32_t arithmetic wraps mod 2^32 by definition in C++, so no masking needed.
// ESP32 int is 32-bit (uint32_t == unsigned int): host g++ test is representative.
#include <cstdint>
#include <cstddef>
namespace ids_can_auth {
// Per-session keys ("Cypher"). REMOTE_CONTROL gates on/off/move.
enum SessionKey : uint32_t {
MANUFACTURING = 0xB16BA115u,
DIAGNOSTIC = 0xBABECAFEu,
REPROGRAMMING = 0xDEADBEEFu,
REMOTE_CONTROL = 0xB16B00B5u,
DAQ = 0x0B00B135u,
};
// response = Encrypt(challenge). seed = challenge word, cypher = session key.
inline uint32_t encrypt(uint32_t seed, uint32_t cypher) {
uint32_t num = cypher;
uint32_t sum = 0x9E3779B9u; // TEA golden-ratio delta
for (int rounds = 32;;) {
seed += ((num << 4) + 1131376761u) ^ (num + sum) ^ ((num >> 5) + 1919510376u);
if (--rounds <= 0) break;
num += ((seed << 4) + 1948272964u) ^ (seed + sum) ^ ((seed >> 5) + 1400073827u);
sum += 0x9E3779B9u;
}
return seed;
}
inline uint32_t remote_control_response(uint32_t challenge) {
return encrypt(challenge, REMOTE_CONTROL);
}
// 4 big-endian challenge bytes (as they arrive in the page-42 payload, after the
// "00 04" prefix) -> 4 big-endian response bytes (for the page-43 reply).
inline void remote_control_response_bytes(const uint8_t challenge[4], uint8_t response[4]) {
uint32_t c = (uint32_t)challenge[0] << 24 | (uint32_t)challenge[1] << 16 |
(uint32_t)challenge[2] << 8 | (uint32_t)challenge[3];
uint32_t r = remote_control_response(c);
response[0] = (uint8_t)(r >> 24);
response[1] = (uint8_t)(r >> 16);
response[2] = (uint8_t)(r >> 8);
response[3] = (uint8_t)(r);
}
} // namespace ids_can_auth
+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())
+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()
@@ -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