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
|
(`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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user