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:
2026-08-27 19:53:43 +02:00
co-authored by Claude Opus 5
parent b1e6c08d07
commit c1c6ca390c
4 changed files with 86 additions and 5 deletions
+19 -1
View File
@@ -24,7 +24,10 @@ SUBCOMMANDS:
past the ~50 s cliff (--candidate ours|play|echo; omit for a
control run that answers nothing). --sweep sends every known
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
listen <ADDR> Raw GATT: dump services, characteristics and descriptors, then
subscribe to everything and print each notification's
@@ -111,6 +114,8 @@ pub enum Command {
sweep: bool,
/// Send exactly one named variant, so its effect is unambiguous.
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
/// 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 sweep = false;
let mut variant: Option<String> = None;
let mut keepalive: Option<u64> = None;
let mut help = false;
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,
"--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" => {
i += 1;
variant = Some(
@@ -265,6 +282,7 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
candidate: candidate.clone(),
sweep,
variant: variant.clone(),
keepalive: keepalive.map(Duration::from_secs),
},
"zwift" => Command::Zwift {
device: device(&positional, 1)?,