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:
Generated
+149
@@ -222,6 +222,12 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base16ct"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
@@ -308,6 +314,8 @@ dependencies = [
|
|||||||
"bikecontrol-core",
|
"bikecontrol-core",
|
||||||
"btleplug",
|
"btleplug",
|
||||||
"futures",
|
"futures",
|
||||||
|
"p256",
|
||||||
|
"rand_core",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -641,6 +649,12 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -724,6 +738,18 @@ version = "0.8.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
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]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -893,6 +919,16 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -962,6 +998,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1103,6 +1140,25 @@ version = "1.17.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
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]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.11"
|
version = "3.0.11"
|
||||||
@@ -1212,6 +1268,16 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"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]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
@@ -1505,6 +1571,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1637,6 +1704,17 @@ dependencies = [
|
|||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "gtk"
|
name = "gtk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -1731,6 +1809,24 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
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]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -2706,6 +2802,16 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -2916,6 +3022,15 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
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]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -3008,6 +3123,15 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
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]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -3253,6 +3377,19 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.36.1"
|
version = "0.36.1"
|
||||||
@@ -3625,6 +3762,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -5527,6 +5670,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
|
|||||||
@@ -18,3 +18,9 @@ uuid = { workspace = true }
|
|||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { 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"] }
|
||||||
|
|||||||
+32
-1
@@ -20,6 +20,9 @@ SUBCOMMANDS:
|
|||||||
inspect <ADDR> Connect and dump every service, characteristic and capability
|
inspect <ADDR> Connect and dump every service, characteristic and capability
|
||||||
monitor <ADDR> Stream Indoor Bike Data as raw hex alongside decoded fields
|
monitor <ADDR> Stream Indoor Bike Data as raw hex alongside decoded fields
|
||||||
set <ADDR> <TARGET> Take control and apply a target, then reset the trainer to zero
|
set <ADDR> <TARGET> Take control and apply a target, then reset the trainer to zero
|
||||||
|
unlock <ADDR> 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 <ADDR> Talk to Zwift's custom service: handshake, then log every frame
|
zwift <ADDR> Talk to Zwift's custom service: handshake, then log every frame
|
||||||
listen <ADDR> Raw GATT: dump services, characteristics and descriptors, then
|
listen <ADDR> Raw GATT: dump services, characteristics and descriptors, then
|
||||||
subscribe to everything and print each notification's
|
subscribe to everything and print each notification's
|
||||||
@@ -33,7 +36,7 @@ TARGET (for `set`):
|
|||||||
|
|
||||||
OPTIONS:
|
OPTIONS:
|
||||||
--secs <N> scan/monitor duration, or how long `set` holds the target (default:
|
--secs <N> 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
|
--all `scan`: list every peripheral, not just fitness machines
|
||||||
--name <SUBSTR> use in place of <ADDR> to match on advertised name
|
--name <SUBSTR> use in place of <ADDR> to match on advertised name
|
||||||
--no-handshake `zwift`, `listen`: subscribe and listen without writing RideOn.
|
--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.
|
/// until it is greeted, so listening alone hears silence from one.
|
||||||
handshake: bool,
|
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<String>,
|
||||||
|
},
|
||||||
/// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises
|
/// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises
|
||||||
/// it — a Click, or the trainer itself.
|
/// it — a Click, or the trainer itself.
|
||||||
Zwift {
|
Zwift {
|
||||||
@@ -124,6 +139,7 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
|||||||
let mut no_handshake = false;
|
let mut no_handshake = false;
|
||||||
let mut buttons_only = false;
|
let mut buttons_only = false;
|
||||||
let mut name: Option<String> = None;
|
let mut name: Option<String> = None;
|
||||||
|
let mut candidate: Option<String> = None;
|
||||||
let mut help = false;
|
let mut help = false;
|
||||||
let mut positional: Vec<String> = Vec::new();
|
let mut positional: Vec<String> = Vec::new();
|
||||||
|
|
||||||
@@ -147,6 +163,14 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
|||||||
.map_err(|_| anyhow!("--secs expects a whole number of seconds, got {v:?}"))?,
|
.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" => {
|
"--name" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
name = Some(
|
name = Some(
|
||||||
@@ -214,6 +238,13 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
|||||||
duration: Duration::from_secs(secs.unwrap_or(60)),
|
duration: Duration::from_secs(secs.unwrap_or(60)),
|
||||||
handshake: !no_handshake,
|
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 {
|
"zwift" => Command::Zwift {
|
||||||
device: device(&positional, 1)?,
|
device: device(&positional, 1)?,
|
||||||
duration: Duration::from_secs(secs.unwrap_or(60)),
|
duration: Duration::from_secs(secs.unwrap_or(60)),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use bikecontrol_ble::indoor_bike_data::{self, hex, IndoorBikeData};
|
|||||||
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector};
|
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector};
|
||||||
use bikecontrol_ble::{uuids, zwift, FtmsError};
|
use bikecontrol_ble::{uuids, zwift, FtmsError};
|
||||||
use bikecontrol_core::types::ControlTarget;
|
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 btleplug::platform::Peripheral;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use uuid::Uuid;
|
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.
|
/// 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 {
|
fn zwift_named(uuid: Uuid) -> String {
|
||||||
zwift::well_known_name(uuid)
|
zwift::well_known_name(uuid)
|
||||||
.map(|n| format!(" ({n})"))
|
.map(|n| format!(" ({n})"))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
mod cli;
|
mod cli;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod unlock;
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -54,6 +55,11 @@ async fn main() -> Result<()> {
|
|||||||
duration,
|
duration,
|
||||||
handshake,
|
handshake,
|
||||||
} => commands::listen(&device, duration, handshake, SCAN_TIMEOUT).await,
|
} => 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 {
|
cli::Command::Zwift {
|
||||||
device,
|
device,
|
||||||
duration,
|
duration,
|
||||||
|
|||||||
@@ -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