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.
83 lines
3.4 KiB
Markdown
83 lines
3.4 KiB
Markdown
# 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:
|
||
|
||
```sh
|
||
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.
|