Sweep the handshake variants, since the pod answers back

The first two candidate runs looked like failures and were not. Buried in
them: a `0x3e` frame arriving 90 ms after our write, in both runs, never
otherwise — `{1: 255, 2: 5}`. The pod parsed what we sent and rejected it
with a reason. That is a feedback channel, and it turns this from
guessing into navigating.

Both candidates drew the *same* reason, so the field-2 marker is not what
it objects to. `--sweep` therefore sends every variant down one
connection and prints the reply to each: field 1 alone, the pod's own
trailer echoed back, an uncompressed 65-byte point, and the documented
2023 Play handshake verbatim (`RideOn 01 02` + a raw 64-byte key, no
protobuf at all) — which we had never actually tried, having assumed the
protobuf shape from the offer.

It needs no button presses. That matters now: this pod has stopped
reporting buttons entirely, so the paddle oracle the rest of the command
depends on is unavailable, and a sweep that reads only the reply code
still works.

Fuzzing a pod is not fuzzing a trainer. §2.3 refused unknown writes to
the D100 because it puts resistance under a rider; a Click has no
actuator and the worst it can do is ignore us. The OAD characteristics
stay untouched — those can brick a sealed unit.

Two corrections to the tool while here. Button frames were counted but
never printed, so an operator pressing into a silent terminal could not
tell a working run from a dead pod and reasonably concluded the latter.
And the cliff is now taken from an actual `flag 0 -> 1` transition rather
than the first sighting of a 1 — these runs opened with the flag already
set, the pod having kept that state across the reconnect, and reporting
"cliff at 2.3s" for it was a reading dressed as a measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 19:39:17 +02:00
co-authored by Claude Opus 5
parent 4e400cdd3b
commit 9ab5b5530b
4 changed files with 239 additions and 13 deletions
+112 -6
View File
@@ -66,6 +66,108 @@ pub const CANDIDATES: &[Candidate] = &[
},
];
/// 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 const VARIANTS: &[Variant] = &[
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()
}
@@ -293,17 +395,21 @@ impl Verdict {
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 compressed = secret
.public_key()
.to_encoded_point(true)
.as_bytes()
.to_vec();
LocalKey { secret, compressed }
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