A Python tool to safely read the Keihin ECU on a 2010 Bonneville T100 over K-Line (KKL cable) or a Bluetooth ELM327, plus the reverse-engineering research behind it. Phase 1 (read-only comms) of an open tuning toolchain to replace the closed TuneECU app. Read-only by construction: safety.assert_read_only() runs on every outbound request before it hits the wire and refuses all write/flash services (0x27, 0x31, 0x34/0x36, 0x35, 0x37, 0x14, 0x11, 0x2E) and programming sessions, so a bug cannot brick the ECU. Verified frames match TuneECU byte-for-byte in tests/verify_protocol.py. Protocol constants recovered from the TuneECU APK (not guessed): ECU address 0xD5, K-Line tester 0xF5, format byte 0x80|len, additive mod-256 checksum. Includes the full TuneECU map catalogue (1811 entries) extracted to maps.json, searchable and filterable by ECU type and mechanical-vs-LCD odometer. research/ documents the Security Access seed/key algorithm, recovered as standard AES-128 (three embedded keys), with a self-testing reference impl verified against FIPS-197. This is write-path material, kept outside the read-only package. STATUS.md and README.md capture full context, the risk register, and where we left off: comms built but not yet run against the bike; next step is wiring the VAG KKL cable to the Triumph connector and running the first scan.
224 lines
8.2 KiB
Python
224 lines
8.2 KiB
Python
"""Reference implementation of the Triumph Keihin KWP2000 seed/key algorithm.
|
|
|
|
RECOVERED FROM: TuneECU, com/tuneecu/m.java, method Vb() (jadx decompile).
|
|
|
|
Headline: the security-access algorithm is **AES-128**, not the Honda-style
|
|
XOR/bit-shift scheme RESEARCH.md predicted. The proof is in m.java's Vb():
|
|
|
|
* round constants sArr = {1,2,4,8,16,32,64,128,27,54} -> AES Rcon
|
|
(0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1B,0x36)
|
|
* four table lookups per column at offsets +0,+256,+512,+768 -> AES T-tables
|
|
* 10-iteration key schedule producing 44 words -> AES-128 expand
|
|
* 9 full rounds + 1 final (S-box only) round -> AES-128 encrypt
|
|
|
|
The lookup table iArr3 = w.f (field `f` in smali, shown as `f2739f` in jadx).
|
|
Its first entry is 0xA56363C6, which decomposes as (0x63*3, 0x63, 0x63, 0x63*2)
|
|
in GF(2^8): the standard AES S-box (S[0]=0x63) and MixColumns coefficients.
|
|
So this is unmodified AES-128 -- we do not need to extract the 2048-entry table;
|
|
the standard S-box reproduces it exactly (verified below against FIPS-197).
|
|
|
|
>>> IMPORTANT <<<
|
|
This is WRITE-PATH material. It lives here in research/, deliberately OUTSIDE the
|
|
read-only `tunie` package, and nothing in `tunie` imports it. Computing a valid
|
|
key is one of the several things that must line up before a flash is possible;
|
|
having the algorithm does not make writing to the ECU safe. See README notes.
|
|
|
|
Two things still need a single real seed/key capture (from the bike or the
|
|
logging build) to pin down, because they are framing details rather than crypto:
|
|
|
|
1. Which of the three embedded keys applies to a mechanical-odometer 865 twin.
|
|
The key is chosen by MainActivity.h7 in {0,1,2}; h7 is looked up from an
|
|
ECU calibration-code string. For the older no-code Keihin maps this appears
|
|
to default to 0, but that should be confirmed, not assumed.
|
|
2. The exact seed-block layout. Vb() encrypts Ie[0..3] (a 128-bit block).
|
|
m.java loads Ie[1..3] from the received 0x27 response and pads two bytes
|
|
with fixed constants (0x03 into Ie[1] low byte, 0x01 into Ie[3] high byte);
|
|
Ie[0] is set on an earlier path. One captured pair resolves this instantly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
# The three 128-bit keys, verbatim from m.java:5351 (iArr4), as signed 32-bit
|
|
# ints exactly as Java stores them. Four consecutive words == one AES-128 key;
|
|
# MainActivity.h7 selects the key as iArr4[h7*4 : h7*4+4].
|
|
_IARR4_SIGNED = [
|
|
-1605603089, -872368047, 1793034130, 2024345909, # key index 0
|
|
-195055148, -256431415, 2054042008, 967577947, # key index 1
|
|
-1529110564, 414003055, 316782756, -1031573188, # key index 2
|
|
]
|
|
|
|
|
|
def _u32(x: int) -> int:
|
|
return x & 0xFFFFFFFF
|
|
|
|
|
|
def key_words(h7: int) -> list[int]:
|
|
"""Return the four unsigned 32-bit key words for key selector h7 in {0,1,2}."""
|
|
if not 0 <= h7 <= 2:
|
|
raise ValueError(f"h7 must be 0, 1 or 2 (got {h7})")
|
|
return [_u32(w) for w in _IARR4_SIGNED[h7 * 4 : h7 * 4 + 4]]
|
|
|
|
|
|
def key_bytes(h7: int) -> bytes:
|
|
"""The selected AES-128 key as 16 bytes.
|
|
|
|
m.java packs each key word little-endian when it feeds the cipher (Ie/iArr2
|
|
words are consumed low-byte first via the T-table indices), so we emit
|
|
little-endian to match Vb() exactly.
|
|
"""
|
|
return b"".join(w.to_bytes(4, "little") for w in key_words(h7))
|
|
|
|
|
|
# --- textbook AES-128, byte-oriented (matches Vb()'s T-table math) -----------
|
|
|
|
def _gmul(a: int, b: int) -> int:
|
|
p = 0
|
|
for _ in range(8):
|
|
if b & 1:
|
|
p ^= a
|
|
hi = a & 0x80
|
|
a = (a << 1) & 0xFF
|
|
if hi:
|
|
a ^= 0x1B
|
|
b >>= 1
|
|
return p
|
|
|
|
|
|
def _build_sbox() -> list[int]:
|
|
# Multiplicative inverse in GF(2^8) followed by the AES affine transform.
|
|
inv = [0] * 256
|
|
p = q = 1
|
|
for _ in range(255):
|
|
p = _gmul(p, 3)
|
|
# q = p^-1 via the standard log/antilog trick
|
|
q = _gmul(q, 0xF6) # 3^-1
|
|
inv[p] = q
|
|
inv[1] = 1
|
|
sbox = [0] * 256
|
|
for i in range(256):
|
|
x = inv[i]
|
|
s = x ^ ((x << 1) | (x >> 7)) ^ ((x << 2) | (x >> 6)) ^ \
|
|
((x << 3) | (x >> 5)) ^ ((x << 4) | (x >> 4))
|
|
sbox[i] = (s ^ 0x63) & 0xFF
|
|
return sbox
|
|
|
|
|
|
SBOX = _build_sbox()
|
|
RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36]
|
|
|
|
|
|
def _expand_key(key: bytes) -> list[list[int]]:
|
|
assert len(key) == 16
|
|
words = [list(key[i : i + 4]) for i in range(0, 16, 4)]
|
|
for i in range(4, 44):
|
|
temp = list(words[i - 1])
|
|
if i % 4 == 0:
|
|
temp = temp[1:] + temp[:1] # RotWord
|
|
temp = [SBOX[b] for b in temp] # SubWord
|
|
temp[0] ^= RCON[i // 4 - 1]
|
|
words.append([words[i - 4][j] ^ temp[j] for j in range(4)])
|
|
return words
|
|
|
|
|
|
def _add_round_key(state: list[int], words: list[list[int]], rnd: int) -> None:
|
|
for c in range(4):
|
|
for r in range(4):
|
|
state[r * 4 + c] ^= words[rnd * 4 + c][r]
|
|
|
|
|
|
def _sub_shift_mix(state: list[int], final: bool) -> None:
|
|
s = [SBOX[b] for b in state]
|
|
# ShiftRows
|
|
s = [
|
|
s[0], s[1], s[2], s[3],
|
|
s[5], s[6], s[7], s[4],
|
|
s[10], s[11], s[8], s[9],
|
|
s[15], s[12], s[13], s[14],
|
|
]
|
|
if final:
|
|
state[:] = s
|
|
return
|
|
for c in range(4):
|
|
col = [s[r * 4 + c] for r in range(4)]
|
|
state[0 * 4 + c] = _gmul(col[0], 2) ^ _gmul(col[1], 3) ^ col[2] ^ col[3]
|
|
state[1 * 4 + c] = col[0] ^ _gmul(col[1], 2) ^ _gmul(col[2], 3) ^ col[3]
|
|
state[2 * 4 + c] = col[0] ^ col[1] ^ _gmul(col[2], 2) ^ _gmul(col[3], 3)
|
|
state[3 * 4 + c] = _gmul(col[0], 3) ^ col[1] ^ col[2] ^ _gmul(col[3], 2)
|
|
|
|
|
|
def aes128_encrypt_block(block: bytes, key: bytes) -> bytes:
|
|
"""Standard AES-128 ECB single block. Column-major state, per FIPS-197."""
|
|
assert len(block) == 16 and len(key) == 16
|
|
words = _expand_key(key)
|
|
state = [block[r + 4 * c] for r in range(4) for c in range(4)]
|
|
_add_round_key(state, words, 0)
|
|
for rnd in range(1, 10):
|
|
_sub_shift_mix(state, final=False)
|
|
_add_round_key(state, words, rnd)
|
|
_sub_shift_mix(state, final=True)
|
|
_add_round_key(state, words, 10)
|
|
return bytes(state[r + 4 * c] for r in range(4) for c in range(4))
|
|
|
|
|
|
def compute_key(seed_block: bytes, h7: int = 0) -> bytes:
|
|
"""Reproduce Vb(): AES-128 encrypt the seed block, take the first 4 bytes.
|
|
|
|
m.java's Gd() sends `27 02` followed by these four bytes, low byte first,
|
|
which is the natural byte order of the first ciphertext word.
|
|
"""
|
|
if len(seed_block) != 16:
|
|
raise ValueError("seed block must be 16 bytes")
|
|
cipher = aes128_encrypt_block(seed_block, key_bytes(h7))
|
|
return cipher[:4]
|
|
|
|
|
|
# --- self test ---------------------------------------------------------------
|
|
|
|
def _selftest() -> bool:
|
|
ok = True
|
|
|
|
# 1. S-box against the well-known first row of the AES S-box.
|
|
expected = [0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5]
|
|
got = SBOX[:8]
|
|
good = got == expected
|
|
ok &= good
|
|
print(f" [{'PASS' if good else 'FAIL'}] S-box row 0: {[hex(x) for x in got]}")
|
|
|
|
# 2. FIPS-197 Appendix B known-answer test.
|
|
pt = bytes.fromhex("3243f6a8885a308d313198a2e0370734")
|
|
kk = bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c")
|
|
ct = aes128_encrypt_block(pt, kk).hex()
|
|
want = "3925841d02dc09fbdc118597196a0b32"
|
|
good = ct == want
|
|
ok &= good
|
|
print(f" [{'PASS' if good else 'FAIL'}] FIPS-197 vector: {ct}")
|
|
|
|
# 3. The T-table's first entry confirms S-box+MixColumns: 0xA56363C6.
|
|
s0 = SBOX[0]
|
|
good = (
|
|
s0 == 0x63
|
|
and _gmul(s0, 2) == 0xC6
|
|
and _gmul(s0, 3) == 0xA5
|
|
)
|
|
ok &= good
|
|
print(f" [{'PASS' if good else 'FAIL'}] Te[0] decode: "
|
|
f"3*S0=0x{_gmul(s0,3):02X} S0=0x{s0:02X} 2*S0=0x{_gmul(s0,2):02X} "
|
|
"-> A5 63 63 C6")
|
|
|
|
# 4. The three embedded keys, for the record.
|
|
print(" embedded AES-128 keys (little-endian, from iArr4):")
|
|
for h in range(3):
|
|
print(f" h7={h}: {key_bytes(h).hex()}")
|
|
|
|
# 5. Demonstrate a full seed->key with a placeholder seed.
|
|
demo_seed = bytes(range(16))
|
|
print(f" demo compute_key(00..0f, h7=0) = {compute_key(demo_seed, 0).hex()}")
|
|
|
|
return ok
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
print("Keihin seed/key reference self-test:")
|
|
sys.exit(0 if _selftest() else 1)
|