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>
532 lines
18 KiB
Rust
532 lines
18 KiB
Rust
//! `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<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 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<u8> {
|
||
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<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()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Minimal protobuf, write side
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn varint(out: &mut Vec<u8>, 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<u8>, 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<u8>, 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<u8> {
|
||
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<u64> {
|
||
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<u8>,
|
||
/// Field 2 — `0x02030000` on every capture so far.
|
||
pub marker: u64,
|
||
/// Field 3 — 40 or 60 bytes, meaning unknown.
|
||
pub trailer: Vec<u8>,
|
||
}
|
||
|
||
/// Recognise `ff 03 00` + protobuf. Returns `None` for anything else.
|
||
pub fn parse_key_offer(raw: &[u8]) -> Option<KeyOffer> {
|
||
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<Status> {
|
||
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<Duration>,
|
||
pub other_edges: u32,
|
||
pub last_other: Option<Duration>,
|
||
last_mask: Option<u32>,
|
||
}
|
||
|
||
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<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 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<Vec<u8>> {
|
||
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::<p256::PublicKey>::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<u8> {
|
||
(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));
|
||
}
|
||
}
|