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:
82
tunie/research/FINDINGS.md
Normal file
82
tunie/research/FINDINGS.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user