From 9ab5b5530b6c69398bc043389624a114d1c9661c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 27 Aug 2026 19:39:17 +0200 Subject: [PATCH] Sweep the handshake variants, since the pod answers back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first two candidate runs looked like failures and were not. Buried in them: a `0x3e` frame arriving 90 ms after our write, in both runs, never otherwise — `{1: 255, 2: 5}`. The pod parsed what we sent and rejected it with a reason. That is a feedback channel, and it turns this from guessing into navigating. Both candidates drew the *same* reason, so the field-2 marker is not what it objects to. `--sweep` therefore sends every variant down one connection and prints the reply to each: field 1 alone, the pod's own trailer echoed back, an uncompressed 65-byte point, and the documented 2023 Play handshake verbatim (`RideOn 01 02` + a raw 64-byte key, no protobuf at all) — which we had never actually tried, having assumed the protobuf shape from the offer. It needs no button presses. That matters now: this pod has stopped reporting buttons entirely, so the paddle oracle the rest of the command depends on is unavailable, and a sweep that reads only the reply code still works. Fuzzing a pod is not fuzzing a trainer. §2.3 refused unknown writes to the D100 because it puts resistance under a rider; a Click has no actuator and the worst it can do is ignore us. The OAD characteristics stay untouched — those can brick a sealed unit. Two corrections to the tool while here. Button frames were counted but never printed, so an operator pressing into a silent terminal could not tell a working run from a dead pod and reasonably concluded the latter. And the cliff is now taken from an actual `flag 0 -> 1` transition rather than the first sighting of a 1 — these runs opened with the flag already set, the pod having kept that state across the reconnect, and reporting "cliff at 2.3s" for it was a reading dressed as a measurement. Co-Authored-By: Claude Opus 5 (1M context) --- crates/probe/src/cli.rs | 11 +++- crates/probe/src/commands.rs | 118 +++++++++++++++++++++++++++++++++-- crates/probe/src/main.rs | 5 +- crates/probe/src/unlock.rs | 118 +++++++++++++++++++++++++++++++++-- 4 files changed, 239 insertions(+), 13 deletions(-) diff --git a/crates/probe/src/cli.rs b/crates/probe/src/cli.rs index 87a81fd..f00e671 100644 --- a/crates/probe/src/cli.rs +++ b/crates/probe/src/cli.rs @@ -22,7 +22,9 @@ SUBCOMMANDS: set Take control and apply a target, then reset the trainer to zero unlock Answer the pod's key offer and see whether its paddles survive past the ~50 s cliff (--candidate ours|play|echo; omit for a - control run that answers nothing) + control run that answers nothing). --sweep sends every known + variant in one connection and compares the pod's replies — + no button presses needed zwift Talk to Zwift's custom service: handshake, then log every frame listen Raw GATT: dump services, characteristics and descriptors, then subscribe to everything and print each notification's @@ -103,6 +105,10 @@ pub enum Command { /// Which field-2 marker to answer with; `None` sends nothing and is the /// control run. candidate: Option, + /// Send every variant in one connection and compare the pod's replies. + /// Needs no button presses, so it works on a pod that has stopped + /// reporting them. + sweep: bool, }, /// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises /// it — a Click, or the trainer itself. @@ -140,6 +146,7 @@ pub fn parse>(argv: I) -> Result { let mut buttons_only = false; let mut name: Option = None; let mut candidate: Option = None; + let mut sweep = false; let mut help = false; let mut positional: Vec = Vec::new(); @@ -163,6 +170,7 @@ pub fn parse>(argv: I) -> Result { .map_err(|_| anyhow!("--secs expects a whole number of seconds, got {v:?}"))?, ); } + "--sweep" => sweep = true, "--candidate" => { i += 1; candidate = Some( @@ -244,6 +252,7 @@ pub fn parse>(argv: I) -> Result { // merely delays the failure must not read as one that fixed it. duration: Duration::from_secs(secs.unwrap_or(150)), candidate: candidate.clone(), + sweep, }, "zwift" => Command::Zwift { device: device(&positional, 1)?, diff --git a/crates/probe/src/commands.rs b/crates/probe/src/commands.rs index 9beac33..c4b7030 100644 --- a/crates/probe/src/commands.rs +++ b/crates/probe/src/commands.rs @@ -724,6 +724,7 @@ pub async fn zwift_cmd( // Only meaningful in --buttons mode: the mask as of the previous frame, so // the ~10 Hz repeat while a button is held collapses to one line. let mut last_mask: Option = None; + let mut presses: u64 = 0; loop { @@ -974,6 +975,7 @@ pub async fn unlock_cmd( device: &Device, duration: Duration, candidate: Option<&str>, + sweep: bool, scan_timeout: Duration, ) -> Result<()> { use crate::unlock; @@ -1038,6 +1040,14 @@ pub async fn unlock_cmd( let mut answered = 0u32; let mut offers = 0u32; let mut last_status: Option = None; + // When the pod flipped its status flag, which is the cliff this run is + // measured against — better than a constant, because the pod says so. + let mut sent: Vec<&'static str> = Vec::new(); + let mut responses: Vec<(&'static str, unlock::Response)> = Vec::new(); + let mut last_mask: Option = None; + // When the pod flipped its status flag, which is the cliff this run is + // measured against — better than a constant, because the pod says so. + let mut flip_at: Option = None; let deadline = tokio::time::sleep(duration); tokio::pin!(deadline); @@ -1074,7 +1084,24 @@ pub async fn unlock_cmd( ), Err(e) => println!(" !! {e}"), } - if let Some(c) = candidate { + if sweep { + // One connection, every variant, because the pod hands + // back a reason for each. Spaced so a late reply cannot + // be attributed to the next thing we sent. + for v in unlock::VARIANTS { + let frame = (v.build)(&local, &offer); + println!("\n -> {:<20} {}", v.name, hex(&frame)); + println!(" {}", v.why); + if let Err(e) = write_frame(&peripheral, &sync_rx, &frame).await { + println!(" !! could not send: {e}"); + continue; + } + answered += 1; + sent.push(v.name); + tokio::time::sleep(Duration::from_millis(1200)).await; + } + println!(); + } else if let Some(c) = candidate { let frame = unlock::reply_frame(&local.compressed, c.marker); match write_frame(&peripheral, &sync_rx, &frame).await { Ok(()) => { @@ -1093,20 +1120,75 @@ pub async fn unlock_cmd( "[{at:7.2}s] STATUS flag={} timer={}", status.flag, status.timer ); + // The pod telling us, in its own words, that whatever + // grace it was extending has ended. Everything before + // this is preamble; the run is only evidence from here. + // A *transition*, not merely a first sighting. These + // runs opened with flag already 1 — the pod remembers + // being past the cliff across reconnects — and calling + // that "the cliff at 2.3s" is a reading, not a fact. + let was_zero = last_status.is_some_and(|s| s.flag == 0); + if status.flag == 1 && was_zero && flip_at.is_none() { + flip_at = Some(start.elapsed()); + println!( + "\n >>> THE CLIFF. Keep pressing both paddles and the D-pad for\n >>> another 60 s — everything before this line proves nothing.\n" + ); + } last_status = Some(status); } continue; } + if let Some(r) = unlock::parse_response(&n.value) { + let to = sent.last().copied().unwrap_or("(unsolicited)"); + println!("[{at:7.2}s] REPLY code={} detail={} <- {to}", r.code, r.detail); + responses.push((to, r)); + continue; + } + + if button_mask(&n.value).is_none() + && unlock::parse_key_offer(&n.value).is_none() + && unlock::parse_status(&n.value).is_none() + && unlock::parse_response(&n.value).is_none() + { + println!("[{at:7.2}s] other {}", hex(&n.value)); + } + if let Some(mask) = button_mask(&n.value) { + // Printed, not merely counted. An operator pressing buttons + // into a silent terminal cannot tell a working run from a + // dead pod, and will reasonably conclude the latter. + if last_mask != Some(mask.raw) { + last_mask = Some(mask.raw); + let paddles = mask.raw & ((1 << 8) | (1 << 12)); + let which = match paddles { + p if p == (1 << 8) | (1 << 12) => "", + p if p & (1 << 8) == 0 => " <- − PADDLE", + _ => " <- + PADDLE", + }; + println!( + "[{at:7.2}s] buttons 0x{:08x}{}{}", + mask.raw, + if mask.is_idle() { " (idle)" } else { "" }, + which + ); + } verdict.observe(mask.raw); } } } } - let cliff = Duration::from_secs(60); + // The pod's own flip where we saw it; otherwise the ~50 s the captures show. + let cliff = flip_at.unwrap_or(Duration::from_secs(50)); println!("\n=== verdict ==="); + match (flip_at, last_status) { + (Some(t), _) => println!(" cliff (flag 0->1): {:.1}s", t.as_secs_f32()), + (None, Some(s)) if s.flag == 1 => println!( + " cliff: already past it when we connected — the pod kept\n \x20 that state across the reconnect" + ), + _ => println!(" cliff: never flipped"), + } println!(" key offers seen: {offers}"); println!(" answered: {answered}"); println!( @@ -1120,6 +1202,28 @@ pub async fn unlock_cmd( verdict.last_other.map_or("never".into(), |t| format!("{:.1}s", t.as_secs_f32())) ); + if sweep { + println!("\n variant reply"); + for (name, r) in &responses { + println!(" {name:<22} code={} detail={}", r.code, r.detail); + } + let distinct: std::collections::BTreeSet<_> = + responses.iter().map(|(_, r)| (r.code, r.detail)).collect(); + if responses.is_empty() { + println!("\n The pod answered none of them, which is itself a change from\n the runs where it answered `ff 03 00` frames."); + } else if distinct.len() == 1 { + println!( + "\n Every variant drew the same reply, so none of the things varied —\n the marker, the trailer, the key encoding, the envelope — is what\n it is objecting to." + ); + } else { + println!( + "\n The reply MOVED. Whichever variant differs is the thread to pull:\n that is the first time this device has told us we got warmer." + ); + } + disconnect(&peripheral).await; + return Ok(()); + } + if verdict.paddle_edges == 0 && verdict.other_edges == 0 { println!( "\n INCONCLUSIVE — no buttons at all. Press things during the run;\n\ @@ -1132,12 +1236,16 @@ pub async fn unlock_cmd( ); } else if verdict.last_paddle.is_some_and(|t| t > cliff) { println!( - "\n HELD — a paddle edge arrived after {}s, past the cliff.\n\ + "\n HELD — a paddle edge arrived {:.1}s past the cliff.\n\ Worth repeating before believing: run it again, and run the control.", - cliff.as_secs() + (verdict.last_paddle.unwrap() - cliff).as_secs_f32() ); } else { - println!("\n INCONCLUSIVE — the run ended before the cliff, or the paddles were idle."); + println!( + "\n INCONCLUSIVE — nothing was pressed after the cliff at {:.1}s.\n\ + The run has to keep going, with fingers on the buttons, well past it.", + cliff.as_secs_f32() + ); } disconnect(&peripheral).await; diff --git a/crates/probe/src/main.rs b/crates/probe/src/main.rs index b1cda9a..487756e 100644 --- a/crates/probe/src/main.rs +++ b/crates/probe/src/main.rs @@ -59,7 +59,10 @@ async fn main() -> Result<()> { device, duration, candidate, - } => commands::unlock_cmd(&device, duration, candidate.as_deref(), SCAN_TIMEOUT).await, + sweep, + } => { + commands::unlock_cmd(&device, duration, candidate.as_deref(), sweep, SCAN_TIMEOUT).await + } cli::Command::Zwift { device, duration, diff --git a/crates/probe/src/unlock.rs b/crates/probe/src/unlock.rs index a77642b..60943f3 100644 --- a/crates/probe/src/unlock.rs +++ b/crates/probe/src/unlock.rs @@ -66,6 +66,108 @@ pub const CANDIDATES: &[Candidate] = &[ }, ]; +/// 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 const VARIANTS: &[Variant] = &[ + 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() } @@ -293,17 +395,21 @@ impl Verdict { 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 compressed = secret - .public_key() - .to_encoded_point(true) - .as_bytes() - .to_vec(); - LocalKey { secret, compressed } + 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