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:
2026-08-27 19:19:29 +02:00
co-authored by Claude Opus 5
parent 135da73e92
commit 4e400cdd3b
6 changed files with 783 additions and 2 deletions
+193 -1
View File
@@ -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::<Vec<_>>()
.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<unlock::Status> = 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})"))