From 135da73e921cdcd9700495e15e360cb680b72e78 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 27 Aug 2026 18:36:14 +0200 Subject: [PATCH] Decode the pod's key-exchange frames, and retire A-3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A-3 said the v2 needs no encryption: the pod completes the handshake and streams buttons in cleartext, and we have never sent a key. That is true for about fifty seconds. Captured on the tablet mid-ride and decoded here. The pod sends a `0xff` `03 00` frame carrying a **compressed 33-byte P-256 point** in protobuf field 1, a constant `0x02030000` in field 2, and 40 or 60 unexplained bytes in field 3. It sends one shortly after connecting and another **242 ms after the last button frame** — and in the same breath a `0xff` `05 00` status frame flips a flag 0 → 1 and a value 15 → 900. We answer neither, and from that moment the paddle byte of the button bitmask is frozen at `ff` while the D-pad keeps reporting in cleartext. So §2.3's open question — whether the daily Zwift unlock is needed on this path — is answered: it is, and this is the mechanism. The note that our unencrypted sessions "ran past three minutes" no longer holds either. Also recorded: this is **not** the Play handshake of §3.5. That is `RideOn 01 02` plus a raw 64-byte key on Sync RX; ours is a compressed 33-byte key inside protobuf on the async channel, with two fields the Play write-up has no room for. Implementing that spec verbatim would be implementing a different device. And the reason no responder is written yet: every frame we have is device → app. Nothing shows what a working client writes back, which is exactly what a responder must send, and five frames of one direction will not yield it. Co-Authored-By: Claude Opus 5 (1M context) --- REQUIREMENTS.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index fbf6dbc..f3772e0 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -239,7 +239,40 @@ open-ended and not obviously safe. **Virtual shifting is therefore emulated app- > **three minutes** with button edges still arriving, so this path does not appear to hit > that timeout — but a long ride is the real test. -**Encryption — not required.** ✅ **A-3 holds for the v2.** The pod completed the handshake +### 2.3.3 The `0xff` key-exchange frames — decoded 2026-08-27 ⚠ **A-3 is dead** + +Captured on the tablet during a real ride. The `−` pod connected, streamed cleartext +buttons for **fifty seconds**, and then stopped reporting its paddles while the link stayed +up and battery frames continued. Two frames bracket the moment: + +| Frame | Meaning | +|-------|---------| +| `ff 03 00` + protobuf | **key offer.** field 1 = 33 bytes, a *compressed* P-256 point (`02`/`03` prefix, fresh each session); field 2 = varint `0x02030000`, constant; field 3 = 40 or 60 bytes, contents unknown | +| `ff 05 00` + protobuf | **status.** field 93 nests: `.1` = device id ASCII, `.2` = flag **0 → 1**, `.3` = **15 → 900**, `.4` = 8, `.5` = 9, `.6` = a small counter | + +The pod sent a key offer at +4 s, a **second** key offer 242 ms after the last button frame, +and the status flag flipped `0 → 1` with `.3` going `15 → 900` in the same breath. We never +answered either offer. + +**So A-3 does not hold.** The claim was that the pod "streams button and battery events in +cleartext and the app has never sent a key" — true, but only for about a minute. The earlier +note that "our unencrypted sessions ran past three minutes" and the open question of whether +the daily unlock is needed on this path are both answered: it is, and this is the mechanism. + +> **This is not the Play handshake of §3.5.** That one is `RideOn 01 02` + a *raw 64-byte* +> key written to Sync RX, answered by `RideOn 00 09` + 64 bytes. Our v2 offers a +> **compressed 33-byte** key inside protobuf on the **async** channel under type `0xff`, with +> two fields the Play description has no room for. Implementing Makinolo's spec verbatim +> would be implementing a different device's protocol. + +> **The missing half.** Every frame above is device → app. Nothing in any capture shows what +> a *working* client writes back, and that is precisely what a responder has to send. It +> cannot be inferred from these five frames. Getting it means capturing both directions of a +> real unlock — Android's Bluetooth HCI snoop log against the official Zwift app — which is +> the next action, not more guessing. + +**Encryption — was thought not required.** ⚠ **Superseded by §2.3.3; A-3 held only for the +first minute of a session.** The pod completed the handshake and streamed button and battery events in cleartext, and the app has never sent a key. The ECDH P-256 → HKDF → AES-256-CCM path documented below is therefore *not* needed, and no crypto crate has been added.