diff --git a/Cargo.lock b/Cargo.lock index 3e3e4eb..76ae9fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,6 +222,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -308,6 +314,8 @@ dependencies = [ "bikecontrol-core", "btleplug", "futures", + "p256", + "rand_core", "tokio", "tracing", "tracing-subscriber", @@ -641,6 +649,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cookie" version = "0.18.1" @@ -724,6 +738,18 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -893,6 +919,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -962,6 +998,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1103,6 +1140,25 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -1212,6 +1268,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -1505,6 +1571,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1637,6 +1704,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -1731,6 +1809,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2706,6 +2802,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve", + "primeorder", +] + [[package]] name = "pango" version = "0.18.3" @@ -2916,6 +3022,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3008,6 +3123,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3253,6 +3377,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3625,6 +3762,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -5527,6 +5670,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/crates/probe/Cargo.toml b/crates/probe/Cargo.toml index 764e5a2..f1ad175 100644 --- a/crates/probe/Cargo.toml +++ b/crates/probe/Cargo.toml @@ -18,3 +18,9 @@ uuid = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +# Protocol discovery only (§9): the pod offers a compressed P-256 point and we +# need a real one to answer with. Pure Rust, and deliberately confined to the +# probe until an unlock candidate is proven — the app takes no crypto +# dependency on a guess. +p256 = { version = "0.13", default-features = false, features = ["ecdh", "arithmetic"] } +rand_core = { version = "0.6", features = ["getrandom"] } diff --git a/crates/probe/src/cli.rs b/crates/probe/src/cli.rs index 4d7b8cc..87a81fd 100644 --- a/crates/probe/src/cli.rs +++ b/crates/probe/src/cli.rs @@ -20,6 +20,9 @@ SUBCOMMANDS: inspect Connect and dump every service, characteristic and capability monitor Stream Indoor Bike Data as raw hex alongside decoded fields set Take control and apply a target, then reset the trainer to zero + unlock 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) zwift Talk to Zwift's custom service: handshake, then log every frame listen Raw GATT: dump services, characteristics and descriptors, then subscribe to everything and print each notification's @@ -33,7 +36,7 @@ TARGET (for `set`): OPTIONS: --secs scan/monitor duration, or how long `set` holds the target (default: - scan 6, monitor 30, set 15, zwift 60) + scan 6, monitor 30, set 15, zwift 60, unlock 150) --all `scan`: list every peripheral, not just fitness machines --name use in place of to match on advertised name --no-handshake `zwift`, `listen`: subscribe and listen without writing RideOn. @@ -89,6 +92,18 @@ pub enum Command { /// until it is greeted, so listening alone hears silence from one. handshake: bool, }, + /// Does answering the pod's key offer keep its paddles alive? + /// + /// One candidate reply per run, then two minutes of watching the paddle + /// bits. See `unlock.rs` for what is being tested and why a guess is + /// affordable here. + Unlock { + device: Device, + duration: Duration, + /// Which field-2 marker to answer with; `None` sends nothing and is the + /// control run. + candidate: Option, + }, /// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises /// it — a Click, or the trainer itself. Zwift { @@ -124,6 +139,7 @@ pub fn parse>(argv: I) -> Result { let mut no_handshake = false; let mut buttons_only = false; let mut name: Option = None; + let mut candidate: Option = None; let mut help = false; let mut positional: Vec = Vec::new(); @@ -147,6 +163,14 @@ pub fn parse>(argv: I) -> Result { .map_err(|_| anyhow!("--secs expects a whole number of seconds, got {v:?}"))?, ); } + "--candidate" => { + i += 1; + candidate = Some( + args.get(i) + .ok_or_else(|| anyhow!("--candidate needs a value"))? + .clone(), + ); + } "--name" => { i += 1; name = Some( @@ -214,6 +238,13 @@ pub fn parse>(argv: I) -> Result { duration: Duration::from_secs(secs.unwrap_or(60)), handshake: !no_handshake, }, + "unlock" => Command::Unlock { + device: device(&positional, 1)?, + // Long enough to cross the ~50 s cliff twice over: a candidate that + // merely delays the failure must not read as one that fixed it. + duration: Duration::from_secs(secs.unwrap_or(150)), + candidate: candidate.clone(), + }, "zwift" => Command::Zwift { device: device(&positional, 1)?, duration: Duration::from_secs(secs.unwrap_or(60)), diff --git a/crates/probe/src/commands.rs b/crates/probe/src/commands.rs index 30a6a60..9beac33 100644 --- a/crates/probe/src/commands.rs +++ b/crates/probe/src/commands.rs @@ -18,7 +18,7 @@ use bikecontrol_ble::indoor_bike_data::{self, hex, IndoorBikeData}; use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector}; use bikecontrol_ble::{uuids, zwift, FtmsError}; use bikecontrol_core::types::ControlTarget; -use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _}; +use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _, WriteType}; use btleplug::platform::Peripheral; use futures::StreamExt; use uuid::Uuid; @@ -966,6 +966,198 @@ fn pressed(b: bool) -> &'static str { } /// Name a UUID, checking Zwift's custom space as well as the SIG's. +/// `probe unlock` — answer the pod's key offer and see whether the paddles live. +/// +/// The experiment, not an implementation: see `crate::unlock` for the +/// hypothesis and why one guess per run is affordable. +pub async fn unlock_cmd( + device: &Device, + duration: Duration, + candidate: Option<&str>, + scan_timeout: Duration, +) -> Result<()> { + use crate::unlock; + + let candidate = match candidate { + None => None, + Some(name) => Some(unlock::candidate(name).ok_or_else(|| { + anyhow::anyhow!( + "unknown candidate {name:?}. Known: {}", + unlock::CANDIDATES + .iter() + .map(|c| c.name) + .collect::>() + .join(", ") + ) + })?), + }; + + match candidate { + Some(c) => println!("Candidate {:?}: field 2 = 0x{:08x}\n {}\n", c.name, c.marker, c.why), + None => println!( + "Control run: answering nothing, to measure the cliff this pod actually has.\n" + ), + } + + let peripheral = connect(device, scan_timeout).await?; + if let Some(d) = scan::describe(&peripheral).await { + println!("Connected to {} ({})\n", d.address, d.label()); + } + + let service = zwift::SERVICES + .iter() + .find_map(|want| peripheral.services().into_iter().find(|s| s.uuid == *want)) + .ok_or_else(|| anyhow::anyhow!("this peripheral exposes no known Zwift service"))?; + + let mut notifications = peripheral.notifications().await?; + for ch in service + .characteristics + .iter() + .filter(|c| c.properties.intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE)) + { + if let Err(e) = peripheral.subscribe(ch).await { + println!("Could not subscribe to {}: {e}", ch.uuid); + } + } + + let sync_rx = writable(&service) + .ok_or_else(|| anyhow::anyhow!("nothing in this service is writable — cannot answer"))?; + + let start = Instant::now(); + handshake(&peripheral, &sync_rx, &mut notifications, start).await?; + + let local = unlock::local_key(); + println!( + "Our P-256 point: {}\n\nWatching for {} s. Work the paddles and the D-pad throughout —\n\ + the question is whether the paddles are still reporting at the end.\n", + hex(&local.compressed), + duration.as_secs() + ); + + let mut verdict = unlock::Verdict::new(start); + let mut answered = 0u32; + let mut offers = 0u32; + let mut last_status: Option = None; + + let deadline = tokio::time::sleep(duration); + tokio::pin!(deadline); + + loop { + tokio::select! { + _ = &mut deadline => break, + _ = tokio::signal::ctrl_c() => { + println!("\nInterrupted."); + break; + } + n = notifications.next() => { + let Some(n) = n else { + println!("\nThe device disconnected — the run is void, not a failure."); + break; + }; + let at = start.elapsed().as_secs_f32(); + + if let Some(offer) = unlock::parse_key_offer(&n.value) { + offers += 1; + println!( + "[{at:7.2}s] KEY OFFER #{offers} key={} marker=0x{:08x} trailer={}B", + hex(&offer.public_key), + offer.marker, + offer.trailer.len() + ); + match unlock::shared_secret(&local, &offer.public_key) { + // Logged, not used: agreeing a secret proves the point + // is real P-256, which is worth knowing before anyone + // writes a responder around it. + Ok(secret) => println!( + " ECDH agrees, shared secret starts {}", + hex(&secret[..8.min(secret.len())]) + ), + Err(e) => println!(" !! {e}"), + } + if let Some(c) = candidate { + let frame = unlock::reply_frame(&local.compressed, c.marker); + match write_frame(&peripheral, &sync_rx, &frame).await { + Ok(()) => { + answered += 1; + println!(" answered with {}", hex(&frame)); + } + Err(e) => println!(" !! could not answer: {e}"), + } + } + continue; + } + + if let Some(status) = unlock::parse_status(&n.value) { + if last_status != Some(status) { + println!( + "[{at:7.2}s] STATUS flag={} timer={}", + status.flag, status.timer + ); + last_status = Some(status); + } + continue; + } + + if let Some(mask) = button_mask(&n.value) { + verdict.observe(mask.raw); + } + } + } + } + + let cliff = Duration::from_secs(60); + println!("\n=== verdict ==="); + println!(" key offers seen: {offers}"); + println!(" answered: {answered}"); + println!( + " paddle edges: {} (last at {})", + verdict.paddle_edges, + verdict.last_paddle.map_or("never".into(), |t| format!("{:.1}s", t.as_secs_f32())) + ); + println!( + " other edges: {} (last at {})", + verdict.other_edges, + verdict.last_other.map_or("never".into(), |t| format!("{:.1}s", t.as_secs_f32())) + ); + + if verdict.paddle_edges == 0 && verdict.other_edges == 0 { + println!( + "\n INCONCLUSIVE — no buttons at all. Press things during the run;\n\ + a pod nobody touched proves nothing." + ); + } else if verdict.paddles_look_dead(cliff) { + println!( + "\n FAILED — the D-pad still reports and the paddles stopped.\n\ + That is the §2.3.3 signature, so this candidate did not hold them open." + ); + } else if verdict.last_paddle.is_some_and(|t| t > cliff) { + println!( + "\n HELD — a paddle edge arrived after {}s, past the cliff.\n\ + Worth repeating before believing: run it again, and run the control.", + cliff.as_secs() + ); + } else { + println!("\n INCONCLUSIVE — the run ended before the cliff, or the paddles were idle."); + } + + disconnect(&peripheral).await; + Ok(()) +} + +/// Write to the pod, preferring write-without-response where offered. +async fn write_frame( + peripheral: &Peripheral, + ch: &Characteristic, + frame: &[u8], +) -> Result<(), btleplug::Error> { + let kind = if ch.properties.contains(CharPropFlags::WRITE_WITHOUT_RESPONSE) { + WriteType::WithoutResponse + } else { + WriteType::WithResponse + }; + peripheral.write(ch, frame, kind).await +} + fn zwift_named(uuid: Uuid) -> String { zwift::well_known_name(uuid) .map(|n| format!(" ({n})")) diff --git a/crates/probe/src/main.rs b/crates/probe/src/main.rs index c686b28..b1cda9a 100644 --- a/crates/probe/src/main.rs +++ b/crates/probe/src/main.rs @@ -11,6 +11,7 @@ mod cli; mod commands; +mod unlock; use std::time::Duration; @@ -54,6 +55,11 @@ async fn main() -> Result<()> { duration, handshake, } => commands::listen(&device, duration, handshake, SCAN_TIMEOUT).await, + cli::Command::Unlock { + device, + duration, + candidate, + } => commands::unlock_cmd(&device, duration, candidate.as_deref(), SCAN_TIMEOUT).await, cli::Command::Zwift { device, duration, diff --git a/crates/probe/src/unlock.rs b/crates/probe/src/unlock.rs new file mode 100644 index 0000000..a77642b --- /dev/null +++ b/crates/probe/src/unlock.rs @@ -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 { + CANDIDATES.iter().find(|c| c.name == name).copied() +} + +// --------------------------------------------------------------------------- +// Minimal protobuf, write side +// --------------------------------------------------------------------------- + +fn varint(out: &mut Vec, 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, 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, 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 { + 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 { + 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, + /// Field 2 — `0x02030000` on every capture so far. + pub marker: u64, + /// Field 3 — 40 or 60 bytes, meaning unknown. + pub trailer: Vec, +} + +/// Recognise `ff 03 00` + protobuf. Returns `None` for anything else. +pub fn parse_key_offer(raw: &[u8]) -> Option { + 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 { + 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, + pub other_edges: u32, + pub last_other: Option, + last_mask: Option, +} + +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, +} + +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> { + 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::::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 { + (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)); + } +}