"""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)