Files
samplez/tunie/research/FINDINGS.md
uhryniuk 4c44933b5d 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.
2026-08-10 14:29:50 -05:00

3.4 KiB
Raw Blame History

Seed/key recovery — findings

Question

Is the Triumph Keihin KWP2000 Security Access (service 0x27) seed/key algorithm recoverable, and does the logging APK help?

Answer

Yes, fully — and from the static decompile, so the logging build isn't needed for it. The algorithm is AES-128, and all key material is embedded in the app. RESEARCH.md's prediction ("Honda-style XOR / bit-shift with a factory constant, possibly brute-forced") was wrong about the primitive.

Where it lives

com/tuneecu/m.java, method Vb() (jadx), = Lcom/tuneecu/m;->Vb()I (smali).

Evidence in Vb() What it proves
sArr = {1,2,4,8,16,32,64,128,27,54} AES Rcon (0x01,02,04,08,10,20,40,80,1B,36)
4 table lookups/column at +0,+256,+512,+768 AES T-tables (Te0–Te3)
10-iteration loop building 44 words AES-128 key expansion
9 rounds + 1 S-box-only final round AES-128 encryption
iArr3 = w.f first entry 0xA56363C6 (3·S0, S0, S0, 2·S0) with S0=0x63 → standard S-box + MixColumns

Verified: all 256 entries of w.f match the standard AES Te0 table exactly (keihin_seedkey.py cross-checks them). Our reference AES also passes the FIPS-197 Appendix-B known-answer test. This is unmodified AES-128.

What Vb() does

  1. Loads a 128-bit seed block into Ie[0..3] from the ECU's 0x27 seed response.
  2. AES-128-encrypts it with one of three embedded keys (iArr4, m.java:5351), selected by MainActivity.h7 ∈ {0,1,2}.
  3. Returns the first ciphertext word; Gd() sends it as 27 02 <4 bytes>.

The three keys (little-endian, as the cipher consumes them):

h7=0: ef704ca051b800cc9287df6a3511a978
h7=1: d4b15ff4c92ab7f098316e7a5b11ac39
h7=2: dc9fdba46f2fad18a4b8e1123c7183c2

h7 is looked up from an ECU calibration-code string (MainActivity.java:6221).

Still open (framing, not crypto — one captured pair resolves both)

  1. Which key applies to a mechanical-odometer 865 twin. h7 defaults toward 0 for the older no-code Keihin maps, but confirm rather than assume.
  2. Exact seed-block layout. Vb() encrypts Ie[0..3]; m.java fills Ie[1..3] from the seed response and pads two bytes with fixed constants (0x03, 0x01), with Ie[0] set on an earlier path. A single real (seed, key) pair pins this down exactly.

The logging APK

work/TuneECU-logging.apk is a clean apktool rebuild of the original (the ~17 KB size delta is re-signing/recompression). No custom instrumentation was found — every Log call is TuneECU's own, gated behind its debug boolean, and no debuggable flag is set. So it does not currently capture seed/key at runtime.

It's still the right tool for the validation step above: make the app perform one real Security Access against the bike (or a bench ECU) and capture the 27 01 seed and 27 02 key off the wire, then check compute_key(seed, h7) reproduces it. That either confirms the port outright or tells us the exact h7/padding.

Reference implementation

research/keihin_seedkey.py — pure-Python, self-testing. Run it:

python3 research/keihin_seedkey.py

Boundary

This is write-path material. It is kept in research/, outside the read-only tunie package, and tunie does not import it. Reproducing the key does not make flashing safe — it's one of several pieces (checksum patching, a verified stock dump, a recovery plan, ideally a spare ECU) that all have to line up first.