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
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
**rolling challenge-response** the bare opcode won't pass:
**WRITE IS AUTH-GATED — and the gate is now CRACKED (2026-06-12, see below).**
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"
@@ -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
opcode.
It is **not** the BLE TEA cipher (`tea(612643285, 0x21CA0C06) = 0x87AC5CBD ≠`
the observed `0xCC18366B`) — different key/algorithm. So Lippert put a second,
separate auth on the CAN write path. Cracking it = its own reversing project.
It uses a **different key** from the BLE TEA auth (`tea(612643285, 0x21CA0C06) =
0x87AC5CBD ≠` the observed `0xCC18366B`) — but, as it turns out, the **same
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
pairs from node `2A`, captured 2026-06-11 (app on/off ×~20). Analysis so far:
`response = f(challenge)` is **fully deterministic** (0 inconsistent responses
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.
**Dataset for the crack: `sniff/2A-auth-pairs.txt`** (42 pairs, node `2A`) +
`sniff/auth-pairs-multinode-2026-06-11.txt` (9 more across nodes `61`/`75`/`F8`
+2 on `2A`) — **51 pairs / 4 nodes**, captured 2026-06-11 (app-driven).
> Movement nodes (awning `75`) showed the page42/43/45 frames as **commander→node
> only, with no nonce reply** — possibly a weaker/no gate. NOT spoof-tested
> (don't actuate a motor unattended). Worth a careful look later.
Structural analysis of `response = f(challenge)` (script `sniff/analyze_auth.py`):
**genuine keyed nonlinear block cipher.** Ruled out by the data — **not**
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 +
states from broadcasts, zero auth). WRITE stays on the BLE integration for now
(laggy but works) until/unless the CAN challenge-response is cracked.
That structural read said the function was unrecoverable from random pairs and
pointed at recovering the key rather than cryptanalyzing the captures — which is
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
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