Sweep the handshake variants, since the pod answers back
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) <noreply@anthropic.com>
This commit is contained in:
+10
-1
@@ -22,7 +22,9 @@ SUBCOMMANDS:
|
||||
set <ADDR> <TARGET> Take control and apply a target, then reset the trainer to zero
|
||||
unlock <ADDR> 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 <ADDR> Talk to Zwift's custom service: handshake, then log every frame
|
||||
listen <ADDR> 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<String>,
|
||||
/// 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<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
||||
let mut buttons_only = false;
|
||||
let mut name: Option<String> = None;
|
||||
let mut candidate: Option<String> = None;
|
||||
let mut sweep = false;
|
||||
let mut help = false;
|
||||
let mut positional: Vec<String> = Vec::new();
|
||||
|
||||
@@ -163,6 +170,7 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
||||
.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<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
||||
// 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)?,
|
||||
|
||||
@@ -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<u32> = 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<unlock::Status> = 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<u32> = 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<Duration> = 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+112
-6
@@ -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<u8>,
|
||||
}
|
||||
|
||||
/// 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<u8>) -> Vec<u8> {
|
||||
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<Response> {
|
||||
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<Candidate> {
|
||||
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<u8>,
|
||||
/// SEC1 uncompressed, `04 ‖ X ‖ Y`, 65 bytes.
|
||||
pub uncompressed: Vec<u8>,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user