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:
+58
-19
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user