Add tunie: read-only KWP2000 diagnostics for Triumph Keihin ECU

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.
This commit is contained in:
2026-08-10 14:29:50 -05:00
parent f8904382a3
commit 4c44933b5d
20 changed files with 25326 additions and 0 deletions

View File

@@ -0,0 +1,223 @@
"""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)