Ask the pod whether answering its key offer keeps the paddles alive
`probe unlock` — the experiment §2.3.3 ends on, not an implementation. The pod offers a compressed P-256 point and gives up on us when we do not answer; the paddle bits freeze while the D-pad keeps reporting. What a working client writes back is the half no capture has, and the public descriptions are all of the older Play hardware — different message types, an uncompressed key, a different channel. But the offer looks like the handshake we already know, moved into a protobuf envelope: its field 2 is `0x02030000`, and `RESPONSE_START` — the pod's confirmed cleartext reply marker — is `[0x02, 0x03]`. That makes the client side a short list rather than a search, and the device is a perfect oracle: either a paddle edge arrives after the cliff or one does not, every run, in two minutes. So the command sends one candidate per run — `ours` (00 09, what we already write), `play` (01 02, the 2023 client marker), `echo` (02 03, in case field 2 names the suite rather than the speaker) — and reports HELD, FAILED or INCONCLUSIVE. Omitting `--candidate` answers nothing and measures the cliff this pod actually has, which is the control every result needs. The verdict deliberately refuses to call a failure from silence: it needs the D-pad still reporting while the paddles do not, because a pod nobody touched proves nothing and a dropped link is void rather than negative. Field 3 of the offer — 40 or 60 bytes, unexplained — is omitted from the reply. If it is load-bearing no candidate will hold, and that is a finding too. p256 is a dependency of the probe alone. The app takes no crypto dependency on a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
//! `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",
|
||||
},
|
||||
];
|
||||
|
||||
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)]
|
||||
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>,
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user