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