Read the rejection properly: it was a command id, not a key refusal
Makinolo's Zwift Ride write-up gives the client command format — `00`
then protobuf field 1 with the parameter, so `00 08 00` is the
information request and `00 08 82 06` is parameter 770. Which explains
what the pod has been telling us all along.
We wrote frames beginning `0xff`. The pod answered `3e 08 ff 01 10 05` —
`{1: 255, 2: 5}`. **255 is 0xff**: our own first byte, echoed back as the
command id, with a status. It was never rejecting a key exchange; it was
saying "command 255, unsupported". `0xff` is a device-to-app notification
type and we were writing it back as though it were a command.
The same write-up records that Zwift "got rid of the Bluetooth
communication encryption they were using for the Play and the Click" —
and the Click v2 is newer than the Ride. So the crypto gate this line of
work assumed may not exist at all, which fits the plain fact that the
cleartext buttons work for the first fifty seconds.
That reopens A-2 from a better angle. It calls the thing that removes the
daily unlock a **keep-alive**: a periodic message, not a credential. So
`--keepalive <secs>` sends a chosen frame on a timer for the whole run
and lets the paddle oracle answer, and `info` and `param770` are
variants — the two commands the write-up documents, in the shape it
documents them.
If a periodic `00 08 00` holds the paddles open past the cliff, the fix
is a heartbeat in the controller supervisor and no cryptography at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+19
-1
@@ -24,7 +24,10 @@ SUBCOMMANDS:
|
|||||||
past the ~50 s cliff (--candidate ours|play|echo; omit for a
|
past the ~50 s cliff (--candidate ours|play|echo; omit for a
|
||||||
control run that answers nothing). --sweep sends every known
|
control run that answers nothing). --sweep sends every known
|
||||||
variant in one connection and compares the pod's replies —
|
variant in one connection and compares the pod's replies —
|
||||||
no button presses needed
|
no button presses needed. --variant <name> sends exactly one
|
||||||
|
frame; --keepalive <secs> sends it on a timer all run
|
||||||
|
(variants: info, param770, compressed+ours, compressed+none,
|
||||||
|
compressed+trailer, uncompressed+ours, play-rideon)
|
||||||
zwift <ADDR> Talk to Zwift's custom service: handshake, then log every frame
|
zwift <ADDR> Talk to Zwift's custom service: handshake, then log every frame
|
||||||
listen <ADDR> Raw GATT: dump services, characteristics and descriptors, then
|
listen <ADDR> Raw GATT: dump services, characteristics and descriptors, then
|
||||||
subscribe to everything and print each notification's
|
subscribe to everything and print each notification's
|
||||||
@@ -111,6 +114,8 @@ pub enum Command {
|
|||||||
sweep: bool,
|
sweep: bool,
|
||||||
/// Send exactly one named variant, so its effect is unambiguous.
|
/// Send exactly one named variant, so its effect is unambiguous.
|
||||||
variant: Option<String>,
|
variant: Option<String>,
|
||||||
|
/// Send that variant on a timer for the whole run, rather than once.
|
||||||
|
keepalive: Option<Duration>,
|
||||||
},
|
},
|
||||||
/// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises
|
/// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises
|
||||||
/// it — a Click, or the trainer itself.
|
/// it — a Click, or the trainer itself.
|
||||||
@@ -150,6 +155,7 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
|||||||
let mut candidate: Option<String> = None;
|
let mut candidate: Option<String> = None;
|
||||||
let mut sweep = false;
|
let mut sweep = false;
|
||||||
let mut variant: Option<String> = None;
|
let mut variant: Option<String> = None;
|
||||||
|
let mut keepalive: Option<u64> = None;
|
||||||
let mut help = false;
|
let mut help = false;
|
||||||
let mut positional: Vec<String> = Vec::new();
|
let mut positional: Vec<String> = Vec::new();
|
||||||
|
|
||||||
@@ -174,6 +180,17 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
"--sweep" => sweep = true,
|
"--sweep" => sweep = true,
|
||||||
|
"--keepalive" => {
|
||||||
|
i += 1;
|
||||||
|
let v = args
|
||||||
|
.get(i)
|
||||||
|
.ok_or_else(|| anyhow!("--keepalive needs a value in seconds"))?
|
||||||
|
.clone();
|
||||||
|
keepalive = Some(
|
||||||
|
v.parse()
|
||||||
|
.map_err(|_| anyhow!("--keepalive expects whole seconds, got {v:?}"))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
"--variant" => {
|
"--variant" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
variant = Some(
|
variant = Some(
|
||||||
@@ -265,6 +282,7 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
|||||||
candidate: candidate.clone(),
|
candidate: candidate.clone(),
|
||||||
sweep,
|
sweep,
|
||||||
variant: variant.clone(),
|
variant: variant.clone(),
|
||||||
|
keepalive: keepalive.map(Duration::from_secs),
|
||||||
},
|
},
|
||||||
"zwift" => Command::Zwift {
|
"zwift" => Command::Zwift {
|
||||||
device: device(&positional, 1)?,
|
device: device(&positional, 1)?,
|
||||||
|
|||||||
@@ -977,6 +977,7 @@ pub async fn unlock_cmd(
|
|||||||
candidate: Option<&str>,
|
candidate: Option<&str>,
|
||||||
sweep: bool,
|
sweep: bool,
|
||||||
variant: Option<&str>,
|
variant: Option<&str>,
|
||||||
|
keepalive: Option<Duration>,
|
||||||
scan_timeout: Duration,
|
scan_timeout: Duration,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use crate::unlock;
|
use crate::unlock;
|
||||||
@@ -1011,8 +1012,16 @@ pub async fn unlock_cmd(
|
|||||||
)
|
)
|
||||||
})?),
|
})?),
|
||||||
};
|
};
|
||||||
if let Some(v) = single {
|
match (single, keepalive) {
|
||||||
println!("Sending exactly one frame this run: {}\n", v.name);
|
(Some(v), Some(every)) => println!(
|
||||||
|
"Keep-alive run: sending {} every {}s for the whole run.\n\
|
||||||
|
If the paddles are still reporting at the end, that is the fix.\n",
|
||||||
|
v.name,
|
||||||
|
every.as_secs()
|
||||||
|
),
|
||||||
|
(Some(v), None) => println!("Sending exactly one frame this run: {}\n", v.name),
|
||||||
|
(None, Some(_)) => anyhow::bail!("--keepalive needs --variant to say what to send"),
|
||||||
|
(None, None) => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let peripheral = connect(device, scan_timeout).await?;
|
let peripheral = connect(device, scan_timeout).await?;
|
||||||
@@ -1059,6 +1068,7 @@ pub async fn unlock_cmd(
|
|||||||
let mut sent: Vec<&'static str> = Vec::new();
|
let mut sent: Vec<&'static str> = Vec::new();
|
||||||
let mut responses: Vec<(&'static str, unlock::Response)> = Vec::new();
|
let mut responses: Vec<(&'static str, unlock::Response)> = Vec::new();
|
||||||
let mut zeros: u64 = 0;
|
let mut zeros: u64 = 0;
|
||||||
|
let mut beats: u64 = 0;
|
||||||
let mut last_mask: Option<u32> = None;
|
let mut last_mask: Option<u32> = None;
|
||||||
// When the pod flipped its status flag, which is the cliff this run is
|
// 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.
|
// measured against — better than a constant, because the pod says so.
|
||||||
@@ -1067,6 +1077,15 @@ pub async fn unlock_cmd(
|
|||||||
let deadline = tokio::time::sleep(duration);
|
let deadline = tokio::time::sleep(duration);
|
||||||
tokio::pin!(deadline);
|
tokio::pin!(deadline);
|
||||||
|
|
||||||
|
// A-2 calls the thing that removes the daily unlock a *keep-alive*. That is
|
||||||
|
// a periodic message, not a credential, and the Ride write-up gives the
|
||||||
|
// shape of one. So: send it on a timer and let the paddles answer.
|
||||||
|
let mut heartbeat = keepalive.map(|every| {
|
||||||
|
let mut t = tokio::time::interval(every);
|
||||||
|
t.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
t
|
||||||
|
});
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = &mut deadline => break,
|
_ = &mut deadline => break,
|
||||||
@@ -1074,6 +1093,18 @@ pub async fn unlock_cmd(
|
|||||||
println!("\nInterrupted.");
|
println!("\nInterrupted.");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
_ = async { heartbeat.as_mut().unwrap().tick().await }, if heartbeat.is_some() => {
|
||||||
|
let Some(v) = single else { continue };
|
||||||
|
let frame = (v.build)(&local, &unlock::KeyOffer::default());
|
||||||
|
let at = start.elapsed().as_secs_f32();
|
||||||
|
match write_frame(&peripheral, &sync_rx, &frame).await {
|
||||||
|
Ok(()) => {
|
||||||
|
beats += 1;
|
||||||
|
println!("[{at:7.2}s] KEEPALIVE #{beats} {} {}", v.name, hex(&frame));
|
||||||
|
}
|
||||||
|
Err(e) => println!("[{at:7.2}s] KEEPALIVE !! {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
n = notifications.next() => {
|
n = notifications.next() => {
|
||||||
let Some(n) = n else {
|
let Some(n) = n else {
|
||||||
println!("\nThe device disconnected — the run is void, not a failure.");
|
println!("\nThe device disconnected — the run is void, not a failure.");
|
||||||
@@ -1099,7 +1130,10 @@ pub async fn unlock_cmd(
|
|||||||
),
|
),
|
||||||
Err(e) => println!(" !! {e}"),
|
Err(e) => println!(" !! {e}"),
|
||||||
}
|
}
|
||||||
if let Some(one) = single {
|
if keepalive.is_some() {
|
||||||
|
// The heartbeat is the experiment; firing again here
|
||||||
|
// would confound which write did what.
|
||||||
|
} else if let Some(one) = single {
|
||||||
// One write per connection. The sweep's replies came
|
// One write per connection. The sweep's replies came
|
||||||
// back in a single burst six seconds after the first
|
// back in a single burst six seconds after the first
|
||||||
// write, so "attributed to the last thing sent" was a
|
// write, so "attributed to the last thing sent" was a
|
||||||
@@ -1232,6 +1266,9 @@ pub async fn unlock_cmd(
|
|||||||
_ => println!(" cliff: never flipped"),
|
_ => println!(" cliff: never flipped"),
|
||||||
}
|
}
|
||||||
println!(" key offers seen: {offers}");
|
println!(" key offers seen: {offers}");
|
||||||
|
if beats > 0 {
|
||||||
|
println!(" keep-alives sent: {beats}");
|
||||||
|
}
|
||||||
if zeros > 0 {
|
if zeros > 0 {
|
||||||
println!(" all-zero frames: {zeros} <- the stream stopped carrying data");
|
println!(" all-zero frames: {zeros} <- the stream stopped carrying data");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ async fn main() -> Result<()> {
|
|||||||
candidate,
|
candidate,
|
||||||
sweep,
|
sweep,
|
||||||
variant,
|
variant,
|
||||||
|
keepalive,
|
||||||
} => {
|
} => {
|
||||||
commands::unlock_cmd(
|
commands::unlock_cmd(
|
||||||
&device,
|
&device,
|
||||||
@@ -68,6 +69,7 @@ async fn main() -> Result<()> {
|
|||||||
candidate.as_deref(),
|
candidate.as_deref(),
|
||||||
sweep,
|
sweep,
|
||||||
variant.as_deref(),
|
variant.as_deref(),
|
||||||
|
keepalive,
|
||||||
SCAN_TIMEOUT,
|
SCAN_TIMEOUT,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -91,7 +91,31 @@ pub fn variant(name: &str) -> Option<&'static Variant> {
|
|||||||
VARIANTS.iter().find(|v| v.name == name)
|
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<u8> {
|
||||||
|
let mut frame = vec![0x00];
|
||||||
|
field_varint(&mut frame, 1, param);
|
||||||
|
frame
|
||||||
|
}
|
||||||
|
|
||||||
pub const VARIANTS: &[Variant] = &[
|
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 {
|
Variant {
|
||||||
name: "compressed+ours",
|
name: "compressed+ours",
|
||||||
why: "what we have already sent twice — the control for the sweep",
|
why: "what we have already sent twice — the control for the sweep",
|
||||||
@@ -242,7 +266,7 @@ fn read_varint(b: &[u8], i: &mut usize) -> Option<u64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The pod's key offer, as much of it as we can name.
|
/// The pod's key offer, as much of it as we can name.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct KeyOffer {
|
pub struct KeyOffer {
|
||||||
/// Field 1 — 33 bytes, a compressed P-256 point.
|
/// Field 1 — 33 bytes, a compressed P-256 point.
|
||||||
pub public_key: Vec<u8>,
|
pub public_key: Vec<u8>,
|
||||||
|
|||||||
Reference in New Issue
Block a user