//! `probe unlock` — does answering the pod's key offer keep the paddles alive? //! //! ## The question //! //! A Click v2 streams button state in cleartext for about fifty seconds and //! then stops reporting its **paddles** — the D-pad keeps working, and the two //! paddle bits of the bitmask freeze. Bracketing that moment, the pod sends a //! `0xff 03 00` frame carrying a compressed P-256 public key, and flips a flag //! in its `0xff 05 00` status frame (REQUIREMENTS §2.3.3). We never answer. //! //! The hypothesis this command tests: **the pod is asking for a key exchange //! and giving up on us when we do not reply.** If so, replying keeps the //! paddles alive past the cliff, and the shape of a working reply is the thing //! we do not have — every capture is device → app. //! //! ## Why a guess is affordable here //! //! The v2's offer looks like the handshake we already know, moved into a //! protobuf envelope. Its field 2 is the varint `0x02030000`, and //! `zwift::RESPONSE_START` — the pod's confirmed cleartext reply marker — is //! `[0x02, 0x03]`. That is not a coincidence, and it makes the client side a //! short list rather than a search: our own marker (`0x00090000`), Play's //! client marker (`0x01020000`), or the pod's own echoed back. //! //! And the device is a perfect oracle. Either the paddle bits still change at //! T+120 s or they do not, and it says so every run, in two minutes, with no //! APK and no tablet. //! //! ## What this is not //! //! Not an implementation. Nothing here derives a session key or decrypts //! anything: it sends one candidate and watches. If a candidate holds the //! paddles open, *then* the full ECDH → HKDF → AES-CCM responder is worth //! writing, against a known-good handshake instead of a hopeful one. use std::time::{Duration, Instant}; use anyhow::Result; /// A candidate for the two-byte marker the client puts in field 2, carried in /// the same big-endian-ish layout the pod uses for its own. #[derive(Debug, Clone, Copy)] pub struct Candidate { pub name: &'static str, pub marker: u32, pub why: &'static str, } pub const CANDIDATES: &[Candidate] = &[ Candidate { name: "ours", marker: 0x0009_0000, why: "the marker we already write in the confirmed cleartext handshake \ (zwift::REQUEST_START = 00 09)", }, Candidate { name: "play", marker: 0x0102_0000, why: "the client marker documented for the 2023 Play controllers (01 02)", }, Candidate { name: "echo", marker: 0x0203_0000, why: "the pod's own marker echoed back, in case field 2 names the suite \ rather than the speaker", }, ]; /// One thing to send, and what it is testing. /// /// The sweep exists because the pod **answers**: a `0x3e` frame came back /// 90 ms after our first write, in both runs, carrying `{1: 255, 2: 5}`. That /// is a rejection with a reason, which makes this a conversation rather than a /// guess — vary one thing, watch the reason move. /// /// It needs no button presses, which matters: the pod this was written for has /// stopped reporting buttons entirely, and the paddle oracle is unavailable /// until it recovers. pub struct Variant { pub name: &'static str, pub why: &'static str, /// Built from our public key and, where it matters, the pod's own offer. pub build: fn(&LocalKey, &KeyOffer) -> Vec, } /// Fuzzing a Click is not fuzzing a trainer. §2.3 refused to fuzz unknown /// writes to the D100 because it puts resistance under a rider; a pod has no /// actuator, and the worst it can do is ignore us. The OAD characteristics stay /// untouched — those *can* brick it, and it is sealed. pub fn variant(name: &str) -> Option<&'static Variant> { VARIANTS.iter().find(|v| v.name == name) } /// A command, in the form the Zwift Ride protocol write-up documents: the byte /// `0x00`, then protobuf field 1 carrying the parameter. /// /// This is the shape we should have been writing all along. Our `0xff …` frames /// were being read as *command 255*, and `0x3e {1: 255, 2: 5}` was the device /// saying so — the command id echoed back with a status, not a rejected key. pub fn command(param: u64) -> Vec { let mut frame = vec![0x00]; field_varint(&mut frame, 1, param); frame } pub const VARIANTS: &[Variant] = &[ Variant { name: "info", why: "the documented information request, `00 08 00` — if the command channel \ works at all, this is what proves it", build: |_, _| command(0), }, Variant { name: "param770", why: "`00 08 82 06`, the other documented command; 770 is 0x0302, which is the \ pod's own RideOn marker read as a number", build: |_, _| command(770), }, Variant { name: "compressed+ours", why: "what we have already sent twice — the control for the sweep", build: |k, _| reply_frame(&k.compressed, 0x0009_0000), }, Variant { name: "compressed+none", why: "field 1 alone, in case field 2 is the objection", build: |k, _| { let mut body = Vec::new(); field_bytes(&mut body, 1, &k.compressed); envelope(body) }, }, Variant { name: "compressed+trailer", why: "the pod's own field 3 echoed back — if the trailer is load-bearing, this is the cheapest way to find out", build: |k, offer| { let mut body = Vec::new(); field_bytes(&mut body, 1, &k.compressed); field_varint(&mut body, 2, 0x0009_0000); field_bytes(&mut body, 3, &offer.trailer); envelope(body) }, }, Variant { name: "uncompressed+ours", why: "a 65-byte SEC1 point, since the Play generation exchanged uncompressed keys", build: |k, _| reply_frame(&k.uncompressed, 0x0009_0000), }, Variant { name: "play-rideon", why: "the documented 2023 Play handshake verbatim: RideOn 01 02 + a raw 64-byte key, no protobuf envelope at all", build: |k, _| { let mut frame = Vec::with_capacity(72); frame.extend_from_slice(b"RideOn"); frame.extend_from_slice(&[0x01, 0x02]); // SEC1 uncompressed minus the 0x04 tag, which is how Play carried it. frame.extend_from_slice(&k.uncompressed[1..]); frame }, }, ]; /// `ff 03 00` around a protobuf body. fn envelope(body: Vec) -> Vec { let mut frame = Vec::with_capacity(body.len() + 3); frame.extend_from_slice(&[0xff, 0x03, 0x00]); frame.extend_from_slice(&body); frame } /// The pod's answer to something we sent. `0x3e`, two varints. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Response { pub code: u64, pub detail: u64, } pub fn parse_response(raw: &[u8]) -> Option { if raw.first() != Some(&0x3e) { return None; } let mut i = 1; let mut r = Response { code: 0, detail: 0 }; while i < raw.len() { let tag = read_varint(raw, &mut i)?; let v = match tag & 7 { 0 => read_varint(raw, &mut i)?, _ => return None, }; match tag >> 3 { 1 => r.code = v, 2 => r.detail = v, _ => {} } } Some(r) } pub fn candidate(name: &str) -> Option { CANDIDATES.iter().find(|c| c.name == name).copied() } // --------------------------------------------------------------------------- // Minimal protobuf, write side // --------------------------------------------------------------------------- fn varint(out: &mut Vec, mut v: u64) { loop { let byte = (v & 0x7f) as u8; v >>= 7; if v == 0 { out.push(byte); return; } out.push(byte | 0x80); } } fn field_bytes(out: &mut Vec, field: u32, value: &[u8]) { varint(out, u64::from(field) << 3 | 2); varint(out, value.len() as u64); out.extend_from_slice(value); } fn field_varint(out: &mut Vec, field: u32, value: u64) { varint(out, u64::from(field) << 3); varint(out, value); } /// The reply, shaped exactly like the offer we are answering. /// /// `ff 03 00` then `{1: our compressed public key, 2: marker}`. Field 3 of the /// pod's own offer — 40 or 60 bytes, unexplained — is deliberately omitted: if /// it turns out to be load-bearing, no candidate will hold the paddles open and /// that is itself the finding. pub fn reply_frame(public_key: &[u8], marker: u32) -> Vec { let mut body = Vec::with_capacity(48); field_bytes(&mut body, 1, public_key); field_varint(&mut body, 2, u64::from(marker)); let mut frame = Vec::with_capacity(body.len() + 3); frame.extend_from_slice(&[0xff, 0x03, 0x00]); frame.extend_from_slice(&body); frame } // --------------------------------------------------------------------------- // Minimal protobuf, read side // --------------------------------------------------------------------------- fn read_varint(b: &[u8], i: &mut usize) -> Option { let mut v = 0u64; let mut shift = 0; loop { let byte = *b.get(*i)?; *i += 1; v |= u64::from(byte & 0x7f) << shift; if byte & 0x80 == 0 { return Some(v); } shift += 7; if shift > 63 { return None; } } } /// The pod's key offer, as much of it as we can name. #[derive(Debug, Clone, Default)] pub struct KeyOffer { /// Field 1 — 33 bytes, a compressed P-256 point. pub public_key: Vec, /// Field 2 — `0x02030000` on every capture so far. pub marker: u64, /// Field 3 — 40 or 60 bytes, meaning unknown. pub trailer: Vec, } /// Recognise `ff 03 00` + protobuf. Returns `None` for anything else. pub fn parse_key_offer(raw: &[u8]) -> Option { if raw.len() < 4 || raw[0] != 0xff || raw[1] != 0x03 { return None; } let mut i = 3; let mut offer = KeyOffer { public_key: Vec::new(), marker: 0, trailer: Vec::new(), }; while i < raw.len() { let tag = read_varint(raw, &mut i)?; let (field, wire) = (tag >> 3, tag & 7); match wire { 0 => { let v = read_varint(raw, &mut i)?; if field == 2 { offer.marker = v; } } 2 => { let len = read_varint(raw, &mut i)? as usize; let end = i.checked_add(len)?; let value = raw.get(i..end)?.to_vec(); i = end; match field { 1 => offer.public_key = value, 3 => offer.trailer = value, _ => {} } } // Nothing in the captures uses the other wire types; bail rather // than mis-parse and report a confident wrong answer. _ => return None, } } (!offer.public_key.is_empty()).then_some(offer) } /// The pod's status frame — `ff 05 00`, field 93 nested. `.2` flips 0 → 1 and /// `.3` goes 15 → 900 as the paddles die (§2.3.3). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Status { pub flag: u64, pub timer: u64, } pub fn parse_status(raw: &[u8]) -> Option { if raw.len() < 4 || raw[0] != 0xff || raw[1] != 0x05 { return None; } let mut i = 3; let tag = read_varint(raw, &mut i)?; if tag >> 3 != 93 || tag & 7 != 2 { return None; } let len = read_varint(raw, &mut i)? as usize; let end = i.checked_add(len)?; let inner = raw.get(i..end)?; let mut j = 0; let mut status = Status { flag: 0, timer: 0 }; while j < inner.len() { let tag = read_varint(inner, &mut j)?; let (field, wire) = (tag >> 3, tag & 7); match wire { 0 => { let v = read_varint(inner, &mut j)?; match field { 2 => status.flag = v, 3 => status.timer = v, _ => {} } } 2 => { let len = read_varint(inner, &mut j)? as usize; j = j.checked_add(len)?; } _ => return None, } } Some(status) } // --------------------------------------------------------------------------- // The verdict // --------------------------------------------------------------------------- /// Which bits the paddles occupy, confirmed 2026-08-05 (§2.3.1). const PADDLE_MINUS: u32 = 1 << 8; const PADDLE_PLUS: u32 = 1 << 12; const PADDLES: u32 = PADDLE_MINUS | PADDLE_PLUS; /// Tracks the one thing this experiment is for: are the paddles still alive? pub struct Verdict { start: Instant, pub paddle_edges: u32, pub last_paddle: Option, pub other_edges: u32, pub last_other: Option, last_mask: Option, } impl Verdict { pub fn new(start: Instant) -> Self { Self { start, paddle_edges: 0, last_paddle: None, other_edges: 0, last_other: None, last_mask: None, } } /// Feed a button bitmask. Only *changes* count, since the pod repeats at /// ~10 Hz while anything is held. pub fn observe(&mut self, mask: u32) { let Some(previous) = self.last_mask.replace(mask) else { return; }; let changed = previous ^ mask; let at = self.start.elapsed(); if changed & PADDLES != 0 { self.paddle_edges += 1; self.last_paddle = Some(at); } if changed & !PADDLES != 0 { self.other_edges += 1; self.last_other = Some(at); } } /// The pod is *reachable* — the D-pad still reports — but the paddles have /// gone quiet. That, and not a dropped link, is the failure being chased. pub fn paddles_look_dead(&self, cliff: Duration) -> bool { let alive_elsewhere = self.last_other.is_some_and(|t| t > cliff); let paddles_quiet = self.last_paddle.is_none_or(|t| t <= cliff); alive_elsewhere && paddles_quiet } } /// A P-256 keypair, and the compressed point to put on the wire. pub struct LocalKey { pub secret: p256::ecdh::EphemeralSecret, pub compressed: Vec, /// SEC1 uncompressed, `04 ‖ X ‖ Y`, 65 bytes. pub uncompressed: Vec, } pub fn local_key() -> LocalKey { use p256::elliptic_curve::sec1::ToEncodedPoint; let secret = p256::ecdh::EphemeralSecret::random(&mut rand_core::OsRng); let public = secret.public_key(); let compressed = public.to_encoded_point(true).as_bytes().to_vec(); let uncompressed = public.to_encoded_point(false).as_bytes().to_vec(); LocalKey { secret, compressed, uncompressed, } } /// Best-effort ECDH against the pod's offered point, for the log. A key we /// cannot agree on is a candidate we can stop testing. pub fn shared_secret(local: &LocalKey, peer: &[u8]) -> Result> { use p256::elliptic_curve::sec1::FromEncodedPoint; let point = p256::EncodedPoint::from_bytes(peer) .map_err(|e| anyhow::anyhow!("the pod's point is not a valid SEC1 encoding: {e}"))?; let public = Option::::from(p256::PublicKey::from_encoded_point(&point)) .ok_or_else(|| anyhow::anyhow!("the pod's point is not on P-256"))?; Ok(local .secret .diffie_hellman(&public) .raw_secret_bytes() .to_vec()) } #[cfg(test)] mod tests { use super::*; /// The five frames captured on the tablet, 2026-08-27. const OFFER: &str = "ff03000a21026d059e761978c34f8a13eedbff764a34f0e48577d90fb5dea76ef6238eae8580108080\ 8c101a285e71479dbe97b0c9bf3c754d594d67ca40792345b69a921905489ec5a3cd0b1e5bb5e36e8cb3e683"; fn bytes(h: &str) -> Vec { (0..h.len()) .step_by(2) .map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()) .collect() } #[test] fn the_captured_offer_parses() { let offer = parse_key_offer(&bytes(OFFER)).expect("captured frame should parse"); assert_eq!(offer.public_key.len(), 33); // Compressed SEC1: 0x02 or 0x03 then the x coordinate. assert!(matches!(offer.public_key[0], 0x02 | 0x03)); assert_eq!(offer.marker, 0x0203_0000); assert_eq!(offer.trailer.len(), 40); } #[test] fn the_status_flag_and_timer_are_read() { // Before the paddles died, and after. let before = bytes("ff0500ea05180a0c3334433435393033413138451000180f200828093020"); let after = bytes("ff0500ea05190a0c3334433435393033413138451001188407200828093020"); assert_eq!(parse_status(&before).unwrap(), Status { flag: 0, timer: 15 }); assert_eq!(parse_status(&after).unwrap(), Status { flag: 1, timer: 900 }); } #[test] fn a_button_frame_is_not_mistaken_for_a_key_offer() { assert!(parse_key_offer(&bytes("2308ffffffff0f")).is_none()); assert!(parse_status(&bytes("2308ffffffff0f")).is_none()); } #[test] fn our_reply_is_shaped_like_the_offer_it_answers() { let key = local_key(); assert_eq!(key.compressed.len(), 33); let frame = reply_frame(&key.compressed, 0x0009_0000); let echoed = parse_key_offer(&frame).expect("our own frame should parse"); assert_eq!(echoed.public_key, key.compressed); assert_eq!(echoed.marker, 0x0009_0000); assert!(echoed.trailer.is_empty()); } /// The oracle: the D-pad still moving while the paddles do not is the /// signature being chased, and neither silence alone nor a live paddle is. #[test] fn dead_paddles_need_a_live_d_pad_to_be_evidence() { let start = Instant::now(); let cliff = Duration::from_secs(0); let mut nothing_at_all = Verdict::new(start); nothing_at_all.observe(0xffff_ffff); assert!(!nothing_at_all.paddles_look_dead(cliff)); let mut d_pad_only = Verdict::new(start); d_pad_only.observe(0xffff_ffff); d_pad_only.observe(0xffff_fffe); // `left` assert!(d_pad_only.paddles_look_dead(cliff)); let mut healthy = Verdict::new(start); healthy.observe(0xffff_ffff); healthy.observe(0xffff_fffe); healthy.observe(0xffff_feff); // `−` paddle assert!(!healthy.paddles_look_dead(cliff)); } }